From 609bb0aa5c4086df178cd550fc26e13a34c618ae Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Mar 2025 14:46:24 +0100 Subject: [PATCH 01/54] Add (or update) GitHub Action (GHA) files and related config. --- {build => .config}/.remarkrc | 0 {build => .config}/.yamllint | 0 .config/hadolint.yml | 30 ++++ {build => .config}/phpcs.xml.dist | 2 +- .../workflows/dependancy-security-check.yml | 50 ------ .github/workflows/dockerfile.yml | 56 +++++++ .github/workflows/json.yml | 46 ++++++ .github/workflows/linting.yml | 37 ----- .github/workflows/markdown.yml | 42 +++++ .github/workflows/php-version-sniff.yml | 31 ---- .github/workflows/php.yml | 152 ++++++++++++++++++ .github/workflows/quality-checks.yml | 23 --- .github/workflows/shell.yml | 60 +++++++ .../{ci.yml => solid-tests-suites.yml} | 12 +- .github/workflows/xml.yml | 45 ++++++ .github/workflows/yaml.yml | 42 +++++ run-solid-test-suite.sh | 2 +- 17 files changed, 481 insertions(+), 149 deletions(-) rename {build => .config}/.remarkrc (100%) rename {build => .config}/.yamllint (100%) create mode 100644 .config/hadolint.yml rename {build => .config}/phpcs.xml.dist (98%) delete mode 100644 .github/workflows/dependancy-security-check.yml create mode 100644 .github/workflows/dockerfile.yml create mode 100644 .github/workflows/json.yml delete mode 100644 .github/workflows/linting.yml create mode 100644 .github/workflows/markdown.yml delete mode 100644 .github/workflows/php-version-sniff.yml create mode 100644 .github/workflows/php.yml delete mode 100644 .github/workflows/quality-checks.yml create mode 100644 .github/workflows/shell.yml rename .github/workflows/{ci.yml => solid-tests-suites.yml} (96%) create mode 100644 .github/workflows/xml.yml create mode 100644 .github/workflows/yaml.yml diff --git a/build/.remarkrc b/.config/.remarkrc similarity index 100% rename from build/.remarkrc rename to .config/.remarkrc diff --git a/build/.yamllint b/.config/.yamllint similarity index 100% rename from build/.yamllint rename to .config/.yamllint diff --git a/.config/hadolint.yml b/.config/hadolint.yml new file mode 100644 index 00000000..541ddd31 --- /dev/null +++ b/.config/hadolint.yml @@ -0,0 +1,30 @@ +--- +# For all available rules see: https://github.com/hadolint/hadolint#rules +ignored: + - DL3008 # We do not want to pin versions in apt get install. + - DL3018 # We do not want to pin versions in apk add + +# For full details see https://github.com/hadolint/hadolint#configure +# +# The following keys are available: +# +# failure-threshold: string # name of threshold level (error | warning | info | style | ignore | none) +# format: string # Output format (tty | json | checkstyle | codeclimate | gitlab_codeclimate | gnu | codacy) +# label-schema: # See https://github.com/hadolint/hadolint#linting-labels for details +# author: string # Your name +# contact: string # email address +# created: timestamp # rfc3339 datetime +# version: string # semver +# documentation: string # url +# git-revision: string # hash +# license: string # spdx +# no-color: boolean # true | false +# no-fail: boolean # true | false +# override: +# error: [string] # list of rules +# warning: [string] # list of rules +# info: [string] # list of rules +# style: [string] # list of rules +# strict-labels: boolean # true | false +# disable-ignore-pragma: boolean # true | false +# trustedRegistries: string | [string] # registry or list of registries diff --git a/build/phpcs.xml.dist b/.config/phpcs.xml.dist similarity index 98% rename from build/phpcs.xml.dist rename to .config/phpcs.xml.dist index fc1286ec..a6147742 100644 --- a/build/phpcs.xml.dist +++ b/.config/phpcs.xml.dist @@ -15,7 +15,7 @@ . - */vendor/*|*/build/* + */vendor/*|*/.config/* diff --git a/.github/workflows/dependancy-security-check.yml b/.github/workflows/dependancy-security-check.yml deleted file mode 100644 index 285f62b6..00000000 --- a/.github/workflows/dependancy-security-check.yml +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: Security check - -on: - - push - - pull_request - # Allow manually triggering the workflow. - - workflow_dispatch - -# Cancels all previous workflow runs for the same branch that have not yet completed. -concurrency: - # The concurrency group contains the workflow name and the branch name. - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - security-check: - runs-on: ubuntu-latest - name: "Security check" - - strategy: - matrix: - php: ['8.2'] - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Install PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - coverage: none - - # Install dependencies and handle caching in one go. - # @link https://github.com/marketplace/actions/install-composer-dependencies - - name: Install Composer dependencies - uses: "ramsey/composer-install@v2" - with: - working-directory: "solid" - - - name: Download security checker - # yamllint disable-line rule:line-length - run: wget -P . https://github.com/fabpot/local-php-security-checker/releases/download/v2.0.4/local-php-security-checker_2.0.4_linux_amd64 - - - name: Make security checker executable - run: chmod +x ./local-php-security-checker_2.0.4_linux_amd64 - - - name: Check against insecure dependencies - run: ./local-php-security-checker_2.0.4_linux_amd64 --path=solid/composer.lock diff --git a/.github/workflows/dockerfile.yml b/.github/workflows/dockerfile.yml new file mode 100644 index 00000000..0fdbc00a --- /dev/null +++ b/.github/workflows/dockerfile.yml @@ -0,0 +1,56 @@ +--- +name: Dockerfile Quality Assistance + +on: + # This event occurs when there is activity on a pull request. The workflow + # will be run against the commits, after merge to the target branch (main). + pull_request: + branches: [ main ] + paths: + - '.config/hadolint.yml' + - '.dockerignore' + - '.github/workflows/dockerfile.yml' + - 'Dockerfile' + # Docker project specific, Dockerfile "COPY" and "ADD" entries. + - 'solid/' + - 'init-live.sh' + - 'init.sh' + - 'site.conf' + types: [ opened, reopened, synchronize ] + # This event occurs when there is a push to the repository. + push: + paths: + - '.config/hadolint.yml' + - '.dockerignore' + - '.github/workflows/dockerfile.yml' + - 'Dockerfile' + # Docker project specific, Dockerfile "COPY" and "ADD" entries. + - 'solid/' + - 'init-live.sh' + - 'init.sh' + - 'site.conf' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + # Needed to allow the "concurrency" section to cancel a workflow run. + actions: write + +jobs: + # 03.quality.docker.lint.yml + lint-dockerfile: + name: Dockerfile Linting + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: docker://pipelinecomponents/hadolint + with: + args: >- + hadolint + --config .config/hadolint.yml + Dockerfile diff --git a/.github/workflows/json.yml b/.github/workflows/json.yml new file mode 100644 index 00000000..7e83269e --- /dev/null +++ b/.github/workflows/json.yml @@ -0,0 +1,46 @@ +--- +name: JSON Quality Assistance + +on: + # This event occurs when there is activity on a pull request. The workflow + # will be run against the commits, after merge to the target branch (main). + pull_request: + branches: [ main ] + paths: + - '**.json' + - '.github/workflows/json.yml' + types: [ opened, reopened, synchronize ] + # This event occurs when there is a push to the repository. + push: + paths: + - '**.json' + - '.github/workflows/json.yml' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + # Needed to allow the "concurrency" section to cancel a workflow run. + actions: write + +jobs: + # 01.preflight.json.lint-syntax.yml + lint-json-syntax: + name: JSON Syntax Linting + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: docker://pipelinecomponents/jsonlint + with: + args: >- + find . + -not -path '*/.git/*' + -not -path '*/node_modules/*' + -not -path '*/vendor/*' + -name '*.json' + -type f + -exec jsonlint --quiet {} ; diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml deleted file mode 100644 index cbca0f41..00000000 --- a/.github/workflows/linting.yml +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: Linting jobs - -on: - - push - - pull_request - -jobs: - lint-json: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: "docker://pipelinecomponents/jsonlint:latest" - with: - args: "find . -not -path './.git/*' -name '*.json' -type f" - - lint-php: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: pipeline-components/php-linter@master - - lint-markdown: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: pipeline-components/remark-lint@master - with: - options: --rc-path=build/.remarkrc --ignore-pattern='*/vendor/*' - - lint-yaml: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: pipeline-components/yamllint@master - with: - options: --config-file=build/.yamllint diff --git a/.github/workflows/markdown.yml b/.github/workflows/markdown.yml new file mode 100644 index 00000000..581b9c7e --- /dev/null +++ b/.github/workflows/markdown.yml @@ -0,0 +1,42 @@ +--- +name: Markdown Quality Assistance + +on: + # This event occurs when there is activity on a pull request. The workflow + # will be run against the commits, after merge to the target branch (main). + pull_request: + branches: [ main ] + paths: + - '**.md' + - '.github/workflows/markdown.yml' + types: [ opened, reopened, synchronize ] + # This event occurs when there is a push to the repository. + push: + paths: + - '**.md' + - '.github/workflows/markdown.yml' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + # Needed to allow the "concurrency" section to cancel a workflow run. + actions: write + +jobs: + # 01.quality.markdown.lint-syntax.yml + lint-markdown-syntax: + name: Markdown Linting + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: docker://pipelinecomponents/remark-lint + with: + args: >- + remark + --rc-path=.config/.remarkrc + --ignore-pattern='*/vendor/*' diff --git a/.github/workflows/php-version-sniff.yml b/.github/workflows/php-version-sniff.yml deleted file mode 100644 index 30cfd373..00000000 --- a/.github/workflows/php-version-sniff.yml +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: PHP Version Compatibility - -on: - - push - - pull_request - # Allow manually triggering the workflow. - - workflow_dispatch - -# Cancels all previous workflow runs for the same branch that have not yet completed. -concurrency: - # The concurrency group contains the workflow name and the branch name. - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - php-codesniffer: - strategy: - matrix: - php: [ '8.1' ] - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 - - uses: pipeline-components/php-codesniffer@master - with: - options: >- - -s - --ignore='*vendor/*' - --standard=PHPCompatibility - --extensions=php - --runtime-set testVersion ${{ matrix.php }} diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 00000000..8bd8937d --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,152 @@ +--- +name: PHP Quality Assistance + +on: + # This event occurs when there is activity on a pull request. The workflow + # will be run against the commits, after merge to the target branch (main). + pull_request: + paths: + - '**.php' + - '.config/phpcs.xml.dist' + - '.config/phpunit.xml.dist' + - '.github/workflows/php.yml' + - 'composer.json' + - 'composer.lock' + branches: [ main ] + types: [ opened, reopened, synchronize ] + # This event occurs when there is a push to the repository. + push: + paths: + - '**.php' + - '.config/phpcs.xml.dist' + - '.config/phpunit.xml.dist' + - '.github/workflows/php.yml' + - 'composer.json' + - 'composer.lock' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + # Needed to allow the "concurrency" section to cancel a workflow run. + actions: write + +jobs: + # 01.preflight.php.lint-syntax.yml + lint-php-syntax: + name: PHP Syntax Linting + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: docker://pipelinecomponents/php-linter + with: + args: >- + parallel-lint + --exclude .git + --exclude vendor + --no-progress + . + # 01.quality.php.validate.dependencies-file.yml + validate-dependencies-file: + name: Validate dependencies file + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: >- + composer validate + --check-lock + --no-plugins + --no-scripts + --strict + working-directory: "solid" + # 02.test.php.test-unit.yml + php-unittest: + name: PHP Unit Tests + needs: + - lint-php-syntax + - validate-dependencies-file + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: + - '8.0' # from 2020-11 to 2022-11 (2023-11) + - '8.1' # from 2021-11 to 2023-11 (2025-12) + - '8.2' # from 2022-12 to 2024-12 (2026-12) + - '8.3' # from 2023-11 to 2025-12 (2027-12) + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + coverage: xdebug + ini-values: error_reporting=E_ALL, display_errors=On + php-version: ${{ matrix.php }} + - name: Install and Cache Composer dependencies + uses: "ramsey/composer-install@v2" + with: + working-directory: "solid" + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GITHUB_TOKEN }}"}}' + - run: bin/phpunit --configuration .config/phpunit.xml.dist + # 03.quality.php.scan.dependencies-vulnerabilities.yml + scan-dependencies-vulnerabilities: + name: Scan Dependencies Vulnerabilities + needs: + - validate-dependencies-file + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: >- + composer audit + --abandoned=report + --locked + --no-dev + --no-plugins + --no-scripts + working-directory: "solid" + # 03.quality.php.lint-quality.yml + php-lint-quality: + needs: + - lint-php-syntax + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: docker://pipelinecomponents/php-codesniffer + with: + args: >- + phpcs + -s + --extensions=php + --ignore='*vendor/*' + --standard=.config/phpcs.xml.dist + . + # 03.quality.php.lint-version-compatibility.yml + php-check-version-compatibility: + name: PHP Version Compatibility + needs: + - lint-php-syntax + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: + - '8.0' # from 2020-11 to 2022-11 (2023-11) + - '8.1' # from 2021-11 to 2023-11 (2025-12) + - '8.2' # from 2022-12 to 2024-12 (2026-12) + - '8.3' # from 2023-11 to 2025-12 (2027-12) + steps: + - uses: actions/checkout@v4 + - uses: docker://pipelinecomponents/php-codesniffer + with: + args: >- + phpcs + -s + --extensions=php + --ignore='*vendor/*' + --runtime-set testVersion ${{ matrix.php }} + --standard=PHPCompatibility + . diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml deleted file mode 100644 index 885c567a..00000000 --- a/.github/workflows/quality-checks.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Quality Assurance jobs - -on: - - push - - pull_request - -jobs: - composer-validate: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: "docker://composer" - with: - args: composer validate --strict --working-dir=solid/ - - php-codesniffer: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: pipeline-components/php-codesniffer@master - with: - options: --standard=build/phpcs.xml.dist diff --git a/.github/workflows/shell.yml b/.github/workflows/shell.yml new file mode 100644 index 00000000..3ef3d51d --- /dev/null +++ b/.github/workflows/shell.yml @@ -0,0 +1,60 @@ +--- +name: Shell Script Quality Assistance + +on: + # This event occurs when there is activity on a pull request. The workflow + # will be run against the commits, after merge to the target branch (main). + pull_request: + branches: [ main ] + paths: + - '**.bash' + - '**.sh' + - '.github/workflows/shell.yml' + types: [ opened, reopened, synchronize ] + # This event occurs when there is a push to the repository. + push: + paths: + - '**.bash' + - '**.sh' + - '.github/workflows/shell.yml' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + # Needed to allow the "concurrency" section to cancel a workflow run. + actions: write + +jobs: + # 01.preflight.shell.lint-syntax.yml + lint-shell-syntax: + name: Shell Syntax Linting + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: >- + find . + -name '*.sh' + -not -path '*/.git/*' + -type f + -print0 + | xargs -0 -P"$(nproc)" -I{} bash -n "{}" + # 03.quality.shell.lint.yml + lint-shell-quality: + name: Shell Quality Linting + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: docker://pipelinecomponents/shellcheck + with: + args: >- + find . + -not -path '*/.git/*' + -name '*.sh' + -type f + -print0 + | xargs -0 -r -n1 shellcheck diff --git a/.github/workflows/ci.yml b/.github/workflows/solid-tests-suites.yml similarity index 96% rename from .github/workflows/ci.yml rename to .github/workflows/solid-tests-suites.yml index 4aa46902..895d0fb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/solid-tests-suites.yml @@ -46,15 +46,15 @@ jobs: | tr --complement --squeeze-repeats '[:alnum:]._-' '_')" \ >> "${GITHUB_ENV}" - - uses: actions/cache@v3 + - uses: actions/cache@v4 id: cache-solid-nextcloud-docker with: path: cache/solid-nextcloud key: solid-nextcloud-docker-${{ matrix.nextcloud_version }}-${{ github.sha }} - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: docker/login-action@v2 + - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -101,15 +101,15 @@ jobs: | tr --complement --squeeze-repeats '[:alnum:]._-' '_')" \ >> "${GITHUB_ENV}" - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/cache@v3 + - uses: actions/cache@v4 id: cache-solid-nextcloud-docker with: path: cache/solid-nextcloud key: solid-nextcloud-docker-${{ matrix.nextcloud_version }}-${{ github.sha }} - - uses: docker/login-action@v2 + - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/xml.yml b/.github/workflows/xml.yml new file mode 100644 index 00000000..62d0c2eb --- /dev/null +++ b/.github/workflows/xml.yml @@ -0,0 +1,45 @@ +--- +name: XML Quality Assistance + +on: + # This event occurs when there is activity on a pull request. The workflow + # will be run against the commits, after merge to the target branch (main). + pull_request: + branches: [ main ] + paths: + - '**.xml' + - '**.xml.dist' + - '.github/workflows/xml.yml' + types: [ opened, reopened, synchronize ] + # This event occurs when there is a push to the repository. + push: + paths: + - '**.xml' + - '**.xml.dist' + - '.github/workflows/xml.yml' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + # Needed to allow the "concurrency" section to cancel a workflow run. + actions: write + +jobs: + # 01.preflight.xml.lint-syntax.yml + lint-xml: + name: XML Linting + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: docker://pipelinecomponents/xmllint + with: + args: >- + find . + -iname '*.xml' + -type f + -exec xmllint --noout {} \+ diff --git a/.github/workflows/yaml.yml b/.github/workflows/yaml.yml new file mode 100644 index 00000000..ad8fb9d3 --- /dev/null +++ b/.github/workflows/yaml.yml @@ -0,0 +1,42 @@ +--- +name: YAML Quality Assistance + +on: + # This event occurs when there is activity on a pull request. The workflow + # will be run against the commits, after merge to the target branch (main). + pull_request: + branches: [ main ] + paths: + - '**.yml' + - '**.yaml' + types: [ opened, reopened, synchronize ] + # This event occurs when there is a push to the repository. + push: + paths: + - '**.yml' + - '**.yaml' + # Allow manually triggering the workflow. + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + # Needed to allow the "concurrency" section to cancel a workflow run. + actions: write + +jobs: + # 01.preflight.yaml.lint.yml + lint-yaml: + name: YAML Linting + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: docker://pipelinecomponents/yamllint + with: + args: >- + yamllint + --config-file=.config/.yamllint + . diff --git a/run-solid-test-suite.sh b/run-solid-test-suite.sh index 4416b4cb..9f35e320 100755 --- a/run-solid-test-suite.sh +++ b/run-solid-test-suite.sh @@ -2,7 +2,7 @@ set -e -# Note that .github/workflows/ci.yml does not use this, this function is just for manual runs of this script. +# Note that .github/workflows/solid-tests-suites.yml does not use this, this function is just for manual runs of this script. # You can pick different values for the NEXTCLOUD_VERSION build arg, as required: function setup { docker build -t pubsub-server https://github.com/pdsinterop/php-solid-pubsub-server.git#main From fd20e418457b99444bd12ef6b1cda161cf686a75 Mon Sep 17 00:00:00 2001 From: Auke van Slooten Date: Tue, 29 Apr 2025 15:39:12 +0200 Subject: [PATCH 02/54] start of user-specific subdomains support --- docker-compose-dev.yml | 37 ++++++++++++++++++++++ solid/css/settings-admin.css | 6 +++- solid/lib/Controller/AppController.php | 8 ++++- solid/lib/Controller/ProfileController.php | 17 ++++++++++ solid/lib/Controller/StorageController.php | 4 +++ solid/lib/Sections/SolidAdmin.php | 1 + solid/lib/ServerConfig.php | 14 +++++++- solid/lib/Settings/SolidAdmin.php | 11 +++++-- solid/templates/admin.php | 13 ++++++-- 9 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 docker-compose-dev.yml diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 00000000..3e402d3d --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,37 @@ +services: + db: + environment: + MYSQL_DATABASE: nextcloud + MYSQL_PASSWORD: nextcloud + MYSQL_ROOT_PASSWORD: root + MYSQL_USER: nextcloud + image: mysql:8.0.32 + ports: + - "3306:3306" + # To start with a populated database, mount an SQL file + # volumes: + # - ./init.sql:/docker-entrypoint-initdb.d/init.sql + + nextcloud: + depends_on: + - db + environment: + - MARIADB_ROOT_PASSWORD=nextcloud + - MYSQL_HOST=db + expose: + - 443 + image: ghcr.io/pdsinterop/solid-nextcloud:main-30 + ports: + - "443:443" + volumes: + - ./solid/:/var/www/html/apps/solid/ + - ./site.conf:/etc/apache2/sites-enabled/000-default.conf + + pubsub: + depends_on: + - nextcloud + expose: + - 8080 + image: ghcr.io/pdsinterop/php-solid-pubsub-server + ports: + - "8080:8080" \ No newline at end of file diff --git a/solid/css/settings-admin.css b/solid/css/settings-admin.css index 1facf521..9ca74f0a 100644 --- a/solid/css/settings-admin.css +++ b/solid/css/settings-admin.css @@ -1,4 +1,4 @@ -#solid-admin label { +#solid-admin label.narrow { width: 160px; vertical-align: top; display: block; @@ -8,4 +8,8 @@ height: 240px; font-size: 12px; font-family: monospace; +} +#solid-admin input.textaligned { + height: 1rem; + min-height: unset; } \ No newline at end of file diff --git a/solid/lib/Controller/AppController.php b/solid/lib/Controller/AppController.php index 94addc28..4c2025af 100644 --- a/solid/lib/Controller/AppController.php +++ b/solid/lib/Controller/AppController.php @@ -20,7 +20,9 @@ class AppController extends Controller { private $urlGenerator; private $config; - public function __construct($AppName, IRequest $request, IConfig $config, IUserManager $userManager, IManager $contactsManager, IURLGenerator $urlGenerator, $userId){ + public function __construct($AppName, IRequest $request, IConfig $config, IUserManager $userManager, IManager $contactsManager, IURLGenerator $urlGenerator, $userId + // , bool $userDomains + ){ parent::__construct($AppName, $request); $this->userId = $userId; $this->userManager = $userManager; @@ -28,6 +30,7 @@ public function __construct($AppName, IRequest $request, IConfig $config, IUserM $this->request = $request; $this->urlGenerator = $urlGenerator; $this->config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); + // $this->userDomains = $userDomains; } private function getUserApps($userId) { @@ -67,6 +70,9 @@ private function getProfilePage() { private function getStorageUrl($userId) { $storageUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleHead", array("userId" => $userId, "path" => "foo"))); $storageUrl = preg_replace('/foo$/', '', $storageUrl); +// if ($this->userDomains) { + $storageUrl = $userId.'.'.$storageUrl; +// } return $storageUrl; } /** diff --git a/solid/lib/Controller/ProfileController.php b/solid/lib/Controller/ProfileController.php index 578e6239..b4ff9b27 100644 --- a/solid/lib/Controller/ProfileController.php +++ b/solid/lib/Controller/ProfileController.php @@ -131,9 +131,24 @@ private function getProfileUrl($userId) { $profileUrl = preg_replace('/foo$/', '', $profileUrl); return $profileUrl; } + + private function build_url(array $parts) { + return (isset($parts['scheme']) ? "{$parts['scheme']}:" : '') . + (isset($parts['host']) ? "//{$parts['host']}" : '') . + (isset($parts['port']) ? ":{$parts['port']}" : '') . + (isset($parts['path']) ? "{$parts['path']}" : '') . + (isset($parts['query']) ? "?{$parts['query']}" : '') . + (isset($parts['fragment']) ? "#{$parts['fragment']}" : ''); + } + private function getStorageUrl($userId) { $storageUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleHead", array("userId" => $userId, "path" => "foo"))); $storageUrl = preg_replace('/foo$/', '/', $storageUrl); +// if ($this->userDomains) { + $url = parse_url($storageUrl); + $url['host'] = $userId.'.'.$url['host']; + $storageUrl = $this->build_url($url); +// } return $storageUrl; } @@ -190,6 +205,7 @@ public function handleRequest($userId, $path) { * @NoCSRFRequired */ public function handleGet($userId, $path) { + //TODO: check that the $userId matches the userDomain, if enabled. return $this->handleRequest($userId, $path); } @@ -287,6 +303,7 @@ private function getUserProfile($userId) { } } } + //TODO: privateTypeIndex and publisTypeIndex need to user getStorageURL if ($user !== null) { $profile = array( 'id' => $userId, diff --git a/solid/lib/Controller/StorageController.php b/solid/lib/Controller/StorageController.php index c5a66735..bbaddd52 100644 --- a/solid/lib/Controller/StorageController.php +++ b/solid/lib/Controller/StorageController.php @@ -93,8 +93,12 @@ private function getUserProfile($userId) { private function getStorageUrl($userId) { $storageUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleHead", array("userId" => $userId, "path" => "foo"))); $storageUrl = preg_replace('/foo$/', '', $storageUrl); +// if ($this->userDomains) { + $storageUrl = $userId.'.'.$storageUrl; +// } return $storageUrl; } + private function generateDefaultAcl($userId) { $defaultAcl = <<< EOF # Root ACL resource for the user account diff --git a/solid/lib/Sections/SolidAdmin.php b/solid/lib/Sections/SolidAdmin.php index 59a5c9b1..1e66b429 100644 --- a/solid/lib/Sections/SolidAdmin.php +++ b/solid/lib/Sections/SolidAdmin.php @@ -29,4 +29,5 @@ public function getName(): string { public function getPriority(): int { return 98; } + } diff --git a/solid/lib/ServerConfig.php b/solid/lib/ServerConfig.php index 8313b1f8..14900ce9 100644 --- a/solid/lib/ServerConfig.php +++ b/solid/lib/ServerConfig.php @@ -13,16 +13,21 @@ class ServerConfig extends BaseServerConfig { private IConfig $config; private IUrlGenerator $urlGenerator; private IUserManager $userManager; +// private bool $userDomains; /** * @param IConfig $config * @param IUrlGenerator $urlGenerator * @param IUserManager $userManager + * @param bool $userDomains */ - public function __construct(IConfig $config, IUrlGenerator $urlGenerator, IUserManager $userManager) { + public function __construct(IConfig $config, IUrlGenerator $urlGenerator, IUserManager $userManager +// , bool $userDomains + ) { $this->config = $config; $this->userManager = $userManager; $this->urlGenerator = $urlGenerator; +// $this->userDomains = $userDomains; parent::__construct($config); } @@ -62,4 +67,11 @@ public function setProfileData($userId, $profileData) { $user->setDisplayName($fields['name']); } } + // public function getUserDomains() { + // return $this->userDomains; + // } + // public function setUserDomains($userDomains) { + // $this->userDomains = $userDomains; + // } + } diff --git a/solid/lib/Settings/SolidAdmin.php b/solid/lib/Settings/SolidAdmin.php index 2fc684f8..d0f56ec2 100644 --- a/solid/lib/Settings/SolidAdmin.php +++ b/solid/lib/Settings/SolidAdmin.php @@ -11,11 +11,15 @@ class SolidAdmin implements ISettings { private IL10N $l; private IConfig $config; private BaseServerConfig $serverConfig; + // private Bool $userDomains; - public function __construct(IConfig $config, IL10N $l) { + public function __construct(IConfig $config, IL10N $l + // , bool $userDomains + ) { $this->config = $config; $this->l = $l; $this->serverConfig = new BaseServerConfig($config); + // $this->userDomains = $userDomains; } /** @@ -26,8 +30,9 @@ public function getForm() { $parameters = [ 'privateKey' => $this->serverConfig->getPrivateKey(), - 'encryptionKey' => $this->serverConfig->getEncryptionKey(), - 'clients' => $allClients + 'encryptionKey' => $this->serverConfig->getEncryptionKey(), + 'clients' => $allClients, + // 'userDomains' => (int) $this->userDomains ]; return new TemplateResponse('solid', 'admin', $parameters, ''); diff --git a/solid/templates/admin.php b/solid/templates/admin.php index c414498c..bfdd0383 100644 --- a/solid/templates/admin.php +++ b/solid/templates/admin.php @@ -6,14 +6,22 @@ ?>
-

t('Solid OpenID Connect Settings')); ?>

+

t('Solid Server Settings')); ?>

+

+ +

t('Solid OpenID Connect Settings')); ?>

+

+ -

\ No newline at end of file From 6b75dd86325751753005377b46e92d4a6fbe9f2c Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Fri, 2 May 2025 16:43:51 +0200 Subject: [PATCH 03/54] Add first draft GetStorageUrlTrait based on existing code. --- solid/lib/Controller/GetStorageUrlTrait.php | 68 +++++++ .../Controller/GetStorageUrlTraitTest.php | 168 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 solid/lib/Controller/GetStorageUrlTrait.php create mode 100644 solid/tests/Unit/Controller/GetStorageUrlTraitTest.php diff --git a/solid/lib/Controller/GetStorageUrlTrait.php b/solid/lib/Controller/GetStorageUrlTrait.php new file mode 100644 index 00000000..9bc65d1e --- /dev/null +++ b/solid/lib/Controller/GetStorageUrlTrait.php @@ -0,0 +1,68 @@ +config = $config; + } + + final public function setUrlGenerator(IURLGenerator $urlGenerator): void + { + $this->urlGenerator = $urlGenerator; + } + + ////////////////////////////// CLASS PROPERTIES \\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + protected BaseServerConfig $config; + protected IURLGenerator $urlGenerator; + + /////////////////////////////// PROTECTED API \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + /** + * @FIXME: Deduplicate multiple declarations of getStorageUrl() + * @FIXME: Add check for bob.nextcloud.local/solid/alice to throw 404 + * @TODO: Use route without `@alice` in /apps/solid/@alice/profile/card#me when user-domains are enabled + */ + protected function getStorageUrl($userId) { + $routeUrl = $this->urlGenerator->linkToRoute( + 'solid.storage.handleHead', + ['userId' => $userId, 'path' => 'foo'] + ); + + $storageUrl = $this->urlGenerator->getAbsoluteURL($routeUrl); + + // (?) $storageUrl = preg_replace('/foo$/', '', $storageUrl); + $storageUrl = preg_replace('/foo$/', '/', $storageUrl); + + // @FIXME: $this->getUserSubDomainsEnabled should contain true/false from (?) somewhere + if ($this->config->getUserSubDomainsEnabled()) { + // @FIXME: Check whether we are already on a domain that starts with $userId, + // in which case it should not be prepended again + $url = parse_url($storageUrl); + $url['host'] = $userId . '.' . $url['host']; // $storageUrl = $userId . '.' . $storageUrl; + $storageUrl = $this->build_url($url); + } + + return $storageUrl; + } + + ////////////////////////////// UTILITY METHODS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + private function build_url(array $parts) { + // @FIXME: Replace with existing more robust URL builder + return (isset($parts['scheme']) ? "{$parts['scheme']}:" : '') . + (isset($parts['host']) ? "//{$parts['host']}" : '') . + (isset($parts['port']) ? ":{$parts['port']}" : '') . + (isset($parts['path']) ? "{$parts['path']}" : '') . + (isset($parts['query']) ? "?{$parts['query']}" : '') . + (isset($parts['fragment']) ? "#{$parts['fragment']}" : ''); + } +} diff --git a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php new file mode 100644 index 00000000..9bee9951 --- /dev/null +++ b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php @@ -0,0 +1,168 @@ +trait = new class { + use GetStorageUrlTrait; + + public function _getStorageUrl($userId) + { + $class = new ReflectionObject($this); + $method = $class->getMethod('getStorageUrl'); + // Only needed for PHP 8.1 and lower + $method->setAccessible(true); + + return $method->invokeArgs($this, [$userId]); + } + }; + } + + /////////////////////////////////// TESTS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + /** + * @testdox GetStorageUrlTrait should complain when called before given a UrlGenerator + * @covers ::getStorageUrl + */ + public function testGetStorageUrlWithoutUrlGenerator() + { + $this->expectException(Error::class); + $this->expectExceptionMessage('urlGenerator must not be accessed before initialization'); + + $this->trait->_getStorageUrl(self::MOCK_USER_ID); + } + + /** + * @testdox GetStorageUrlTrait should complain when called before given a Configuration + * @covers ::getStorageUrl + */ + public function testGetStorageUrlWithoutConfig() + { + $mockUrlGenerator = $this->getMockUrlGenerator(self::MOCK_URL); + + $this->expectException(Error::class); + $this->expectExceptionMessage('config must not be accessed before initialization'); + + $this->trait->setUrlGenerator($mockUrlGenerator); + + $this->trait->_getStorageUrl(self::MOCK_USER_ID); + } + + /** + * @testdox GetStorageUrlTrait should return a string when called with a UrlGenerator and Configuration + * @covers ::getStorageUrl + * @dataProvider provideSubDomainsDisabledUrls + */ + public function testGetStorageUrlWithUserSubDomainsDisabled($url, $userId, $expected) + { + $mockConfig = $this->getMockConfig(); + $mockUrlGenerator = $this->getMockUrlGenerator($url); + + $this->trait->setUrlGenerator($mockUrlGenerator); + $this->trait->setConfig($mockConfig); + + $actual = $this->trait->_getStorageUrl($userId); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox GetStorageUrlTrait should return a string when called with a UrlGenerator and Configuration + * @covers ::getStorageUrl + * @covers ::build_url + * + * @dataProvider provideSubDomainsEnabledUrls + */ + public function testGetStorageUrlWithUserSubDomainsEnabled($url, $userId, $expected) + { + $mockUrlGenerator = $this->getMockUrlGenerator($url); + $mockConfig = $this->getMockConfig(true); + + $this->trait->setUrlGenerator($mockUrlGenerator); + $this->trait->setConfig($mockConfig); + + $actual = $this->trait->_getStorageUrl($userId); + + $this->assertEquals($expected, $actual); + } + + ////////////////////////////// MOCKS AND STUBS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + public function getMockConfig($enabled = false): MockObject|BaseServerConfig + { + $mockConfig = $this->getMockBuilder(BaseServerConfig::class) + ->disableOriginalConstructor() + ->getMock(); + + $mockConfig->expects($this->any()) + ->method('getUserSubDomainsEnabled') + ->willReturn($enabled); + + return $mockConfig; + } + + public function getMockUrlGenerator($url): MockObject|IURLGenerator + { + $mockUrlGenerator = $this + ->getMockBuilder(IURLGenerator::class) + ->getMock(); + + $mockUrlGenerator->expects($this->atLeast(1)) + ->method('getAbsoluteURL') + ->willReturn($url); + + return $mockUrlGenerator; + } + + /////////////////////////////// DATAPROVIDERS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + public function provideSubDomainsDisabledUrls() + { + return [ + ['url' => 'example.com/foo', 'userId' => 'alice', 'expected' => 'example.com//'], + ['url' => 'https://example.com/foo', 'userId' => 'alice', 'expected' => 'https://example.com//'], + ['url' => 'http://example.com/foo', 'userId' => 'alice', 'expected' => 'http://example.com//'], + ['url' => 'https://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'https://bob.example.com//'], + ['url' => 'http://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'http://bob.example.com//'], + ['url' => 'https://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'https://bob.example.com//'], + ['url' => 'http://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'http://bob.example.com//'], + ]; + } + + public function provideSubDomainsEnabledUrls() + { + return [ + // @FIXME: "Undefined array key 'host'" caused by the use of `parse_url` + // ['url' => 'example.com/foo', 'userId' => 'alice', 'expected' => 'example.com//'], + + ['url' => 'https://example.com/foo', 'userId' => 'alice', 'expected' => 'https://alice.example.com//'], + ['url' => 'http://example.com/foo', 'userId' => 'alice', 'expected' => 'http://alice.example.com//'], + ['url' => 'https://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'https://alice.bob.example.com//'], + ['url' => 'http://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'http://alice.bob.example.com//'], + ['url' => 'https://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'https://bob.bob.example.com//'], + ['url' => 'http://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'http://bob.bob.example.com//'], + ]; + } +} From 2174d8e07dd85b103d89e643e6764cee4000d304 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Fri, 2 May 2025 16:56:26 +0200 Subject: [PATCH 04/54] Add check for when a domain already starts with $userId, in which case it should not be prepended again. --- solid/lib/Controller/GetStorageUrlTrait.php | 7 +++++-- solid/tests/Unit/Controller/GetStorageUrlTraitTest.php | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/solid/lib/Controller/GetStorageUrlTrait.php b/solid/lib/Controller/GetStorageUrlTrait.php index 9bc65d1e..f697029f 100644 --- a/solid/lib/Controller/GetStorageUrlTrait.php +++ b/solid/lib/Controller/GetStorageUrlTrait.php @@ -44,9 +44,12 @@ protected function getStorageUrl($userId) { // @FIXME: $this->getUserSubDomainsEnabled should contain true/false from (?) somewhere if ($this->config->getUserSubDomainsEnabled()) { - // @FIXME: Check whether we are already on a domain that starts with $userId, - // in which case it should not be prepended again $url = parse_url($storageUrl); + + if (strpos($url['host'], $userId . '.') !== false) { + $url['host'] = str_replace($userId . '.', '', $url['host']); + } + $url['host'] = $userId . '.' . $url['host']; // $storageUrl = $userId . '.' . $storageUrl; $storageUrl = $this->build_url($url); } diff --git a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php index 9bee9951..ee7a5905 100644 --- a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php +++ b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php @@ -161,8 +161,8 @@ public function provideSubDomainsEnabledUrls() ['url' => 'http://example.com/foo', 'userId' => 'alice', 'expected' => 'http://alice.example.com//'], ['url' => 'https://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'https://alice.bob.example.com//'], ['url' => 'http://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'http://alice.bob.example.com//'], - ['url' => 'https://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'https://bob.bob.example.com//'], - ['url' => 'http://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'http://bob.bob.example.com//'], + ['url' => 'https://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'https://bob.example.com//'], + ['url' => 'http://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'http://bob.example.com//'], ]; } } From 18ee49cc80959f2dd7505a907ae7e47a86ded0b6 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Fri, 2 May 2025 17:34:50 +0200 Subject: [PATCH 05/54] Add UserSubDomainsEnabled setter and getter to BaseServerConfig. --- solid/lib/BaseServerConfig.php | 10 +- solid/lib/Controller/GetStorageUrlTrait.php | 1 - solid/lib/ServerConfig.php | 16 +-- solid/tests/Unit/BaseServerConfigTest.php | 105 ++++++++++++++++++++ 4 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 solid/tests/Unit/BaseServerConfigTest.php diff --git a/solid/lib/BaseServerConfig.php b/solid/lib/BaseServerConfig.php index 2b9de07c..0debc247 100644 --- a/solid/lib/BaseServerConfig.php +++ b/solid/lib/BaseServerConfig.php @@ -4,7 +4,7 @@ use OCP\IConfig; class BaseServerConfig { - private IConfig $config; + protected IConfig $config; /** * @param IConfig $config @@ -182,4 +182,12 @@ public function getClientRegistration($clientId) { $data = $this->config->getAppValue('solid', "client-" . $clientId, "{}"); return json_decode($data, true); } + + public function getUserSubDomainsEnabled(): bool { + return $this->config->getAppValue('solid', 'userSubDomainsEnabled', false); + } + + public function setUserSubDomainsEnabled(bool $enabled) { + $this->config->setAppValue('solid', 'userSubDomainsEnabled', $enabled); + } } diff --git a/solid/lib/Controller/GetStorageUrlTrait.php b/solid/lib/Controller/GetStorageUrlTrait.php index f697029f..2b184aa5 100644 --- a/solid/lib/Controller/GetStorageUrlTrait.php +++ b/solid/lib/Controller/GetStorageUrlTrait.php @@ -42,7 +42,6 @@ protected function getStorageUrl($userId) { // (?) $storageUrl = preg_replace('/foo$/', '', $storageUrl); $storageUrl = preg_replace('/foo$/', '/', $storageUrl); - // @FIXME: $this->getUserSubDomainsEnabled should contain true/false from (?) somewhere if ($this->config->getUserSubDomainsEnabled()) { $url = parse_url($storageUrl); diff --git a/solid/lib/ServerConfig.php b/solid/lib/ServerConfig.php index 14900ce9..9874cb6c 100644 --- a/solid/lib/ServerConfig.php +++ b/solid/lib/ServerConfig.php @@ -10,24 +10,19 @@ * @package OCA\Solid */ class ServerConfig extends BaseServerConfig { - private IConfig $config; private IUrlGenerator $urlGenerator; private IUserManager $userManager; -// private bool $userDomains; /** * @param IConfig $config * @param IUrlGenerator $urlGenerator * @param IUserManager $userManager - * @param bool $userDomains */ - public function __construct(IConfig $config, IUrlGenerator $urlGenerator, IUserManager $userManager -// , bool $userDomains - ) { + public function __construct(IConfig $config, IUrlGenerator $urlGenerator, IUserManager $userManager) { $this->config = $config; $this->userManager = $userManager; $this->urlGenerator = $urlGenerator; -// $this->userDomains = $userDomains; + parent::__construct($config); } @@ -67,11 +62,4 @@ public function setProfileData($userId, $profileData) { $user->setDisplayName($fields['name']); } } - // public function getUserDomains() { - // return $this->userDomains; - // } - // public function setUserDomains($userDomains) { - // $this->userDomains = $userDomains; - // } - } diff --git a/solid/tests/Unit/BaseServerConfigTest.php b/solid/tests/Unit/BaseServerConfigTest.php new file mode 100644 index 00000000..aeb70bad --- /dev/null +++ b/solid/tests/Unit/BaseServerConfigTest.php @@ -0,0 +1,105 @@ +expectException(TypeError::class); + $this->expectExceptionMessage('Too few arguments to function'); + + new BaseServerConfig(); + } + + /** + * @testdox BaseServerConfig should be instantiated when given a valid Configuration + * @covers ::__construct + */ + public function testConstructorWithValidConfig() + { + $configMock = $this->createMock(IConfig::class); + + $baseServerConfig = new BaseServerConfig($configMock); + + $this->assertInstanceOf(BaseServerConfig::class, $baseServerConfig); + } + + /** + * @testdox BaseServerConfig should return a boolean when asked whether UserSubDomains are Enabled + * @covers ::getUserSubDomainsEnabled + * @dataProvider provideBooleans + */ + public function testGetUserSubDomainsEnabled($expected) + { + $configMock = $this->createMock(IConfig::class); + $configMock->method('getAppValue')->willReturn($expected); + + $baseServerConfig = new BaseServerConfig($configMock); + $actual = $baseServerConfig->getUserSubDomainsEnabled(); + + $this->assertEquals($expected, $actual); + } + + /** + * @testdox BaseServerConfig should get value from AppConfig when asked whether UserSubDomains are Enabled + * @covers ::getUserSubDomainsEnabled + */ + public function testGetUserSubDomainsEnabledFromAppConfig() + { + $configMock = $this->createMock(IConfig::class); + $configMock->expects($this->atLeast(1)) + ->method('getAppValue') + ->with(Application::APP_ID, 'userSubDomainsEnabled', false) + ->willReturn(true); + + $baseServerConfig = new BaseServerConfig($configMock); + $actual = $baseServerConfig->getUserSubDomainsEnabled(); + + $this->assertTrue($actual); + } + + /** + * @testdox BaseServerConfig should set value in AppConfig when asked to set UserSubDomainsEnabled + * @covers ::setUserSubDomainsEnabled + * + * @dataProvider provideBooleans + */ + public function testSetUserSubDomainsEnabled($expected) + { + $configMock = $this->createMock(IConfig::class); + $configMock->expects($this->atLeast(1)) + ->method('setAppValue') + ->with(Application::APP_ID, 'userSubDomainsEnabled', $expected) + ; + + $baseServerConfig = new BaseServerConfig($configMock); + $baseServerConfig->setUserSubDomainsEnabled($expected); + } + + /////////////////////////////// DATAPROVIDERS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + public function provideBooleans() + { + return [ + 'false' => [false], + 'true' => [true], + ]; + } +} From 2dad4fe8aa545aaeab2ee75fd17b7d95c694b8aa Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Fri, 2 May 2025 17:42:02 +0200 Subject: [PATCH 06/54] Add logic to store and retrieve the UserSubDomainsEnabled property from the frontend. --- solid/js/settings-admin.js | 6 +++++- solid/lib/Settings/SolidAdmin.php | 14 +++++--------- solid/templates/admin.php | 18 ++++++++++++++---- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/solid/js/settings-admin.js b/solid/js/settings-admin.js index 8f63de10..9aac545f 100644 --- a/solid/js/settings-admin.js +++ b/solid/js/settings-admin.js @@ -1,4 +1,8 @@ -$(document).ready(function() { +$(document).ready(function () { + $('#solid-enable-user-subdomains').change(function (el) { + OCP.AppConfig.setValue('solid', 'userSubDomainsEnabled', this.checked ? true : false) + }) + $('#solid-private-key').change(function(el) { OCP.AppConfig.setValue('solid', 'privateKey', this.value); }); diff --git a/solid/lib/Settings/SolidAdmin.php b/solid/lib/Settings/SolidAdmin.php index d0f56ec2..a4d4e73d 100644 --- a/solid/lib/Settings/SolidAdmin.php +++ b/solid/lib/Settings/SolidAdmin.php @@ -11,15 +11,11 @@ class SolidAdmin implements ISettings { private IL10N $l; private IConfig $config; private BaseServerConfig $serverConfig; - // private Bool $userDomains; - public function __construct(IConfig $config, IL10N $l - // , bool $userDomains - ) { + public function __construct(IConfig $config, IL10N $l) { $this->config = $config; $this->l = $l; $this->serverConfig = new BaseServerConfig($config); - // $this->userDomains = $userDomains; } /** @@ -29,10 +25,10 @@ public function getForm() { $allClients = $this->serverConfig->getClients(); $parameters = [ - 'privateKey' => $this->serverConfig->getPrivateKey(), - 'encryptionKey' => $this->serverConfig->getEncryptionKey(), - 'clients' => $allClients, - // 'userDomains' => (int) $this->userDomains + 'clients' => $allClients, + 'encryptionKey' => $this->serverConfig->getEncryptionKey(), + 'privateKey' => $this->serverConfig->getPrivateKey(), + 'userSubDomainsEnabled' => $this->serverConfig->getUserSubDomainsEnabled(), ]; return new TemplateResponse('solid', 'admin', $parameters, ''); diff --git a/solid/templates/admin.php b/solid/templates/admin.php index bfdd0383..616f7edb 100644 --- a/solid/templates/admin.php +++ b/solid/templates/admin.php @@ -1,15 +1,25 @@
-

t('Solid Server Settings')); ?>

+

t('Solid Server Settings')); ?>

From cc470419de92f829c0dcc2de7736efea3f451eef Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Fri, 2 May 2025 18:13:42 +0200 Subject: [PATCH 07/54] Code cleanup - Remove trailing and leading space - Remove unneeded `use` statements - Sort `use` statements alphabetically --- solid/lib/Controller/AppController.php | 22 +++++------ solid/lib/Controller/ProfileController.php | 26 ++++++------- .../lib/Controller/SolidWebhookController.php | 37 +++++++------------ solid/lib/Controller/StorageController.php | 25 ++++++------- 4 files changed, 50 insertions(+), 60 deletions(-) diff --git a/solid/lib/Controller/AppController.php b/solid/lib/Controller/AppController.php index 4c2025af..24a7c22b 100644 --- a/solid/lib/Controller/AppController.php +++ b/solid/lib/Controller/AppController.php @@ -2,17 +2,17 @@ namespace OCA\Solid\Controller; use OCA\Solid\ServerConfig; -use OCP\IRequest; -use OCP\IUserManager; -use OCP\Contacts\IManager; -use OCP\IURLGenerator; -use OCP\IConfig; -use OCP\AppFramework\Http; -use OCP\AppFramework\Http\TemplateResponse; -use OCP\AppFramework\Http\DataResponse; + use OCP\AppFramework\Controller; -use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\Contacts\IManager; +use OCP\IConfig; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserManager; class AppController extends Controller { private $userId; @@ -33,7 +33,7 @@ public function __construct($AppName, IRequest $request, IConfig $config, IUserM // $this->userDomains = $userDomains; } - private function getUserApps($userId) { + private function getUserApps($userId) { $userApps = []; if ($this->userManager->userExists($userId)) { $allowedClients = $this->config->getAllowedClients($userId); @@ -49,7 +49,7 @@ private function getAppsList() { $path = __DIR__ . "/../solid-app-list.json"; $appsListJson = file_get_contents($path); $appsList = json_decode($appsListJson, true); - + $userApps = $this->getUserApps($this->userId); foreach ($appsList as $key => $app) { diff --git a/solid/lib/Controller/ProfileController.php b/solid/lib/Controller/ProfileController.php index b4ff9b27..1bc31ca9 100644 --- a/solid/lib/Controller/ProfileController.php +++ b/solid/lib/Controller/ProfileController.php @@ -27,7 +27,7 @@ class ProfileController extends Controller { /* @var ISession */ private $session; - + public function __construct( $AppName, IRequest $request, @@ -82,7 +82,7 @@ private function getFileSystem($userId) { $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); - + $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); $filesystem->addPlugin($plugin); @@ -102,7 +102,7 @@ private function generateDefaultAcl($userId) { acl:accessTo <./>; acl:default <./>; acl:mode acl:Read. - + # The owner has full access to every resource in their pod. # Other agents have no access rights, # unless specifically authorized in other .acl resources. @@ -165,11 +165,11 @@ public function handleRequest($userId, $path) { $this->filesystem = $this->getFileSystem($userId); - $this->resourceServer = new ResourceServer($this->filesystem, $this->response); + $this->resourceServer = new ResourceServer($this->filesystem, $this->response); $this->WAC = new WAC($this->filesystem); $request = $this->rawRequest; - $baseUrl = $this->getProfileUrl($userId); + $baseUrl = $this->getProfileUrl($userId); $this->resourceServer->setBaseUrl($baseUrl); $this->WAC->setBaseUrl($baseUrl); $notifications = new SolidNotifications(); @@ -194,21 +194,21 @@ public function handleRequest($userId, $path) { return $this->respond($response); } - $response = $this->resourceServer->respondToRequest($request); + $response = $this->resourceServer->respondToRequest($request); $response = $this->WAC->addWACHeaders($request, $response, $webId); return $this->respond($response); } - + /** * @PublicPage * @NoAdminRequired * @NoCSRFRequired */ - public function handleGet($userId, $path) { + public function handleGet($userId, $path) { //TODO: check that the $userId matches the userDomain, if enabled. return $this->handleRequest($userId, $path); } - + /** * @PublicPage * @NoAdminRequired @@ -225,7 +225,7 @@ public function handlePost($userId, $path) { public function handlePut() { // $userId, $path) { // FIXME: Adding the correct variables in the function name will make nextcloud // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - + // because we got here, the request uri should look like: // /index.php/apps/solid/@{userId}/storage{path} $pathInfo = explode("@", $_SERVER['REQUEST_URI']); @@ -233,7 +233,7 @@ public function handlePut() { // $userId, $path) { $userId = $pathInfo[0]; $path = $pathInfo[1]; $path = preg_replace("/^profile/", "", $path); - + return $this->handleRequest($userId, $path); } /** @@ -339,9 +339,9 @@ private function generateTurtleProfile($userId) { @prefix inbox: <>. @prefix sp: . @prefix ser: <>. - + pro:card a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. - + :me a schem:Person, foaf:Person; ldp:inbox inbox:; diff --git a/solid/lib/Controller/SolidWebhookController.php b/solid/lib/Controller/SolidWebhookController.php index 6a88a81e..f77c4bee 100644 --- a/solid/lib/Controller/SolidWebhookController.php +++ b/solid/lib/Controller/SolidWebhookController.php @@ -3,31 +3,22 @@ namespace OCA\Solid\Controller; use Closure; -use OCA\Solid\AppInfo\Application; -use OCA\Solid\Service\SolidWebhookService; -use OCA\Solid\ServerConfig; -use OCA\Solid\PlainResponse; -use OCA\Solid\Notifications\SolidNotifications; + use OCA\Solid\DpopFactoryTrait; +use OCA\Solid\PlainResponse; +use OCA\Solid\Service\SolidWebhookService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; +use OCP\Files\IRootFolder; +use OCP\IConfig; +use OCP\IDBConnection; use OCP\IRequest; -use OCP\IUserManager; -use OCP\IURLGenerator; use OCP\ISession; -use OCP\IDBConnection; -use OCP\IConfig; -use OCP\Files\IRootFolder; -use OCP\Files\IHomeStorage; -use OCP\Files\SimpleFS\ISimpleRoot; -use OCP\AppFramework\Http; -use OCP\AppFramework\Http\Response; -use OCP\AppFramework\Http\JSONResponse; -use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\IURLGenerator; +use OCP\IUserManager; -use Pdsinterop\Solid\Resources\Server as ResourceServer; -use Pdsinterop\Solid\Auth\Utils\DPop as DPop; use Pdsinterop\Solid\Auth\WAC as WAC; class SolidWebhookController extends Controller { @@ -38,7 +29,7 @@ class SolidWebhookController extends Controller { /* @var ISession */ private $session; - + /** @var SolidWebhookService */ private $webhookService; @@ -139,7 +130,7 @@ private function getFileSystem() { $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); - + $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); $filesystem->addPlugin($plugin); @@ -175,7 +166,7 @@ private function parseTopic($topic) { "path" => $storagePath ); } - + private function createGetRequest($topic) { $serverParams = []; $fileParams = []; @@ -192,7 +183,7 @@ private function createGetRequest($topic) { $headers ); } - + private function checkReadAccess($topic) { // split out $topic into $userId and $path https://nextcloud.server/solid/@alice/storage/foo/bar // - userId in this case is the pod owner (not the one doing the request). (alice) @@ -200,7 +191,7 @@ private function checkReadAccess($topic) { $target = $this->parseTopic($topic); $userId = $target["userId"]; $path = $target["path"]; - + $this->initializeStorage($userId); $this->WAC = new WAC($this->filesystem); diff --git a/solid/lib/Controller/StorageController.php b/solid/lib/Controller/StorageController.php index bbaddd52..ceed61eb 100644 --- a/solid/lib/Controller/StorageController.php +++ b/solid/lib/Controller/StorageController.php @@ -1,13 +1,14 @@ addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); - + $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); $filesystem->addPlugin($plugin); @@ -307,7 +306,7 @@ public function handleRequest($userId, $path) { $this->WAC = new WAC($this->filesystem); $request = $this->rawRequest; - $baseUrl = $this->getStorageUrl($userId); + $baseUrl = $this->getStorageUrl($userId); $this->resourceServer->setBaseUrl($baseUrl); $this->WAC->setBaseUrl($baseUrl); @@ -355,20 +354,20 @@ public function handleRequest($userId, $path) { ->withStatus(403, "Access denied"); return $this->respond($response); } - $response = $this->resourceServer->respondToRequest($request); + $response = $this->resourceServer->respondToRequest($request); $response = $this->WAC->addWACHeaders($request, $response, $webId); return $this->respond($response); } - + /** * @PublicPage * @NoAdminRequired * @NoCSRFRequired */ - public function handleGet($userId, $path) { + public function handleGet($userId, $path) { return $this->handleRequest($userId, $path); } - + /** * @PublicPage * @NoAdminRequired @@ -385,7 +384,7 @@ public function handlePost($userId, $path) { public function handlePut() { // $userId, $path) { // FIXME: Adding the correct variables in the function name will make nextcloud // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - + // because we got here, the request uri should look like: // /index.php/apps/solid/@{userId}/storage{path} $pathInfo = explode("@", $_SERVER['REQUEST_URI']); @@ -393,7 +392,7 @@ public function handlePut() { // $userId, $path) { $userId = $pathInfo[0]; $path = $pathInfo[1]; $path = preg_replace("/^storage/", "", $path); - + return $this->handleRequest($userId, $path); } /** @@ -438,7 +437,7 @@ private function respond($response) { // $result->addHeader('Access-Control-Allow-Credentials', 'true'); // $result->addHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); // $result->addHeader('Access-Control-Allow-Origin', $origin); - + $policy = new EmptyContentSecurityPolicy(); $policy->addAllowedStyleDomain("*"); $policy->addAllowedStyleDomain("data:"); From 9ef1f1874a03424f7ddff386f00ad2b2cad1dd6e Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Fri, 2 May 2025 18:24:48 +0200 Subject: [PATCH 08/54] Change relevant Controllers to use GetStorageUrlTrait and remove duplicate declarations of `getStorageUrl()`. --- solid/lib/Controller/AppController.php | 20 +++++--------- solid/lib/Controller/GetStorageUrlTrait.php | 7 +++-- solid/lib/Controller/ProfileController.php | 26 +++---------------- .../lib/Controller/SolidWebhookController.php | 11 +++----- solid/lib/Controller/StorageController.php | 16 ++++-------- .../Controller/GetStorageUrlTraitTest.php | 6 ++--- 6 files changed, 26 insertions(+), 60 deletions(-) diff --git a/solid/lib/Controller/AppController.php b/solid/lib/Controller/AppController.php index 24a7c22b..d0b99e92 100644 --- a/solid/lib/Controller/AppController.php +++ b/solid/lib/Controller/AppController.php @@ -15,13 +15,15 @@ use OCP\IUserManager; class AppController extends Controller { + use GetStorageUrlTrait; + + protected ServerConfig $config; + protected IURLGenerator $urlGenerator; + private $userId; private $userManager; - private $urlGenerator; - private $config; public function __construct($AppName, IRequest $request, IConfig $config, IUserManager $userManager, IManager $contactsManager, IURLGenerator $urlGenerator, $userId - // , bool $userDomains ){ parent::__construct($AppName, $request); $this->userId = $userId; @@ -29,8 +31,7 @@ public function __construct($AppName, IRequest $request, IConfig $config, IUserM $this->contactsManager = $contactsManager; $this->request = $request; $this->urlGenerator = $urlGenerator; - $this->config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); - // $this->userDomains = $userDomains; + $this->config = new ServerConfig($config, $urlGenerator, $userManager); } private function getUserApps($userId) { @@ -67,14 +68,7 @@ private function getAppsList() { private function getProfilePage() { return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $this->userId, "path" => "/card"))) . "#me"; } - private function getStorageUrl($userId) { - $storageUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleHead", array("userId" => $userId, "path" => "foo"))); - $storageUrl = preg_replace('/foo$/', '', $storageUrl); -// if ($this->userDomains) { - $storageUrl = $userId.'.'.$storageUrl; -// } - return $storageUrl; - } + /** * @NoAdminRequired * @NoCSRFRequired diff --git a/solid/lib/Controller/GetStorageUrlTrait.php b/solid/lib/Controller/GetStorageUrlTrait.php index 2b184aa5..84fb0033 100644 --- a/solid/lib/Controller/GetStorageUrlTrait.php +++ b/solid/lib/Controller/GetStorageUrlTrait.php @@ -2,14 +2,14 @@ namespace OCA\Solid\Controller; -use OCA\Solid\BaseServerConfig; +use OCA\Solid\ServerConfig; use OCP\IURLGenerator; trait GetStorageUrlTrait { //////////////////////////// GETTERS AND SETTERS \\\\\\\\\\\\\\\\\\\\\\\\\\\ - final public function setConfig(BaseServerConfig $config): void + final public function setConfig(ServerConfig $config): void { $this->config = $config; } @@ -21,13 +21,12 @@ final public function setUrlGenerator(IURLGenerator $urlGenerator): void ////////////////////////////// CLASS PROPERTIES \\\\\\\\\\\\\\\\\\\\\\\\\\\\ - protected BaseServerConfig $config; + protected ServerConfig $config; protected IURLGenerator $urlGenerator; /////////////////////////////// PROTECTED API \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ /** - * @FIXME: Deduplicate multiple declarations of getStorageUrl() * @FIXME: Add check for bob.nextcloud.local/solid/alice to throw 404 * @TODO: Use route without `@alice` in /apps/solid/@alice/profile/card#me when user-domains are enabled */ diff --git a/solid/lib/Controller/ProfileController.php b/solid/lib/Controller/ProfileController.php index 1bc31ca9..bd437089 100644 --- a/solid/lib/Controller/ProfileController.php +++ b/solid/lib/Controller/ProfileController.php @@ -4,6 +4,7 @@ use OCA\Solid\DpopFactoryTrait; use OCA\Solid\PlainResponse; use OCA\Solid\Notifications\SolidNotifications; +use OCA\Solid\ServerConfig; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -21,9 +22,10 @@ class ProfileController extends Controller { use DpopFactoryTrait; + use GetStorageUrlTrait; - /* @var IURLGenerator */ - private $urlGenerator; + protected ServerConfig $config; + protected IURLGenerator $urlGenerator; /* @var ISession */ private $session; @@ -131,26 +133,6 @@ private function getProfileUrl($userId) { $profileUrl = preg_replace('/foo$/', '', $profileUrl); return $profileUrl; } - - private function build_url(array $parts) { - return (isset($parts['scheme']) ? "{$parts['scheme']}:" : '') . - (isset($parts['host']) ? "//{$parts['host']}" : '') . - (isset($parts['port']) ? ":{$parts['port']}" : '') . - (isset($parts['path']) ? "{$parts['path']}" : '') . - (isset($parts['query']) ? "?{$parts['query']}" : '') . - (isset($parts['fragment']) ? "#{$parts['fragment']}" : ''); - } - - private function getStorageUrl($userId) { - $storageUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleHead", array("userId" => $userId, "path" => "foo"))); - $storageUrl = preg_replace('/foo$/', '/', $storageUrl); -// if ($this->userDomains) { - $url = parse_url($storageUrl); - $url['host'] = $userId.'.'.$url['host']; - $storageUrl = $this->build_url($url); -// } - return $storageUrl; - } /** * @PublicPage diff --git a/solid/lib/Controller/SolidWebhookController.php b/solid/lib/Controller/SolidWebhookController.php index f77c4bee..371e3c02 100644 --- a/solid/lib/Controller/SolidWebhookController.php +++ b/solid/lib/Controller/SolidWebhookController.php @@ -6,6 +6,7 @@ use OCA\Solid\DpopFactoryTrait; use OCA\Solid\PlainResponse; +use OCA\Solid\ServerConfig; use OCA\Solid\Service\SolidWebhookService; use OCP\AppFramework\Controller; @@ -23,9 +24,10 @@ class SolidWebhookController extends Controller { use DpopFactoryTrait; + use GetStorageUrlTrait; - /* @var IURLGenerator */ - private $urlGenerator; + protected ServerConfig $config; + protected IURLGenerator $urlGenerator; /* @var ISession */ private $session; @@ -137,11 +139,6 @@ private function getFileSystem() { return $filesystem; } - private function getStorageUrl($userId) { - $storageUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleHead", array("userId" => $userId, "path" => "foo"))); - $storageUrl = preg_replace('/foo$/', '', $storageUrl); - return $storageUrl; - } private function getAppBaseUrl() { $appBaseUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.app.appLauncher")); return $appBaseUrl; diff --git a/solid/lib/Controller/StorageController.php b/solid/lib/Controller/StorageController.php index ceed61eb..789e1201 100644 --- a/solid/lib/Controller/StorageController.php +++ b/solid/lib/Controller/StorageController.php @@ -5,6 +5,7 @@ use OCA\Solid\DpopFactoryTrait; use OCA\Solid\Notifications\SolidNotifications; use OCA\Solid\PlainResponse; +use OCA\Solid\ServerConfig; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -22,11 +23,12 @@ class StorageController extends Controller { - use DpopFactoryTrait; use BearerFactoryTrait; + use DpopFactoryTrait; + use GetStorageUrlTrait; - /* @var IURLGenerator */ - private $urlGenerator; + protected ServerConfig $config; + protected IURLGenerator $urlGenerator; /* @var ISession */ private $session; @@ -89,14 +91,6 @@ private function getFileSystem() { private function getUserProfile($userId) { return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me"; } - private function getStorageUrl($userId) { - $storageUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleHead", array("userId" => $userId, "path" => "foo"))); - $storageUrl = preg_replace('/foo$/', '', $storageUrl); -// if ($this->userDomains) { - $storageUrl = $userId.'.'.$storageUrl; -// } - return $storageUrl; - } private function generateDefaultAcl($userId) { $defaultAcl = <<< EOF diff --git a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php index ee7a5905..b74fd2ab 100644 --- a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php +++ b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php @@ -3,7 +3,7 @@ namespace OCA\Solid\Controller; use Error; -use OCA\Solid\BaseServerConfig; +use OCA\Solid\ServerConfig; use OCP\IURLGenerator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -110,9 +110,9 @@ public function testGetStorageUrlWithUserSubDomainsEnabled($url, $userId, $expec ////////////////////////////// MOCKS AND STUBS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ - public function getMockConfig($enabled = false): MockObject|BaseServerConfig + public function getMockConfig($enabled = false): MockObject|ServerConfig { - $mockConfig = $this->getMockBuilder(BaseServerConfig::class) + $mockConfig = $this->getMockBuilder(ServerConfig::class) ->disableOriginalConstructor() ->getMock(); From 61b115c3ded7b07a0a95dd83c738b96e3cbfe048 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Fri, 2 May 2025 18:27:51 +0200 Subject: [PATCH 09/54] Add a comment regarding User SubDomains in the Docker apache config. --- site.conf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site.conf b/site.conf index d7789bd9..2f1272a1 100644 --- a/site.conf +++ b/site.conf @@ -1,4 +1,8 @@ + # To use User SubDomains, make sure to add a "catch-all", for instance: + # ServerName nextcloud.local + # ServerAlias *.nextcloud.local + DocumentRoot /var/www/html ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined From d54c811edb58fd11e18ee00d19669096ce08b39b Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Fri, 2 May 2025 18:45:52 +0200 Subject: [PATCH 10/54] Delete development docker compose. --- docker-compose-dev.yml | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 docker-compose-dev.yml diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml deleted file mode 100644 index 3e402d3d..00000000 --- a/docker-compose-dev.yml +++ /dev/null @@ -1,37 +0,0 @@ -services: - db: - environment: - MYSQL_DATABASE: nextcloud - MYSQL_PASSWORD: nextcloud - MYSQL_ROOT_PASSWORD: root - MYSQL_USER: nextcloud - image: mysql:8.0.32 - ports: - - "3306:3306" - # To start with a populated database, mount an SQL file - # volumes: - # - ./init.sql:/docker-entrypoint-initdb.d/init.sql - - nextcloud: - depends_on: - - db - environment: - - MARIADB_ROOT_PASSWORD=nextcloud - - MYSQL_HOST=db - expose: - - 443 - image: ghcr.io/pdsinterop/solid-nextcloud:main-30 - ports: - - "443:443" - volumes: - - ./solid/:/var/www/html/apps/solid/ - - ./site.conf:/etc/apache2/sites-enabled/000-default.conf - - pubsub: - depends_on: - - nextcloud - expose: - - 8080 - image: ghcr.io/pdsinterop/php-solid-pubsub-server - ports: - - "8080:8080" \ No newline at end of file From c3c6a8770b1ed981d4399fdeae96d52b717681dc Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 21 May 2025 09:33:20 +0200 Subject: [PATCH 11/54] Change StorageUrlTrait::getStorageUrl() method to be public. --- solid/lib/Controller/GetStorageUrlTrait.php | 2 +- .../Unit/Controller/GetStorageUrlTraitTest.php | 18 ++++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/solid/lib/Controller/GetStorageUrlTrait.php b/solid/lib/Controller/GetStorageUrlTrait.php index 84fb0033..78564c99 100644 --- a/solid/lib/Controller/GetStorageUrlTrait.php +++ b/solid/lib/Controller/GetStorageUrlTrait.php @@ -30,7 +30,7 @@ final public function setUrlGenerator(IURLGenerator $urlGenerator): void * @FIXME: Add check for bob.nextcloud.local/solid/alice to throw 404 * @TODO: Use route without `@alice` in /apps/solid/@alice/profile/card#me when user-domains are enabled */ - protected function getStorageUrl($userId) { + public function getStorageUrl($userId) { $routeUrl = $this->urlGenerator->linkToRoute( 'solid.storage.handleHead', ['userId' => $userId, 'path' => 'foo'] diff --git a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php index b74fd2ab..d0ceddb2 100644 --- a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php +++ b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php @@ -27,16 +27,6 @@ protected function setUp(): void { $this->trait = new class { use GetStorageUrlTrait; - - public function _getStorageUrl($userId) - { - $class = new ReflectionObject($this); - $method = $class->getMethod('getStorageUrl'); - // Only needed for PHP 8.1 and lower - $method->setAccessible(true); - - return $method->invokeArgs($this, [$userId]); - } }; } @@ -51,7 +41,7 @@ public function testGetStorageUrlWithoutUrlGenerator() $this->expectException(Error::class); $this->expectExceptionMessage('urlGenerator must not be accessed before initialization'); - $this->trait->_getStorageUrl(self::MOCK_USER_ID); + $this->trait->getStorageUrl(self::MOCK_USER_ID); } /** @@ -67,7 +57,7 @@ public function testGetStorageUrlWithoutConfig() $this->trait->setUrlGenerator($mockUrlGenerator); - $this->trait->_getStorageUrl(self::MOCK_USER_ID); + $this->trait->getStorageUrl(self::MOCK_USER_ID); } /** @@ -83,7 +73,7 @@ public function testGetStorageUrlWithUserSubDomainsDisabled($url, $userId, $expe $this->trait->setUrlGenerator($mockUrlGenerator); $this->trait->setConfig($mockConfig); - $actual = $this->trait->_getStorageUrl($userId); + $actual = $this->trait->getStorageUrl($userId); $this->assertEquals($expected, $actual); } @@ -103,7 +93,7 @@ public function testGetStorageUrlWithUserSubDomainsEnabled($url, $userId, $expec $this->trait->setUrlGenerator($mockUrlGenerator); $this->trait->setConfig($mockConfig); - $actual = $this->trait->_getStorageUrl($userId); + $actual = $this->trait->getStorageUrl($userId); $this->assertEquals($expected, $actual); } From cff842c6024d3a95d656f92c37666ffe584645f5 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 21 May 2025 09:39:46 +0200 Subject: [PATCH 12/54] Add logic for validating storage URLs to StorageUrlTrait. --- solid/lib/Controller/GetStorageUrlTrait.php | 22 ++++++++++++ .../Controller/GetStorageUrlTraitTest.php | 34 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/solid/lib/Controller/GetStorageUrlTrait.php b/solid/lib/Controller/GetStorageUrlTrait.php index 78564c99..97a312b7 100644 --- a/solid/lib/Controller/GetStorageUrlTrait.php +++ b/solid/lib/Controller/GetStorageUrlTrait.php @@ -4,6 +4,7 @@ use OCA\Solid\ServerConfig; use OCP\IURLGenerator; +use Psr\Http\Message\RequestInterface; trait GetStorageUrlTrait { @@ -55,6 +56,27 @@ public function getStorageUrl($userId) { return $storageUrl; } + public function validateUrl(RequestInterface $request): bool { + $isValid = false; + + $host = $request->getUri()->getHost(); + $path = $request->getUri()->getPath(); + $pathParts = explode('/', $path); + + $pathUsers = array_filter($pathParts, static function ($value) { + return str_starts_with($value, '@'); + }); + + if (count($pathUsers) === 1) { + $pathUser = reset($pathUsers); + $subDomainUser = explode('.', $host)[0]; + + $isValid = $pathUser === '@' . $subDomainUser; + } + + return $isValid; + } + ////////////////////////////// UTILITY METHODS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ private function build_url(array $parts) { diff --git a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php index d0ceddb2..32d70052 100644 --- a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php +++ b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php @@ -3,11 +3,13 @@ namespace OCA\Solid\Controller; use Error; +use Laminas\Diactoros\Request; +use Laminas\Diactoros\Uri; use OCA\Solid\ServerConfig; use OCP\IURLGenerator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use ReflectionObject; +use Psr\Http\Message\RequestInterface; /** * @coversDefaultClass \OCA\Solid\Controller\GetStorageUrlTrait @@ -98,6 +100,20 @@ public function testGetStorageUrlWithUserSubDomainsEnabled($url, $userId, $expec $this->assertEquals($expected, $actual); } + /** + * @testdox GetStorageUrlTrait should return expected validity when asked to validateUrl + * + * @covers ::validateUrl + * + * @dataProvider provideRequests + */ + public function testValidateUrl(RequestInterface $response, $expected) + { + $actual = $this->trait->validateUrl($response); + + $this->assertEquals($expected, $actual); + } + ////////////////////////////// MOCKS AND STUBS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ public function getMockConfig($enabled = false): MockObject|ServerConfig @@ -128,6 +144,22 @@ public function getMockUrlGenerator($url): MockObject|IURLGenerator /////////////////////////////// DATAPROVIDERS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + public function provideRequests() + { + $request = new Request(); + + return [ + 'invalid: invalid URL' => ['request' => $request->withUri(new Uri('!@#$%^&*()_')), 'expected' => false], + 'invalid: no domain user' => ['request' => $request->withUri(new Uri('https://example.com/@alice/profile/card#me')), 'expected' => false], + 'invalid: no path or domain user' => ['request' => $request->withUri(new Uri('https://example.com/')), 'expected' => false], + 'invalid: no path user' => ['request' => $request->withUri(new Uri('https://alice.example.com/profile/card#me')), 'expected' => false], + 'invalid: no URL' => ['request' => $request, 'expected' => false], + 'invalid: path and domain user mismatch' => ['request' => $request->withUri(new Uri('https://bob.example.com/@alice/profile/card#me')), 'expected' => false], + 'valid: minimal path and domain user match' => ['request' => $request->withUri(new Uri('https://alice.example.com/apps/@alice')), 'expected' => true], + 'valid: path and domain user match' => ['request' => $request->withUri(new Uri('https://alice.example.com/apps/solid/@alice/profile/card#me')), 'expected' => true], + ]; + } + public function provideSubDomainsDisabledUrls() { return [ From 7b9ea41a9a335ed06267757566282eca1f03a35c Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 21 May 2025 19:34:19 +0200 Subject: [PATCH 13/54] Change the App routes to not contain a UserId when User SubDomains are enabled. --- solid/appinfo/routes.php | 143 +++++++++++++++++++----------- solid/lib/AppInfo/Application.php | 16 +--- 2 files changed, 94 insertions(+), 65 deletions(-) diff --git a/solid/appinfo/routes.php b/solid/appinfo/routes.php index 085cf09a..2429ad9f 100644 --- a/solid/appinfo/routes.php +++ b/solid/appinfo/routes.php @@ -1,4 +1,11 @@ 'page#approval', 'url' => '/sharing/{clientId}', 'verb' => 'GET'], + ['name' => 'page#handleRevoke', 'url' => '/revoke/{clientId}', 'verb' => 'DELETE'], + ['name' => 'page#handleRevoke', 'url' => '/revoke/{clientId}', 'verb' => 'POST'], + + ['name' => 'page#handleApproval', 'url' => '/sharing/{clientId}', 'verb' => 'POST'], + ['name' => 'page#customscheme', 'url' => '/customscheme', 'verb' => 'GET'], + + ['name' => 'server#cors', 'url' => '/{path}', 'verb' => 'OPTIONS', 'requirements' => ['path' => '.+']], + ['name' => 'server#authorize', 'url' => '/authorize', 'verb' => 'GET'], + ['name' => 'server#jwks', 'url' => '/jwks', 'verb' => 'GET'], + ['name' => 'server#session', 'url' => '/session', 'verb' => 'GET'], + ['name' => 'server#logout', 'url' => '/logout', 'verb' => 'GET'], + ['name' => 'server#token', 'url' => '/token', 'verb' => 'POST'], + ['name' => 'server#userinfo', 'url' => '/userinfo', 'verb' => 'GET'], + ['name' => 'server#register', 'url' => '/register', 'verb' => 'POST'], + ['name' => 'server#registeredClient', 'url' => '/register/{clientId}', 'verb' => 'GET'], + + ['name' => 'solidWebhook#listWebhooks', 'url' => '/webhook/list', 'verb' => 'GET'], + ['name' => 'solidWebhook#register', 'url' => '/webhook/register', 'verb' => 'POST'], + ['name' => 'solidWebhook#unregister', 'url' => '/webhook/unregister', 'verb' => 'POST'], + + ['name' => 'solidWebsocket#register', 'url' => '/websocket/register', 'verb' => 'POST'], + + ['name' => 'app#appLauncher', 'url' => '/', 'verb' => 'GET'], +]; + +$userIdRoutes = [ + ['name' => 'page#profile', 'url' => '/@{userId}/', 'verb' => 'GET'], + + ['name' => 'profile#handleGet', 'url' => '/@{userId}/profile{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], + ['name' => 'profile#handlePut', 'url' => '/@{userId}/profile{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], + ['name' => 'profile#handlePatch', 'url' => '/@{userId}/profile{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], + ['name' => 'profile#handleHead', 'url' => '/@{userId}/profile{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], + + ['name' => 'storage#handleGet', 'url' => '/@{userId}/storage{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], + ['name' => 'storage#handlePost', 'url' => '/@{userId}/storage{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+'], ], + ['name' => 'storage#handlePut', 'url' => '/@{userId}/storage{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], + ['name' => 'storage#handleDelete', 'url' => '/@{userId}/storage{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+'], ], + ['name' => 'storage#handlePatch', 'url' => '/@{userId}/storage{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], + ['name' => 'storage#handleHead', 'url' => '/@{userId}/storage{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], + + ['name' => 'calendar#handleGet', 'url' => '/@{userId}/calendar{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], + ['name' => 'calendar#handlePost', 'url' => '/@{userId}/calendar{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+'], ], + ['name' => 'calendar#handlePut', 'url' => '/@{userId}/calendar{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], + ['name' => 'calendar#handleDelete', 'url' => '/@{userId}/calendar{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+'], ], + ['name' => 'calendar#handlePatch', 'url' => '/@{userId}/calendar{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], + ['name' => 'calendar#handleHead', 'url' => '/@{userId}/calendar{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], + + ['name' => 'contacts#handleGet', 'url' => '/@{userId}/contacts{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], + ['name' => 'contacts#handlePost', 'url' => '/@{userId}/contacts{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+'], ], + ['name' => 'contacts#handlePut', 'url' => '/@{userId}/contacts{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], + ['name' => 'contacts#handleDelete', 'url' => '/@{userId}/contacts{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+'], ], + ['name' => 'contacts#handlePatch', 'url' => '/@{userId}/contacts{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], + ['name' => 'contacts#handleHead', 'url' => '/@{userId}/contacts{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], +]; + +// @TODO: All routes NOT generated by the UrlGenerator ANYWHERE in the code need to be checked! + +if (Application::$userSubDomainsEnabled) { + $userIdRoutes = array_map(function ($route) { + if ($route['name'] === 'page#profile') { + // The profile route should be `/me` instead of `/@{userId}/` + $route['url'] = '/me'; + } else { + // When UserSubDomains are enabled, all routes that start with + // `/@{userId}/` should just be `/`, as the userId is present + // in the subdomain. + $route['url'] = preg_replace('#^/@{userId}/#', '/', $route['url']); + } + + // The required userId is set to the userId from the subdomain + $host = OC::$server->get(IRequest::class)->getServerHost(); + $userId = explode('.', $host)[0]; + $route['defaults'] = ['userId' => $userId]; + + return $route; + }, $userIdRoutes); +} + return [ - 'routes' => [ - ['name' => 'page#profile', 'url' => '/@{userId}/', 'verb' => 'GET'], - ['name' => 'page#approval', 'url' => '/sharing/{clientId}', 'verb' => 'GET'], - ['name' => 'page#handleRevoke', 'url' => '/revoke/{clientId}', 'verb' => 'DELETE'], - ['name' => 'page#handleRevoke', 'url' => '/revoke/{clientId}', 'verb' => 'POST'], - - ['name' => 'page#handleApproval', 'url' => '/sharing/{clientId}', 'verb' => 'POST'], - ['name' => 'page#customscheme', 'url' => '/customscheme', 'verb' => 'GET'], - - ['name' => 'server#cors', 'url' => '/{path}', 'verb' => 'OPTIONS', 'requirements' => array('path' => '.+') ], - ['name' => 'server#authorize', 'url' => '/authorize', 'verb' => 'GET'], - ['name' => 'server#jwks', 'url' => '/jwks', 'verb' => 'GET'], - ['name' => 'server#session', 'url' => '/session', 'verb' => 'GET'], - ['name' => 'server#logout', 'url' => '/logout', 'verb' => 'GET'], - ['name' => 'server#token', 'url' => '/token', 'verb' => 'POST'], - ['name' => 'server#userinfo', 'url' => '/userinfo', 'verb' => 'GET'], - ['name' => 'server#register', 'url' => '/register', 'verb' => 'POST'], - ['name' => 'server#registeredClient', 'url' => '/register/{clientId}', 'verb' => 'GET'], - - ['name' => 'profile#handleGet', 'url' => '/@{userId}/profile{path}', 'verb' => 'GET', 'requirements' => array('path' => '.+')], - ['name' => 'profile#handlePut', 'url' => '/@{userId}/profile{path}', 'verb' => 'PUT', 'requirements' => array('path' => '.+')], - ['name' => 'profile#handlePatch', 'url' => '/@{userId}/profile{path}', 'verb' => 'PATCH', 'requirements' => array('path' => '.+')], - ['name' => 'profile#handleHead', 'url' => '/@{userId}/profile{path}', 'verb' => 'HEAD', 'requirements' => array('path' => '.+')], - - ['name' => 'storage#handleGet', 'url' => '/@{userId}/storage{path}', 'verb' => 'GET', 'requirements' => array('path' => '.+')], - ['name' => 'storage#handlePost', 'url' => '/@{userId}/storage{path}', 'verb' => 'POST', 'requirements' => array('path' => '.+')], - ['name' => 'storage#handlePut', 'url' => '/@{userId}/storage{path}', 'verb' => 'PUT', 'requirements' => array('path' => '.+')], - ['name' => 'storage#handleDelete', 'url' => '/@{userId}/storage{path}', 'verb' => 'DELETE', 'requirements' => array('path' => '.+')], - ['name' => 'storage#handlePatch', 'url' => '/@{userId}/storage{path}', 'verb' => 'PATCH', 'requirements' => array('path' => '.+')], - ['name' => 'storage#handleHead', 'url' => '/@{userId}/storage{path}', 'verb' => 'HEAD', 'requirements' => array('path' => '.+')], - - ['name' => 'calendar#handleGet', 'url' => '/@{userId}/calendar{path}', 'verb' => 'GET', 'requirements' => array('path' => '.+')], - ['name' => 'calendar#handlePost', 'url' => '/@{userId}/calendar{path}', 'verb' => 'POST', 'requirements' => array('path' => '.+')], - ['name' => 'calendar#handlePut', 'url' => '/@{userId}/calendar{path}', 'verb' => 'PUT', 'requirements' => array('path' => '.+')], - ['name' => 'calendar#handleDelete', 'url' => '/@{userId}/calendar{path}', 'verb' => 'DELETE', 'requirements' => array('path' => '.+')], - ['name' => 'calendar#handlePatch', 'url' => '/@{userId}/calendar{path}', 'verb' => 'PATCH', 'requirements' => array('path' => '.+')], - ['name' => 'calendar#handleHead', 'url' => '/@{userId}/calendar{path}', 'verb' => 'HEAD', 'requirements' => array('path' => '.+')], - - ['name' => 'contacts#handleGet', 'url' => '/@{userId}/contacts{path}', 'verb' => 'GET', 'requirements' => array('path' => '.+')], - ['name' => 'contacts#handlePost', 'url' => '/@{userId}/contacts{path}', 'verb' => 'POST', 'requirements' => array('path' => '.+')], - ['name' => 'contacts#handlePut', 'url' => '/@{userId}/contacts{path}', 'verb' => 'PUT', 'requirements' => array('path' => '.+')], - ['name' => 'contacts#handleDelete', 'url' => '/@{userId}/contacts{path}', 'verb' => 'DELETE', 'requirements' => array('path' => '.+')], - ['name' => 'contacts#handlePatch', 'url' => '/@{userId}/contacts{path}', 'verb' => 'PATCH', 'requirements' => array('path' => '.+')], - ['name' => 'contacts#handleHead', 'url' => '/@{userId}/contacts{path}', 'verb' => 'HEAD', 'requirements' => array('path' => '.+')], - - ['name' => 'solidWebhook#listWebhooks', 'url' => '/webhook/list', 'verb' => 'GET'], - ['name' => 'solidWebhook#register', 'url' => '/webhook/register', 'verb' => 'POST'], - ['name' => 'solidWebhook#unregister', 'url' => '/webhook/unregister', 'verb' => 'POST'], - - ['name' => 'solidWebsocket#register', 'url' => '/websocket/register', 'verb' => 'POST'], - - ['name' => 'app#appLauncher', 'url' => '/', 'verb' => 'GET'], - ] + 'routes' => array_merge($routes, $userIdRoutes), ]; diff --git a/solid/lib/AppInfo/Application.php b/solid/lib/AppInfo/Application.php index 5436450b..3080fb03 100644 --- a/solid/lib/AppInfo/Application.php +++ b/solid/lib/AppInfo/Application.php @@ -4,31 +4,22 @@ namespace OCA\Solid\AppInfo; -use OC\AppFramework\Utility\TimeFactory; -use OC\Authentication\Events\AppPasswordCreatedEvent; -use OC\Authentication\Token\IProvider; -use OC\Server; +use OC; +use OC\AppConfig; -use OCA\Solid\Service\UserService; use OCA\Solid\Service\SolidWebhookService; use OCA\Solid\Db\SolidWebhookMapper; -use OCA\Solid\WellKnown\OpenIdConfigurationHandler; -use OCA\Solid\WellKnown\SolidHandler; use OCA\Solid\Middleware\SolidCorsMiddleware; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; -use OCP\AppFramework\IAppContainer; -use OCP\Defaults; -use OCP\IServerContainer; -use OCP\Settings\IManager; -use OCP\Util; use OCP\IDBConnection; class Application extends App implements IBootstrap { public const APP_ID = 'solid'; + public static $userSubDomainsEnabled; /** * @param array $urlParams @@ -76,5 +67,6 @@ public function register(IRegistrationContext $context): void { } public function boot(IBootContext $context): void { + self::$userSubDomainsEnabled = OC::$server->get(AppConfig::class)->getValueBool(self::APP_ID, 'userSubDomainsEnabled'); } } From 83281c4eb52cb7284111c195d5a5483ee854cdbb Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Fri, 23 May 2025 15:23:17 +0200 Subject: [PATCH 14/54] Add logic to cast mixed AppValue to boolean. --- solid/lib/BaseServerConfig.php | 65 ++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/solid/lib/BaseServerConfig.php b/solid/lib/BaseServerConfig.php index 0debc247..d829b5ad 100644 --- a/solid/lib/BaseServerConfig.php +++ b/solid/lib/BaseServerConfig.php @@ -1,14 +1,19 @@ config = $config; } @@ -152,6 +157,7 @@ public function removeClientConfig($clientId) { unset($scopes[$clientId]); $this->config->setAppValue('solid', 'clientScopes', $scopes); } + public function saveClientRegistration($origin, $clientData) { $originHash = md5($origin); $existingRegistration = $this->getClientRegistration($originHash); @@ -183,11 +189,56 @@ public function getClientRegistration($clientId) { return json_decode($data, true); } - public function getUserSubDomainsEnabled(): bool { - return $this->config->getAppValue('solid', 'userSubDomainsEnabled', false); + public function getUserSubDomainsEnabled() { + $value = $this->config->getAppValue('solid', 'userSubDomainsEnabled', false); + + return $this->castToBool($value); } - public function setUserSubDomainsEnabled(bool $enabled) { - $this->config->setAppValue('solid', 'userSubDomainsEnabled', $enabled); + public function setUserSubDomainsEnabled($enabled) { + $value = $this->castToBool($enabled); + + $this->config->setAppValue('solid', 'userSubDomainsEnabled', $value); + } + + ////////////////////////////// UTILITY METHODS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + private function castToBool(string $mixedValue): bool + { + $type = gettype($mixedValue); + + if ($type === 'boolean' || $type === 'NULL' || $type === 'integer') { + $value = (bool) $mixedValue; + } else { + if ($type === 'string') { + $mixedValue = strtolower($mixedValue); + if ($mixedValue === 'true' || $mixedValue === '1') { + $value = true; + } elseif ($mixedValue === 'false' || $mixedValue === '0') { + $value = false; + } else { + $error = [ + 'invalid' => 'value', + 'for' => 'userSubDomainsEnabled', + 'received' => $mixedValue, + 'expected' => implode(',', ['true', 'false', '1', '0']) + ]; + } + } else { + $error = [ + 'invalid' => 'type', + 'for' => 'userSubDomainsEnabled', + 'received' => $type, + 'expected' => implode(',', ['boolean', 'NULL', 'integer', 'string']) + ]; + } + } + + if (isset($error)) { + $errorMessage = vsprintf(self::ERROR_INVALID_ARGUMENT, $error); + throw new InvalidArgumentException($errorMessage); + } + + return $value; } } From 0ba1dfb8f0434512b44576790558d36bed51e28c Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Fri, 23 May 2025 15:24:14 +0200 Subject: [PATCH 15/54] Fix error caused by a value not being set. --- solid/lib/BaseServerConfig.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solid/lib/BaseServerConfig.php b/solid/lib/BaseServerConfig.php index d829b5ad..bcc24d76 100644 --- a/solid/lib/BaseServerConfig.php +++ b/solid/lib/BaseServerConfig.php @@ -96,7 +96,7 @@ public function getClients() { $clients[] = [ "clientId" => $matches[1], "clientName" => $clientRegistration['client_name'], - "clientBlocked" => $clientRegistration['blocked'] + "clientBlocked" => $clientRegistration['blocked'] ?? false, ]; } } From b4437c589310d3714ef2a95d0ff65477011be914 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Fri, 16 May 2025 11:19:56 +0200 Subject: [PATCH 16/54] update to ubuntu latest --- .github/workflows/linting.yml | 8 ++++---- .github/workflows/php-version-sniff.yml | 2 +- .github/workflows/quality-checks.yml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index cbca0f41..f56851cf 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -7,7 +7,7 @@ on: jobs: lint-json: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: "docker://pipelinecomponents/jsonlint:latest" @@ -15,13 +15,13 @@ jobs: args: "find . -not -path './.git/*' -name '*.json' -type f" lint-php: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: pipeline-components/php-linter@master lint-markdown: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: pipeline-components/remark-lint@master @@ -29,7 +29,7 @@ jobs: options: --rc-path=build/.remarkrc --ignore-pattern='*/vendor/*' lint-yaml: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: pipeline-components/yamllint@master diff --git a/.github/workflows/php-version-sniff.yml b/.github/workflows/php-version-sniff.yml index 30cfd373..c3a3f90f 100644 --- a/.github/workflows/php-version-sniff.yml +++ b/.github/workflows/php-version-sniff.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: php: [ '8.1' ] - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: pipeline-components/php-codesniffer@master diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 885c567a..81c5b626 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -7,7 +7,7 @@ on: jobs: composer-validate: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: "docker://composer" @@ -15,7 +15,7 @@ jobs: args: composer validate --strict --working-dir=solid/ php-codesniffer: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: pipeline-components/php-codesniffer@master From acc9f96616e8abdc83689093fc6c216b9eacff1d Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 26 May 2025 10:56:40 +0200 Subject: [PATCH 17/54] add no-install-recommends to solve Dockerfile:4 DL3015 info: Avoid additional packages by specifying `--no-install-recommends` --- Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 80ff378e..84340bee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ -ARG NEXTCLOUD_VERSION -FROM nextcloud:${NEXTCLOUD_VERSION} +#ARG NEXTCLOUD_VERSION +#FROM nextcloud:${NEXTCLOUD_VERSION} +FROM nextcloud:31 -RUN apt-get update && apt-get install -yq \ +RUN apt-get update && apt-get install --no-install-recommends -yq \ git \ sudo \ vim \ From 61640e40b00a5c8184009f5d5077ac1e60c382a7 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 26 May 2025 11:34:23 +0200 Subject: [PATCH 18/54] handle empty string case as false value --- solid/lib/BaseServerConfig.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solid/lib/BaseServerConfig.php b/solid/lib/BaseServerConfig.php index bcc24d76..8147ba0b 100644 --- a/solid/lib/BaseServerConfig.php +++ b/solid/lib/BaseServerConfig.php @@ -214,7 +214,7 @@ private function castToBool(string $mixedValue): bool $mixedValue = strtolower($mixedValue); if ($mixedValue === 'true' || $mixedValue === '1') { $value = true; - } elseif ($mixedValue === 'false' || $mixedValue === '0') { + } elseif ($mixedValue === 'false' || $mixedValue === '0' || $mixedValue === '') { $value = false; } else { $error = [ From 172abdfa5248813b5d3e56ff79555e3de6d472a3 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 26 May 2025 14:35:06 +0200 Subject: [PATCH 19/54] Update handlePut for the user-subdomain case --- solid/lib/Controller/CalendarController.php | 39 +++++++++++++-------- solid/lib/Controller/ContactsController.php | 39 +++++++++++++-------- solid/lib/Controller/ProfileController.php | 39 +++++++++++++-------- solid/lib/Controller/StorageController.php | 39 +++++++++++++-------- 4 files changed, 100 insertions(+), 56 deletions(-) diff --git a/solid/lib/Controller/CalendarController.php b/solid/lib/Controller/CalendarController.php index a9e773bb..046cef08 100644 --- a/solid/lib/Controller/CalendarController.php +++ b/solid/lib/Controller/CalendarController.php @@ -185,20 +185,31 @@ public function handlePost($userId, $path) { * @NoAdminRequired * @NoCSRFRequired */ - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - - // because we got here, the request uri should look like: - // /index.php/apps/solid/@{userId}/storage{path} - $pathInfo = explode("@", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^calendar/", "", $path); - - return $this->handleRequest($userId, $path); - } + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/calendar{path} + // and otherwise: + // index.php/apps/solid/~{userId}/calendar{path} + + // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("calendar/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^calendar/", "", $path); + } + + return $this->handleRequest($userId, $path); + } /** * @PublicPage * @NoAdminRequired diff --git a/solid/lib/Controller/ContactsController.php b/solid/lib/Controller/ContactsController.php index 1fc3fcec..3d2f52bb 100644 --- a/solid/lib/Controller/ContactsController.php +++ b/solid/lib/Controller/ContactsController.php @@ -186,20 +186,31 @@ public function handlePost($userId, $path) { * @NoAdminRequired * @NoCSRFRequired */ - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - - // because we got here, the request uri should look like: - // /index.php/apps/solid/@{userId}/storage{path} - $pathInfo = explode("@", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^contacts/", "", $path); - - return $this->handleRequest($userId, $path); - } + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/contacts{path} + // and otherwise: + // index.php/apps/solid/~{userId}/contacts{path} + + // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("contacts/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^contacts/", "", $path); + } + + return $this->handleRequest($userId, $path); + } /** * @PublicPage * @NoAdminRequired diff --git a/solid/lib/Controller/ProfileController.php b/solid/lib/Controller/ProfileController.php index bd437089..24560dc7 100644 --- a/solid/lib/Controller/ProfileController.php +++ b/solid/lib/Controller/ProfileController.php @@ -204,20 +204,31 @@ public function handlePost($userId, $path) { * @NoAdminRequired * @NoCSRFRequired */ - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - - // because we got here, the request uri should look like: - // /index.php/apps/solid/@{userId}/storage{path} - $pathInfo = explode("@", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^profile/", "", $path); - - return $this->handleRequest($userId, $path); - } + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/profile{path} + // and otherwise: + // index.php/apps/solid/~{userId}/profile{path} + + // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("profile/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^profile/", "", $path); + } + + return $this->handleRequest($userId, $path); + } /** * @PublicPage * @NoAdminRequired diff --git a/solid/lib/Controller/StorageController.php b/solid/lib/Controller/StorageController.php index 789e1201..de4cd201 100644 --- a/solid/lib/Controller/StorageController.php +++ b/solid/lib/Controller/StorageController.php @@ -375,20 +375,31 @@ public function handlePost($userId, $path) { * @NoAdminRequired * @NoCSRFRequired */ - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - - // because we got here, the request uri should look like: - // /index.php/apps/solid/@{userId}/storage{path} - $pathInfo = explode("@", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^storage/", "", $path); - - return $this->handleRequest($userId, $path); - } + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/storage{path} + // and otherwise: + // index.php/apps/solid/~{userId}/storage{path} + + // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("storage/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^storage/", "", $path); + } + + return $this->handleRequest($userId, $path); + } /** * @PublicPage * @NoAdminRequired From 4d4d362ffedfbe6ef8afe8de5882292b97030a9e Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Fri, 4 Oct 2024 10:16:02 +0200 Subject: [PATCH 20/54] replace @ with ~ in urls --- solid/appinfo/routes.php | 61 ++- solid/lib/Controller/AppController.php~ | 99 ++++ solid/lib/Controller/CalendarController.php | 44 +- solid/lib/Controller/CalendarController.php~ | 278 +++++++++++ solid/lib/Controller/ContactsController.php | 44 +- solid/lib/Controller/ContactsController.php~ | 279 +++++++++++ solid/lib/Controller/GetStorageUrlTrait.php~ | 91 ++++ solid/lib/Controller/PageController.php~ | 152 ++++++ solid/lib/Controller/ProfileController.php | 47 +- solid/lib/Controller/ProfileController.php~ | 378 ++++++++++++++ solid/lib/Controller/ServerController.php~ | 449 +++++++++++++++++ .../lib/Controller/SolidWebhookController.php | 12 +- solid/lib/Controller/StorageController.php | 47 +- solid/lib/Controller/StorageController.php~ | 471 ++++++++++++++++++ 14 files changed, 2323 insertions(+), 129 deletions(-) create mode 100644 solid/lib/Controller/AppController.php~ create mode 100644 solid/lib/Controller/CalendarController.php~ create mode 100644 solid/lib/Controller/ContactsController.php~ create mode 100644 solid/lib/Controller/GetStorageUrlTrait.php~ create mode 100644 solid/lib/Controller/PageController.php~ create mode 100644 solid/lib/Controller/ProfileController.php~ create mode 100644 solid/lib/Controller/ServerController.php~ create mode 100644 solid/lib/Controller/StorageController.php~ diff --git a/solid/appinfo/routes.php b/solid/appinfo/routes.php index 2429ad9f..469902b6 100644 --- a/solid/appinfo/routes.php +++ b/solid/appinfo/routes.php @@ -15,7 +15,6 @@ * it's instantiated in there */ - $routes = [ ['name' => 'page#approval', 'url' => '/sharing/{clientId}', 'verb' => 'GET'], ['name' => 'page#handleRevoke', 'url' => '/revoke/{clientId}', 'verb' => 'DELETE'], @@ -44,33 +43,33 @@ ]; $userIdRoutes = [ - ['name' => 'page#profile', 'url' => '/@{userId}/', 'verb' => 'GET'], - - ['name' => 'profile#handleGet', 'url' => '/@{userId}/profile{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], - ['name' => 'profile#handlePut', 'url' => '/@{userId}/profile{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], - ['name' => 'profile#handlePatch', 'url' => '/@{userId}/profile{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], - ['name' => 'profile#handleHead', 'url' => '/@{userId}/profile{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], - - ['name' => 'storage#handleGet', 'url' => '/@{userId}/storage{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], - ['name' => 'storage#handlePost', 'url' => '/@{userId}/storage{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+'], ], - ['name' => 'storage#handlePut', 'url' => '/@{userId}/storage{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], - ['name' => 'storage#handleDelete', 'url' => '/@{userId}/storage{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+'], ], - ['name' => 'storage#handlePatch', 'url' => '/@{userId}/storage{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], - ['name' => 'storage#handleHead', 'url' => '/@{userId}/storage{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], - - ['name' => 'calendar#handleGet', 'url' => '/@{userId}/calendar{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], - ['name' => 'calendar#handlePost', 'url' => '/@{userId}/calendar{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+'], ], - ['name' => 'calendar#handlePut', 'url' => '/@{userId}/calendar{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], - ['name' => 'calendar#handleDelete', 'url' => '/@{userId}/calendar{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+'], ], - ['name' => 'calendar#handlePatch', 'url' => '/@{userId}/calendar{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], - ['name' => 'calendar#handleHead', 'url' => '/@{userId}/calendar{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], - - ['name' => 'contacts#handleGet', 'url' => '/@{userId}/contacts{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], - ['name' => 'contacts#handlePost', 'url' => '/@{userId}/contacts{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+'], ], - ['name' => 'contacts#handlePut', 'url' => '/@{userId}/contacts{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], - ['name' => 'contacts#handleDelete', 'url' => '/@{userId}/contacts{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+'], ], - ['name' => 'contacts#handlePatch', 'url' => '/@{userId}/contacts{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], - ['name' => 'contacts#handleHead', 'url' => '/@{userId}/contacts{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], + ['name' => 'page#profile', 'url' => '/~{userId}/', 'verb' => 'GET'], + + ['name' => 'profile#handleGet', 'url' => '/~{userId}/profile{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], + ['name' => 'profile#handlePut', 'url' => '/~{userId}/profile{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], + ['name' => 'profile#handlePatch', 'url' => '/~{userId}/profile{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], + ['name' => 'profile#handleHead', 'url' => '/~{userId}/profile{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], + + ['name' => 'storage#handleGet', 'url' => '/~{userId}/storage{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], + ['name' => 'storage#handlePost', 'url' => '/~{userId}/storage{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+'], ], + ['name' => 'storage#handlePut', 'url' => '/~{userId}/storage{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], + ['name' => 'storage#handleDelete', 'url' => '/~{userId}/storage{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+'], ], + ['name' => 'storage#handlePatch', 'url' => '/~{userId}/storage{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], + ['name' => 'storage#handleHead', 'url' => '/~{userId}/storage{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], + + ['name' => 'calendar#handleGet', 'url' => '/~{userId}/calendar{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], + ['name' => 'calendar#handlePost', 'url' => '/~{userId}/calendar{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+'], ], + ['name' => 'calendar#handlePut', 'url' => '/~{userId}/calendar{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], + ['name' => 'calendar#handleDelete', 'url' => '/~{userId}/calendar{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+'], ], + ['name' => 'calendar#handlePatch', 'url' => '/~{userId}/calendar{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], + ['name' => 'calendar#handleHead', 'url' => '/~{userId}/calendar{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], + + ['name' => 'contacts#handleGet', 'url' => '/~{userId}/contacts{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], ], + ['name' => 'contacts#handlePost', 'url' => '/~{userId}/contacts{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+'], ], + ['name' => 'contacts#handlePut', 'url' => '/~{userId}/contacts{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+'], ], + ['name' => 'contacts#handleDelete', 'url' => '/~{userId}/contacts{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+'], ], + ['name' => 'contacts#handlePatch', 'url' => '/~{userId}/contacts{path}', 'verb' => 'PATCH', 'requirements' => ['path' => '.+'], ], + ['name' => 'contacts#handleHead', 'url' => '/~{userId}/contacts{path}', 'verb' => 'HEAD', 'requirements' => ['path' => '.+'], ], ]; // @TODO: All routes NOT generated by the UrlGenerator ANYWHERE in the code need to be checked! @@ -78,13 +77,13 @@ if (Application::$userSubDomainsEnabled) { $userIdRoutes = array_map(function ($route) { if ($route['name'] === 'page#profile') { - // The profile route should be `/me` instead of `/@{userId}/` + // The profile route should be `/me` instead of `/~{userId}/` $route['url'] = '/me'; } else { // When UserSubDomains are enabled, all routes that start with - // `/@{userId}/` should just be `/`, as the userId is present + // `/~{userId}/` should just be `/`, as the userId is present // in the subdomain. - $route['url'] = preg_replace('#^/@{userId}/#', '/', $route['url']); + $route['url'] = preg_replace('#^/~{userId}/#', '/', $route['url']); } // The required userId is set to the userId from the subdomain diff --git a/solid/lib/Controller/AppController.php~ b/solid/lib/Controller/AppController.php~ new file mode 100644 index 00000000..d0b99e92 --- /dev/null +++ b/solid/lib/Controller/AppController.php~ @@ -0,0 +1,99 @@ +userId = $userId; + $this->userManager = $userManager; + $this->contactsManager = $contactsManager; + $this->request = $request; + $this->urlGenerator = $urlGenerator; + $this->config = new ServerConfig($config, $urlGenerator, $userManager); + } + + private function getUserApps($userId) { + $userApps = []; + if ($this->userManager->userExists($userId)) { + $allowedClients = $this->config->getAllowedClients($userId); + foreach ($allowedClients as $clientId) { + $registration = $this->config->getClientRegistration($clientId); + $userApps[] = $registration['client_name']; + } + } + return $userApps; + } + + private function getAppsList() { + $path = __DIR__ . "/../solid-app-list.json"; + $appsListJson = file_get_contents($path); + $appsList = json_decode($appsListJson, true); + + $userApps = $this->getUserApps($this->userId); + + foreach ($appsList as $key => $app) { + $parsedOrigin = parse_url($app['launchUrl']); + $origin = $parsedOrigin['host']; + if (in_array($origin, $userApps, true)) { + $appsList[$key]['registered'] = 1; + } else { + $appsList[$key]['registered'] = 0; + } + } + return $appsList; + } + + private function getProfilePage() { + return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $this->userId, "path" => "/card"))) . "#me"; + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function appLauncher() { + $appsList = $this->getAppsList(); + if (!$appsList) { + return new JSONResponse(array(), Http::STATUS_NOT_FOUND); + } + $appLauncherData = array( + "appsListJson" => json_encode($appsList), + "webId" => json_encode($this->getProfilePage()), + "storageUrl" => json_encode($this->getStorageUrl($this->userId)), + 'solidNavigation' => array( + "profile" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.page.profile", array("userId" => $this->userId))), + "launcher" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.app.appLauncher", array())), + ) + ); + $templateResponse = new TemplateResponse('solid', 'applauncher', $appLauncherData); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedStyleDomain("data:"); + $policy->addAllowedScriptDomain("'self'"); + $policy->addAllowedScriptDomain("'unsafe-inline'"); + $policy->addAllowedScriptDomain("'unsafe-eval'"); + $templateResponse->setContentSecurityPolicy($policy); + return $templateResponse; + } +} diff --git a/solid/lib/Controller/CalendarController.php b/solid/lib/Controller/CalendarController.php index 046cef08..9030dabb 100644 --- a/solid/lib/Controller/CalendarController.php +++ b/solid/lib/Controller/CalendarController.php @@ -185,31 +185,31 @@ public function handlePost($userId, $path) { * @NoAdminRequired * @NoCSRFRequired */ - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - // because we got here, the request uri should look like: - // - if we have user subdomains enabled: - // /index.php/apps/solid/calendar{path} - // and otherwise: - // index.php/apps/solid/~{userId}/calendar{path} + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/calendar{path} + // and otherwise: + // index.php/apps/solid/~{userId}/calendar{path} // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; - if ($this->config->getUserSubDomainsEnabled()) { - $pathInfo = explode("calendar/", $_SERVER['REQUEST_URI']); - $path = $pathInfo[1]; - $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; - } else { - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^calendar/", "", $path); - } - - return $this->handleRequest($userId, $path); - } + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("calendar/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^calendar/", "", $path); + } + + return $this->handleRequest($userId, $path); + } /** * @PublicPage * @NoAdminRequired diff --git a/solid/lib/Controller/CalendarController.php~ b/solid/lib/Controller/CalendarController.php~ new file mode 100644 index 00000000..439dcdb0 --- /dev/null +++ b/solid/lib/Controller/CalendarController.php~ @@ -0,0 +1,278 @@ +config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); + $this->request = $request; + $this->urlGenerator = $urlGenerator; + $this->session = $session; + + $this->setJtiStorage($connection); + } + + private function getFileSystem($userId) { + // Make sure the root folder has an acl file, as is required by the spec; + // Generate a default file granting the owner full access. + $defaultAcl = $this->generateDefaultAcl($userId); + + // Create the Nextcloud Calendar Adapter + $adapter = new \Pdsinterop\Flysystem\Adapter\NextcloudCalendar($userId, $defaultAcl); + + $graph = new \EasyRdf\Graph(); + + // Create Formats objects + $formats = new \Pdsinterop\Rdf\Formats(); + + $serverParams = $this->rawRequest->getServerParams(); + $scheme = $serverParams['REQUEST_SCHEME']; + $domain = $serverParams['SERVER_NAME']; + $path = $serverParams['REQUEST_URI']; + $serverUri = "{$scheme}://{$domain}{$path}"; // FIXME: doublecheck that this is the correct url; + + // Create the RDF Adapter + $rdfAdapter = new \Pdsinterop\Rdf\Flysystem\Adapter\Rdf( + $adapter, + $graph, + $formats, + $serverUri + ); + + $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); + + $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); + + $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); + $filesystem->addPlugin($plugin); + + return $filesystem; + } + + private function generateDefaultAcl($userId) { + $defaultAcl = <<< EOF +# Root ACL resource for the user account +@prefix acl: . +@prefix foaf: . + +# The owner has full access to every resource in their pod. +# Other agents have no access rights, +# unless specifically authorized in other .acl resources. +<#owner> + a acl:Authorization; + acl:agent <{user-profile-uri}>; + # Set the access to the root storage folder itself + acl:accessTo <./>; + # All resources will inherit this authorization, by default + acl:default <./>; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. +EOF; + + $profileUri = $this->getUserProfile($userId); + $defaultAcl = str_replace("{user-profile-uri}", $profileUri, $defaultAcl); + return $defaultAcl; + } + + private function getUserProfile($userId) { + return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me"; + } + private function getCalendarUrl($userId) { + $calendarUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.calendar.handleHead", array("userId" => $userId, "path" => "foo"))); + $calendarUrl = preg_replace('/foo$/', '', $calendarUrl); + return $calendarUrl; + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleRequest($userId, $path) { + $this->calendarUserId = $userId; + + $this->rawRequest = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); + $this->response = new \Laminas\Diactoros\Response(); + + $this->filesystem = $this->getFileSystem($userId); + + $this->resourceServer = new ResourceServer($this->filesystem, $this->response); + $this->WAC = new WAC($this->filesystem); + + $request = $this->rawRequest; + $baseUrl = $this->getCalendarUrl($userId); + $this->resourceServer->setBaseUrl($baseUrl); + $this->WAC->setBaseUrl($baseUrl); + $notifications = new SolidNotifications(); + $this->resourceServer->setNotifications($notifications); + + $dpop = $this->getDpop(); + + try { + $webId = $dpop->getWebId($request); + } catch(\Pdsinterop\Solid\Auth\Exception\Exception $e) { + $response = $this->resourceServer->getResponse() + ->withStatus(Http::STATUS_CONFLICT, "Invalid token " . $e->getMessage()); + return $this->respond($response); + } + + if (!$this->WAC->isAllowed($request, $webId)) { + $response = $this->resourceServer->getResponse()->withStatus(403, "Access denied"); + return $this->respond($response); + } + + $response = $this->resourceServer->respondToRequest($request); + $response = $this->WAC->addWACHeaders($request, $response, $webId); + return $this->respond($response); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleGet($userId, $path) { + return $this->handleRequest($userId, $path); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handlePost($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ +<<<<<<< HEAD + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/calendar{path} + // and otherwise: + // index.php/apps/solid/~{userId}/calendar{path} + + // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("calendar/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^calendar/", "", $path); + } + + return $this->handleRequest($userId, $path); + } +======= + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + + // because we got here, the request uri should look like: + // /index.php/apps/solid/~{userId}/storage{path} + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^calendar/", "", $path); + + return $this->handleRequest($userId, $path); + } +>>>>>>> 3100599 (replace @ with ~ in urls) + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleDelete($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleHead($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handlePatch($userId, $path) { + return $this->handleRequest($userId, $path); + } + + private function respond($response) { + $statusCode = $response->getStatusCode(); + $response->getBody()->rewind(); + $headers = $response->getHeaders(); + + $body = $response->getBody()->getContents(); + if ($statusCode > 399) { + $reason = $response->getReasonPhrase(); + $result = new JSONResponse($reason, $statusCode); + return $result; + } + + $result = new PlainResponse($body); + + foreach ($headers as $header => $values) { + foreach ($values as $value) { + $result->addHeader($header, $value); + } + } + + $result->setStatus($statusCode); + return $result; + } +} diff --git a/solid/lib/Controller/ContactsController.php b/solid/lib/Controller/ContactsController.php index 3d2f52bb..0363add2 100644 --- a/solid/lib/Controller/ContactsController.php +++ b/solid/lib/Controller/ContactsController.php @@ -186,31 +186,31 @@ public function handlePost($userId, $path) { * @NoAdminRequired * @NoCSRFRequired */ - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - // because we got here, the request uri should look like: - // - if we have user subdomains enabled: - // /index.php/apps/solid/contacts{path} - // and otherwise: - // index.php/apps/solid/~{userId}/contacts{path} + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/contacts{path} + // and otherwise: + // index.php/apps/solid/~{userId}/contacts{path} // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; - if ($this->config->getUserSubDomainsEnabled()) { - $pathInfo = explode("contacts/", $_SERVER['REQUEST_URI']); - $path = $pathInfo[1]; - $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; - } else { - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^contacts/", "", $path); - } - - return $this->handleRequest($userId, $path); - } + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("contacts/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^contacts/", "", $path); + } + + return $this->handleRequest($userId, $path); + } /** * @PublicPage * @NoAdminRequired diff --git a/solid/lib/Controller/ContactsController.php~ b/solid/lib/Controller/ContactsController.php~ new file mode 100644 index 00000000..f346036e --- /dev/null +++ b/solid/lib/Controller/ContactsController.php~ @@ -0,0 +1,279 @@ +config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); + $this->request = $request; + $this->urlGenerator = $urlGenerator; + $this->session = $session; + + $this->setJtiStorage($connection); + } + + private function getFileSystem($userId) { + // Make sure the root folder has an acl file, as is required by the spec; + // Generate a default file granting the owner full access. + $defaultAcl = $this->generateDefaultAcl($userId); + + // Create the Nextcloud Contacts Adapter + $adapter = new \Pdsinterop\Flysystem\Adapter\NextcloudContacts($userId, $defaultAcl); + + $graph = new \EasyRdf\Graph(); + + // Create Formats objects + $formats = new \Pdsinterop\Rdf\Formats(); + + $serverParams = $this->rawRequest->getServerParams(); + $scheme = $serverParams['REQUEST_SCHEME']; + $domain = $serverParams['SERVER_NAME']; + $path = $serverParams['REQUEST_URI']; + $serverUri = "{$scheme}://{$domain}{$path}"; // FIXME: doublecheck that this is the correct url; + + // Create the RDF Adapter + $rdfAdapter = new \Pdsinterop\Rdf\Flysystem\Adapter\Rdf( + $adapter, + $graph, + $formats, + $serverUri + ); + + $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); + + $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); + + $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); + $filesystem->addPlugin($plugin); + + return $filesystem; + } + + private function generateDefaultAcl($userId) { + $defaultAcl = <<< EOF +# Root ACL resource for the user account +@prefix acl: . +@prefix foaf: . + +# The owner has full access to every resource in their pod. +# Other agents have no access rights, +# unless specifically authorized in other .acl resources. +<#owner> + a acl:Authorization; + acl:agent <{user-profile-uri}>; + # Set the access to the root storage folder itself + acl:accessTo <./>; + # All resources will inherit this authorization, by default + acl:default <./>; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. +EOF; + + $profileUri = $this->getUserProfile($userId); + $defaultAcl = str_replace("{user-profile-uri}", $profileUri, $defaultAcl); + return $defaultAcl; + } + + private function getUserProfile($userId) { + return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me"; + } + private function getContactsUrl($userId) { + $contactsUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.contacts.handleHead", array("userId" => $userId, "path" => "foo"))); + $contactsUrl = preg_replace('/foo$/', '', $contactsUrl); + return $contactsUrl; + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleRequest($userId, $path) { + $this->contactsUserId = $userId; + + $this->rawRequest = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); + $this->response = new \Laminas\Diactoros\Response(); + + $this->filesystem = $this->getFileSystem($userId); + + $this->resourceServer = new ResourceServer($this->filesystem, $this->response); + $this->WAC = new WAC($this->filesystem); + + $request = $this->rawRequest; + $baseUrl = $this->getContactsUrl($userId); + $this->resourceServer->setBaseUrl($baseUrl); + $this->WAC->setBaseUrl($baseUrl); + $notifications = new SolidNotifications(); + $this->resourceServer->setNotifications($notifications); + + $dpop = $this->getDpop(); + + try { + $webId = $dpop->getWebId($request); + } catch(\Pdsinterop\Solid\Auth\Exception\Exception $e) { + $response = $this->resourceServer->getResponse() + ->withStatus(Http::STATUS_CONFLICT, "Invalid token " . $e->getMessage()); + return $this->respond($response); + } + + if (!$this->WAC->isAllowed($request, $webId)) { + $response = $this->resourceServer->getResponse()->withStatus(403, "Access denied"); + return $this->respond($response); + } + + $response = $this->resourceServer->respondToRequest($request); + $response = $this->WAC->addWACHeaders($request, $response, $webId); + return $this->respond($response); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleGet($userId, $path) { + return $this->handleRequest($userId, $path); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handlePost($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ +<<<<<<< HEAD + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/contacts{path} + // and otherwise: + // index.php/apps/solid/~{userId}/contacts{path} + + // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("contacts/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^contacts/", "", $path); + } + + return $this->handleRequest($userId, $path); + } +======= + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + + // because we got here, the request uri should look like: + // /index.php/apps/solid/~{userId}/storage{path} + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^contacts/", "", $path); + + return $this->handleRequest($userId, $path); + } +>>>>>>> 3100599 (replace @ with ~ in urls) + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleDelete($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleHead($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handlePatch($userId, $path) { + return $this->handleRequest($userId, $path); + } + + private function respond($response) { + $statusCode = $response->getStatusCode(); + $response->getBody()->rewind(); + $headers = $response->getHeaders(); + + $body = $response->getBody()->getContents(); + if ($statusCode > 399) { + $reason = $response->getReasonPhrase(); + $result = new JSONResponse($reason, $statusCode); + return $result; + } + + $result = new PlainResponse($body); + + foreach ($headers as $header => $values) { + foreach ($values as $value) { + $result->addHeader($header, $value); + } + } + + $result->setStatus($statusCode); + return $result; + } +} diff --git a/solid/lib/Controller/GetStorageUrlTrait.php~ b/solid/lib/Controller/GetStorageUrlTrait.php~ new file mode 100644 index 00000000..97a312b7 --- /dev/null +++ b/solid/lib/Controller/GetStorageUrlTrait.php~ @@ -0,0 +1,91 @@ +config = $config; + } + + final public function setUrlGenerator(IURLGenerator $urlGenerator): void + { + $this->urlGenerator = $urlGenerator; + } + + ////////////////////////////// CLASS PROPERTIES \\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + protected ServerConfig $config; + protected IURLGenerator $urlGenerator; + + /////////////////////////////// PROTECTED API \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + /** + * @FIXME: Add check for bob.nextcloud.local/solid/alice to throw 404 + * @TODO: Use route without `@alice` in /apps/solid/@alice/profile/card#me when user-domains are enabled + */ + public function getStorageUrl($userId) { + $routeUrl = $this->urlGenerator->linkToRoute( + 'solid.storage.handleHead', + ['userId' => $userId, 'path' => 'foo'] + ); + + $storageUrl = $this->urlGenerator->getAbsoluteURL($routeUrl); + + // (?) $storageUrl = preg_replace('/foo$/', '', $storageUrl); + $storageUrl = preg_replace('/foo$/', '/', $storageUrl); + + if ($this->config->getUserSubDomainsEnabled()) { + $url = parse_url($storageUrl); + + if (strpos($url['host'], $userId . '.') !== false) { + $url['host'] = str_replace($userId . '.', '', $url['host']); + } + + $url['host'] = $userId . '.' . $url['host']; // $storageUrl = $userId . '.' . $storageUrl; + $storageUrl = $this->build_url($url); + } + + return $storageUrl; + } + + public function validateUrl(RequestInterface $request): bool { + $isValid = false; + + $host = $request->getUri()->getHost(); + $path = $request->getUri()->getPath(); + $pathParts = explode('/', $path); + + $pathUsers = array_filter($pathParts, static function ($value) { + return str_starts_with($value, '@'); + }); + + if (count($pathUsers) === 1) { + $pathUser = reset($pathUsers); + $subDomainUser = explode('.', $host)[0]; + + $isValid = $pathUser === '@' . $subDomainUser; + } + + return $isValid; + } + + ////////////////////////////// UTILITY METHODS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + private function build_url(array $parts) { + // @FIXME: Replace with existing more robust URL builder + return (isset($parts['scheme']) ? "{$parts['scheme']}:" : '') . + (isset($parts['host']) ? "//{$parts['host']}" : '') . + (isset($parts['port']) ? ":{$parts['port']}" : '') . + (isset($parts['path']) ? "{$parts['path']}" : '') . + (isset($parts['query']) ? "?{$parts['query']}" : '') . + (isset($parts['fragment']) ? "#{$parts['fragment']}" : ''); + } +} diff --git a/solid/lib/Controller/PageController.php~ b/solid/lib/Controller/PageController.php~ new file mode 100644 index 00000000..3109ad8e --- /dev/null +++ b/solid/lib/Controller/PageController.php~ @@ -0,0 +1,152 @@ +userId = $userId; + $this->userManager = $userManager; + $this->request = $request; + $this->urlGenerator = $urlGenerator; + $this->config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); + } + + /** + * CAUTION: the @Stuff turns off security checks; for this page no admin is + * required and no CSRF check. If you don't know what CSRF is, read + * it up in the docs or you might create a security hole. This is + * basically the only required method to add this exemption, don't + * add it to any other method if you don't exactly know what it does + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function index() { + return new TemplateResponse('solid', 'index'); // templates/index.php + } + + private function getUserProfile($userId) { + if ($this->userManager->userExists($userId)) { + $user = $this->userManager->get($userId); + if ($user !== null) { + $profile = array( + 'id' => $userId, + 'displayName' => $user->getDisplayName(), + 'profileUri' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me", + 'solidNavigation' => array( + "profile" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.page.profile", array("userId" => $userId))), + "launcher" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.app.appLauncher", array())), + ) + ); + return $profile; + } + } + return false; + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function profile($userId) { + // header("Access-Control-Allow-Headers: *, authorization, accept, content-type"); + // header("Access-Control-Allow-Credentials: true"); + $profile = $this->getUserProfile($userId); + if (!$profile) { + return new JSONResponse(array(), Http::STATUS_NOT_FOUND); + } + $templateResponse = new TemplateResponse('solid', 'profile', $profile); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedStyleDomain("data:"); + $templateResponse->setContentSecurityPolicy($policy); + return $templateResponse; + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function approval($clientId) { + $clientRegistration = $this->config->getClientRegistration($clientId); + $params = array( + "clientId" => $clientId, + "clientName" => $clientRegistration['client_name'], + "serverName" => "Nextcloud", + "returnUrl" => $_GET['returnUrl'], + ); + $templateResponse = new TemplateResponse('solid', 'sharing', $params); + + $policy = new ContentSecurityPolicy(); + $policy->addAllowedStyleDomain("data:"); + + $parsedOrigin = parse_url($clientRegistration['redirect_uris'][0]); + $origin = $parsedOrigin['host']; + if ($origin) { + $policy->addAllowedFormActionDomain($parsedOrigin['scheme'] . "://" . $origin); + $templateResponse->setContentSecurityPolicy($policy); + } + return $templateResponse; + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function customscheme() { + $templateResponse = new TemplateResponse('solid', 'customscheme'); + return $templateResponse; + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleApproval($clientId) { + $approval = $_POST['approval']; + if ($approval == "allow") { + $this->config->addAllowedClient($this->userId, $clientId); + } else { + $this->config->removeAllowedClient($this->userId, $clientId); + } + $authUrl = $_POST['returnUrl']; + + $result = new JSONResponse("ok"); + + $result->setStatus("302"); + $result->addHeader("Location", $authUrl); + return $result; + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleRevoke($clientId) { + $this->config->removeAllowedClient($this->userId, $clientId); + $result = new JSONResponse("ok"); + return $result; + } +} diff --git a/solid/lib/Controller/ProfileController.php b/solid/lib/Controller/ProfileController.php index 24560dc7..66391438 100644 --- a/solid/lib/Controller/ProfileController.php +++ b/solid/lib/Controller/ProfileController.php @@ -204,31 +204,30 @@ public function handlePost($userId, $path) { * @NoAdminRequired * @NoCSRFRequired */ - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - - // because we got here, the request uri should look like: - // - if we have user subdomains enabled: - // /index.php/apps/solid/profile{path} - // and otherwise: - // index.php/apps/solid/~{userId}/profile{path} - + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/profile{path} + // and otherwise: + // index.php/apps/solid/~{userId}/profile{path} // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; - if ($this->config->getUserSubDomainsEnabled()) { - $pathInfo = explode("profile/", $_SERVER['REQUEST_URI']); - $path = $pathInfo[1]; - $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; - } else { - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^profile/", "", $path); - } - - return $this->handleRequest($userId, $path); - } + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("profile/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^profile/", "", $path); + } + + return $this->handleRequest($userId, $path); + } /** * @PublicPage * @NoAdminRequired diff --git a/solid/lib/Controller/ProfileController.php~ b/solid/lib/Controller/ProfileController.php~ new file mode 100644 index 00000000..70ba5e12 --- /dev/null +++ b/solid/lib/Controller/ProfileController.php~ @@ -0,0 +1,378 @@ +config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); + $this->request = $request; + $this->urlGenerator = $urlGenerator; + $this->userManager = $userManager; + $this->contactsManager = $contactsManager; + $this->session = $session; + + $this->setJtiStorage($connection); + } + + private function getFileSystem($userId) { + // Make sure the root folder has an acl file, as is required by the spec; + // Generate a default file granting the owner full access. + $defaultAcl = $this->generateDefaultAcl($userId); + $profile = $this->generateTurtleProfile($userId); + + // Create the Nextcloud Calendar Adapter + $adapter = new \Pdsinterop\Flysystem\Adapter\NextcloudProfile($userId, $profile, $defaultAcl, $this->config); + + $graph = new \EasyRdf\Graph(); + // Create Formats objects + $formats = new \Pdsinterop\Rdf\Formats(); + + $serverParams = $this->rawRequest->getServerParams(); + $scheme = $serverParams['REQUEST_SCHEME']; + $domain = $serverParams['SERVER_NAME']; + $path = $serverParams['REQUEST_URI']; + $serverUri = "{$scheme}://{$domain}{$path}"; // FIXME: doublecheck that this is the correct url; + + // Create the RDF Adapter + $rdfAdapter = new \Pdsinterop\Rdf\Flysystem\Adapter\Rdf( + $adapter, + $graph, + $formats, + $serverUri + ); + + $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); + + $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); + + $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); + $filesystem->addPlugin($plugin); + + return $filesystem; + } + + private function generateDefaultAcl($userId) { + $defaultAcl = <<< EOF +# Root ACL resource for the user account +@prefix acl: . +@prefix foaf: . + +# The profile is readable by the public +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read. + +# The owner has full access to every resource in their pod. +# Other agents have no access rights, +# unless specifically authorized in other .acl resources. +<#owner> + a acl:Authorization; + acl:agent <{user-profile-uri}>; + # Set the access to the root storage folder itself + acl:accessTo <./>; + # All resources will inherit this authorization, by default + acl:default <./>; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. +EOF; + + $profileUri = $this->getUserProfileUri($userId); + $defaultAcl = str_replace("{user-profile-uri}", $profileUri, $defaultAcl); + return $defaultAcl; + } + + private function getUserProfileUri($userId) { + return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me"; + } + private function getProfileUrl($userId) { + $profileUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleHead", array("userId" => $userId, "path" => "foo"))); + $profileUrl = preg_replace('/foo$/', '', $profileUrl); + return $profileUrl; + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleRequest($userId, $path) { + $this->userId = $userId; + + $this->rawRequest = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); + $this->response = new \Laminas\Diactoros\Response(); + + $this->filesystem = $this->getFileSystem($userId); + + $this->resourceServer = new ResourceServer($this->filesystem, $this->response); + $this->WAC = new WAC($this->filesystem); + + $request = $this->rawRequest; + $baseUrl = $this->getProfileUrl($userId); + $this->resourceServer->setBaseUrl($baseUrl); + $this->WAC->setBaseUrl($baseUrl); + $notifications = new SolidNotifications(); + $this->resourceServer->setNotifications($notifications); + + $dpop = $this->getDpop(); + + if ($request->getHeaderLine("DPop")) { + try { + $webId = $dpop->getWebId($request); + } catch(\Pdsinterop\Solid\Auth\Exception\Exception $e) { + $response = $this->resourceServer->getResponse() + ->withStatus(Http::STATUS_CONFLICT, "Invalid token " . $e->getMessage()); + return $this->respond($response); + } + } else { + $webId = ""; + } + + if (!$this->WAC->isAllowed($request, $webId)) { + $response = $this->resourceServer->getResponse()->withStatus(403, "Access denied"); + return $this->respond($response); + } + + $response = $this->resourceServer->respondToRequest($request); + $response = $this->WAC->addWACHeaders($request, $response, $webId); + return $this->respond($response); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleGet($userId, $path) { + //TODO: check that the $userId matches the userDomain, if enabled. + return $this->handleRequest($userId, $path); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handlePost($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + +<<<<<<< HEAD + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/profile{path} + // and otherwise: + // index.php/apps/solid/~{userId}/profile{path} +======= + // because we got here, the request uri should look like: + // /index.php/apps/solid/~{userId}/storage{path} + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^profile/", "", $path); +>>>>>>> 3100599 (replace @ with ~ in urls) + + // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("profile/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^profile/", "", $path); + } + + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleDelete($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleHead($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handlePatch($userId, $path) { + return $this->handleRequest($userId, $path); + } + + private function respond($response) { + $statusCode = $response->getStatusCode(); + $response->getBody()->rewind(); + $headers = $response->getHeaders(); + + $body = $response->getBody()->getContents(); + if ($statusCode > 399) { + $reason = $response->getReasonPhrase(); + $result = new JSONResponse($reason, $statusCode); + return $result; + } + + $result = new PlainResponse($body); + + foreach ($headers as $header => $values) { + foreach ($values as $value) { + $result->addHeader($header, $value); + } + } +// $origin = $_SERVER['HTTP_ORIGIN'] ?? "*"; +// $result->addHeader('Access-Control-Allow-Credentials', 'true'); +// $result->addHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); +// $result->addHeader('Access-Control-Allow-Origin', $origin); + $result->setStatus($statusCode); + return $result; + } + + private function getUserProfile($userId) { + if ($this->userManager->userExists($userId)) { + $user = $this->userManager->get($userId); + $addressBooks = $this->contactsManager->getUserAddressBooks(); + $friends = []; + foreach($addressBooks as $k => $v) { + $results = $addressBooks[$k]->search('', ['FN'], ['types' => true]); + foreach($results as $found) { + if (isset($found['URL']) && is_array($found['URL'])) { + foreach($found['URL'] as $i => $obj) { + array_push($friends, $obj['value']); + } + } + } + } + //TODO: privateTypeIndex and publisTypeIndex need to user getStorageURL + if ($user !== null) { + $profile = array( + 'id' => $userId, + 'displayName' => $user->getDisplayName(), + 'profileUri' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me", + 'friends' => $friends, + 'inbox' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/inbox/"))), + 'preferences' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/settings/preferences.ttl"))), + 'privateTypeIndex' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/settings/privateTypeIndex.ttl"))), + 'publicTypeIndex' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/settings/publicTypeIndex.ttl"))), + 'storage' => $this->getStorageUrl($userId), + 'issuer' => $this->urlGenerator->getBaseURL() + ); + return $profile; + } + } + return false; + } + + private function generateTurtleProfile($userId) { + $profile = $this->getUserProfile($userId); + if (!$profile) { + return ""; + } + ob_start(); + ?>@prefix : <#>. + @prefix solid: . + @prefix pro: <./>. + @prefix foaf: . + @prefix schem: . + @prefix acl: . + @prefix ldp: . + @prefix inbox: <>. + @prefix sp: . + @prefix ser: <>. + + pro:card a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. + + :me + a schem:Person, foaf:Person; + ldp:inbox inbox:; + sp:preferencesFile <>; + sp:storage ser:; + solid:account ser:; + solid:privateTypeIndex <>; + solid:publicTypeIndex <>; + solid:oidcIssuer <>; + $friend) { + ?> + foaf:knows <>; + + foaf:name ""; + "". + urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))); + $baseProfile = $this->config->getProfileData($userId); + $graph = new \EasyRdf\Graph(); + $graph->parse($baseProfile, "turtle", $baseUrl); + $graph->parse($generatedProfile, "turtle", $baseUrl); + $combinedProfile = $graph->serialise("turtle"); + return $combinedProfile; + } +} diff --git a/solid/lib/Controller/ServerController.php~ b/solid/lib/Controller/ServerController.php~ new file mode 100644 index 00000000..9c9044ee --- /dev/null +++ b/solid/lib/Controller/ServerController.php~ @@ -0,0 +1,449 @@ +config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); + $this->userId = $userId; + $this->userManager = $userManager; + $this->request = $request; + $this->urlGenerator = $urlGenerator; + $this->session = $session; + + $this->setJtiStorage($connection); + + $this->authServerConfig = $this->createAuthServerConfig(); + $this->authServerFactory = (new \Pdsinterop\Solid\Auth\Factory\AuthorizationServerFactory($this->authServerConfig))->create(); + + $this->tokenGenerator = new \Pdsinterop\Solid\Auth\TokenGenerator( + $this->authServerConfig, + $this->getDpopValidFor(), + $this->getDpop() + ); + } + + private function getOpenIdEndpoints() { + return [ + 'issuer' => $this->urlGenerator->getBaseURL(), + 'authorization_endpoint' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.authorize")), + 'jwks_uri' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.jwks")), + "check_session_iframe" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.session")), + "end_session_endpoint" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.logout")), + "token_endpoint" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.token")), + "userinfo_endpoint" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.userinfo")), + "registration_endpoint" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.register")) + ]; + } + + private function getKeys() { + $encryptionKey = $this->config->getEncryptionKey(); + $privateKey = $this->config->getPrivateKey(); + $key = openssl_pkey_get_private($privateKey); + $publicKey = openssl_pkey_get_details($key)['key']; + return [ + "encryptionKey" => $encryptionKey, + "privateKey" => $privateKey, + "publicKey" => $publicKey + ]; + } + + private function createAuthServerConfig() { + $clientId = isset($_GET['client_id']) ? $_GET['client_id'] : null; + $client = $this->getClient($clientId); + $keys = $this->getKeys(); + try { + return (new \Pdsinterop\Solid\Auth\Factory\ConfigFactory( + $client, + $keys['encryptionKey'], + $keys['privateKey'], + $keys['publicKey'], + $this->getOpenIdEndpoints() + ))->create(); + } catch(\Throwable $e) { + // var_dump($e); + return null; + } + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function cors($path) { + $origin = $_SERVER['HTTP_ORIGIN']; + return (new DataResponse('OK')); +// ->addHeader('Access-Control-Allow-Origin', $origin) +// ->addHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') +// ->addHeader('Access-Control-Allow-Methods', 'POST') +// ->addHeader('Access-Control-Allow-Credentials', 'true'); + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function authorize() { + // Create a request + if (!$this->userManager->userExists($this->userId)) { + $result = new JSONResponse('Authorization required'); + $result->setStatus(401); + return $result; +// return $result->addHeader('Access-Control-Allow-Origin', '*'); + } + + if (isset($_GET['request'])) { + $jwtConfig = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($this->config->getPrivateKey())); + try { + $token = $jwtConfig->parser()->parse($_GET['request']); + $this->session->set("nonce", $token->claims()->get('nonce')); + } catch(\Exception $e) { + $this->session->set("nonce", $_GET['nonce']); + } + } + + $getVars = $_GET; + if (!isset($getVars['grant_type'])) { + $getVars['grant_type'] = 'implicit'; + } + $getVars['response_type'] = $this->getResponseType(); + $getVars['scope'] = "openid" ; + + if (!isset($getVars['redirect_uri'])) { + if (!isset($token)) { + $result = new JSONResponse('Bad request, does not contain valid token'); + $result->setStatus(400); + return $result; +// return $result->addHeader('Access-Control-Allow-Origin', '*'); + } + try { + $getVars['redirect_uri'] = $token->claims()->get("redirect_uri"); + } catch(\Exception $e) { + $result = new JSONResponse('Bad request, missing redirect uri'); + $result->setStatus(400); + return $result; +// return $result->addHeader('Access-Control-Allow-Origin', '*'); + } + } + + if (preg_match("/^http(s)?:/", $getVars['client_id'])) { + $parsedOrigin = parse_url($getVars['redirect_uri']); + $origin = $parsedOrigin['scheme'] . '://' . $parsedOrigin['host']; + if (isset($parsedOrigin['port'])) { + $origin .= ":" . $parsedOrigin['port']; + } + $clientData = array( + "client_id_issued_at" => time(), + "client_name" => $getVars['client_id'], + "origin" => $origin, + "redirect_uris" => array( + $getVars['redirect_uri'] + ) + ); + $clientId = $this->config->saveClientRegistration($origin, $clientData)['client_id']; + $clientId = $this->config->saveClientRegistration($getVars['client_id'], $clientData)['client_id']; + $returnUrl = $getVars['redirect_uri']; + } else { + $clientId = $getVars['client_id']; + $returnUrl = $_SERVER['REQUEST_URI']; + } + + $clientRegistration = $this->config->getClientRegistration($clientId); + if (isset($clientRegistration['blocked']) && ($clientRegistration['blocked'] === true)) { + $result = new JSONResponse('Unauthorized client'); + $result->setStatus(403); + return $result; + } + + $approval = $this->checkApproval($clientId); + if (!$approval) { + $result = new JSONResponse('Approval required'); + $result->setStatus(302); + $approvalUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.page.approval", array("clientId" => $clientId, "returnUrl" => $returnUrl))); + $result->addHeader("Location", $approvalUrl); + return $result; // ->addHeader('Access-Control-Allow-Origin', '*'); + } + + $parsedOrigin = parse_url($clientRegistration['redirect_uris'][0]); + if ($parsedOrigin['scheme'] != "https" && !isset($_GET['customscheme'])) { + $result = new JSONResponse('Custom schema'); + $result->setStatus(302); + $originalRequest = parse_url($_SERVER['REQUEST_URI']); + $customSchemeUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.page.customscheme")) . ($originalRequest['query'] ? "?" . $originalRequest['query'] . "&customscheme=" . $parsedOrigin['scheme'] : ''); + $result->addHeader("Location", $customSchemeUrl); + return $result; + } + + $user = new \Pdsinterop\Solid\Auth\Entity\User(); + $user->setIdentifier($this->getProfilePage()); + + $request = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $getVars, $_POST, $_COOKIE, $_FILES); + $response = new \Laminas\Diactoros\Response(); + $server = new \Pdsinterop\Solid\Auth\Server($this->authServerFactory, $this->authServerConfig, $response); + + $response = $server->respondToAuthorizationRequest($request, $user, $approval); + $response = $this->tokenGenerator->addIdTokenToResponse( + $response, + $clientId, + $this->getProfilePage(), + $this->session->get("nonce"), + $this->config->getPrivateKey() + ); + + return $this->respond($response); // ->addHeader('Access-Control-Allow-Origin', '*'); + } + + private function checkApproval($clientId) { + $allowedClients = $this->config->getAllowedClients($this->userId); + if ($clientId == md5("tester")) { // FIXME: Double check that this is not a security issue; It is only here to help the test suite; + return \Pdsinterop\Solid\Auth\Enum\Authorization::APPROVED; + } + if ($clientId == md5("https://tester")) { // FIXME: Double check that this is not a security issue; It is only here to help the test suite; + return \Pdsinterop\Solid\Auth\Enum\Authorization::APPROVED; + } + if (in_array($clientId, $allowedClients)) { + return \Pdsinterop\Solid\Auth\Enum\Authorization::APPROVED; + } else { + return \Pdsinterop\Solid\Auth\Enum\Authorization::DENIED; + } + } + + private function getProfilePage() { + return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $this->userId, "path" => "/card"))) . "#me"; + } + + private function getResponseType() { + $responseTypes = explode(" ", $_GET['response_type']); + foreach ($responseTypes as $responseType) { + switch ($responseType) { + case "token": + return "token"; + break; + case "code": + return "code"; + break; + } + } + return "token"; // default to token response type; + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function session() { + return new JSONResponse("ok"); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function token() { + $request = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); + $code = $request->getParsedBody()['code']; + $clientId = $request->getParsedBody()['client_id']; + + $httpDpop = $request->getServerParams()['HTTP_DPOP']; + + $response = new \Laminas\Diactoros\Response(); + $server = new \Pdsinterop\Solid\Auth\Server($this->authServerFactory, $this->authServerConfig, $response); + $response = $server->respondToAccessTokenRequest($request); + + // FIXME: not sure if decoding this here is the way to go. + // FIXME: because this is a public page, the nonce from the session is not available here. + $codeInfo = $this->tokenGenerator->getCodeInfo($code); + $response = $this->tokenGenerator->addIdTokenToResponse( + $response, + $clientId, + $codeInfo['user_id'], + ($_SESSION['nonce'] ?? ''), + $this->config->getPrivateKey(), + $httpDpop + ); + + return $this->respond($response); // ->addHeader('Access-Control-Allow-Origin', '*'); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function userinfo() { + return new JSONResponse("ok"); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function logout() { + $this->userService->logout(); + return new JSONResponse("ok"); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function register() { + $clientData = file_get_contents('php://input'); + $clientData = json_decode($clientData, true); + if (!$clientData['redirect_uris']) { + return new JSONResponse("Missing redirect URIs"); + } + $clientData['client_id_issued_at'] = time(); + $parsedOrigin = parse_url($clientData['redirect_uris'][0]); + $origin = $parsedOrigin['scheme'] . '://' . $parsedOrigin['host']; + if (isset($parsedOrigin['port'])) { + $origin .= ":" . $parsedOrigin['port']; + } + + $clientData = $this->config->saveClientRegistration($origin, $clientData); + $registration = array( + 'client_id' => $clientData['client_id'], + /* + FIXME: returning client_secret will trigger calls with basic auth to us. To get this to work, we need this patch: + // File /var/www/vhosts/solid-nextcloud/site/www/lib/base.php not changed so no update needed + // ($request->getRawPathInfo() !== '/apps/oauth2/api/v1/token') && + // ($request->getRawPathInfo() !== '/apps/solid/token') + */ + // 'client_secret' => $clientData['client_secret'], // FIXME: Returning this means we need to patch Nextcloud to accept tokens on calls to + + 'registration_client_uri' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.registeredClient", array("clientId" => $clientData['client_id']))), + 'client_id_issued_at' => $clientData['client_id_issued_at'], + 'redirect_uris' => $clientData['redirect_uris'], + ); + $registration = $this->tokenGenerator->respondToRegistration($registration, $this->config->getPrivateKey()); + return (new JSONResponse($registration)); +// ->addHeader('Access-Control-Allow-Origin', $origin) +// ->addHeader('Access-Control-Allow-Methods', 'POST'); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function registeredClient($clientId) { + $clientRegistration = $this->config->getClientRegistration($clientId); + unset($clientRegistration['client_secret']); + return new JSONResponse($clientRegistration); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function jwks() { + $response = new \Laminas\Diactoros\Response(); + $server = new \Pdsinterop\Solid\Auth\Server($this->authServerFactory, $this->authServerConfig, $response); + $response = $server->respondToJwksMetadataRequest(); + return $this->respond($response); + } + + private function respond($response) { + $statusCode = $response->getStatusCode(); + $response->getBody()->rewind(); + $headers = $response->getHeaders(); + + $body = json_decode($response->getBody()->getContents()); + if ($statusCode > 399) { + // var_dump($body); + $reason = $response->getReasonPhrase(); + $result = new JSONResponse($reason, $statusCode); + return $result; + } + + if ($body == null) { + $body = 'ok'; + } + $result = new JSONResponse($body); + + foreach ($headers as $header => $values) { + foreach ($values as $value) { + $result->addHeader($header, $value); + } + } + $result->setStatus($statusCode); +// $result->addHeader('Access-Control-Allow-Origin', '*'); + return $result; + } + + private function getClient($clientId) { + $clientRegistration = $this->config->getClientRegistration($clientId); + + if ($clientId && count($clientRegistration)) { + return new \Pdsinterop\Solid\Auth\Config\Client( + $clientId, + $clientRegistration['client_secret'] ?? '', + $clientRegistration['redirect_uris'], + $clientRegistration['client_name'] + ); + } else { + return new \Pdsinterop\Solid\Auth\Config\Client('','',array(),''); + } + } +} diff --git a/solid/lib/Controller/SolidWebhookController.php b/solid/lib/Controller/SolidWebhookController.php index 371e3c02..5846097d 100644 --- a/solid/lib/Controller/SolidWebhookController.php +++ b/solid/lib/Controller/SolidWebhookController.php @@ -150,13 +150,13 @@ private function initializeStorage($userId) { } private function parseTopic($topic) { - // topic = https://nextcloud.server/solid/@alice/storage/foo/bar + // topic = https://nextcloud.server/solid/~alice/storage/foo/bar $appBaseUrl = $this->getAppBaseUrl(); // https://nextcloud.server/solid/ - $internalUrl = str_replace($appBaseUrl, '', $topic); // @alice/storage/foo/bar + $internalUrl = str_replace($appBaseUrl, '', $topic); // ~alice/storage/foo/bar $pathicles = explode("/", $internalUrl); - $userId = $pathicles[0]; // @alice - $userId = preg_replace("/^@/", "", $userId); // alice - $storageUrl = $this->getStorageUrl($userId); // https://nextcloud.server/solid/@alice/storage/ + $userId = $pathicles[0]; // ~alice + $userId = preg_replace("/^~/", "", $userId); // alice + $storageUrl = $this->getStorageUrl($userId); // https://nextcloud.server/solid/~alice/storage/ $storagePath = str_replace($storageUrl, '/', $topic); // /foo/bar return array( "userId" => $userId, @@ -182,7 +182,7 @@ private function createGetRequest($topic) { } private function checkReadAccess($topic) { - // split out $topic into $userId and $path https://nextcloud.server/solid/@alice/storage/foo/bar + // split out $topic into $userId and $path https://nextcloud.server/solid/~alice/storage/foo/bar // - userId in this case is the pod owner (not the one doing the request). (alice) // - path is the path within the storage pod (/foo/bar) $target = $this->parseTopic($topic); diff --git a/solid/lib/Controller/StorageController.php b/solid/lib/Controller/StorageController.php index de4cd201..4a834d3b 100644 --- a/solid/lib/Controller/StorageController.php +++ b/solid/lib/Controller/StorageController.php @@ -375,31 +375,30 @@ public function handlePost($userId, $path) { * @NoAdminRequired * @NoCSRFRequired */ - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - - // because we got here, the request uri should look like: - // - if we have user subdomains enabled: - // /index.php/apps/solid/storage{path} - // and otherwise: - // index.php/apps/solid/~{userId}/storage{path} - + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/storage{path} + // and otherwise: + // index.php/apps/solid/~{userId}/storage{path} // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; - if ($this->config->getUserSubDomainsEnabled()) { - $pathInfo = explode("storage/", $_SERVER['REQUEST_URI']); - $path = $pathInfo[1]; - $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; - } else { - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^storage/", "", $path); - } - - return $this->handleRequest($userId, $path); - } + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("storage/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^storage/", "", $path); + } + + return $this->handleRequest($userId, $path); + } /** * @PublicPage * @NoAdminRequired diff --git a/solid/lib/Controller/StorageController.php~ b/solid/lib/Controller/StorageController.php~ new file mode 100644 index 00000000..07541d13 --- /dev/null +++ b/solid/lib/Controller/StorageController.php~ @@ -0,0 +1,471 @@ +config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); + $this->rootFolder = $rootFolder; + $this->request = $request; + $this->urlGenerator = $urlGenerator; + $this->session = $session; + + $this->setJtiStorage($connection); + } + + private function getFileSystem() { + // Create the Nextcloud Adapter + $adapter = new \Pdsinterop\Flysystem\Adapter\Nextcloud($this->solidFolder); + $graph = new \EasyRdf\Graph(); + + // Create Formats objects + $formats = new \Pdsinterop\Rdf\Formats(); + + $serverParams = $this->rawRequest->getServerParams(); + $scheme = $serverParams['REQUEST_SCHEME']; + $domain = $serverParams['SERVER_NAME']; + $path = $serverParams['REQUEST_URI']; + $serverUri = "{$scheme}://{$domain}{$path}"; // FIXME: doublecheck that this is the correct url; + + // Create the RDF Adapter + $rdfAdapter = new \Pdsinterop\Rdf\Flysystem\Adapter\Rdf( + $adapter, + $graph, + $formats, + $serverUri + ); + + $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); + + $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); + + $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); + $filesystem->addPlugin($plugin); + + return $filesystem; + } + + private function getUserProfile($userId) { + return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me"; + } + + private function generateDefaultAcl($userId) { + $defaultAcl = <<< EOF +# Root ACL resource for the user account +@prefix acl: . +@prefix foaf: . + +# The homepage is readable by the public +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:mode acl:Read. + +# The owner has full access to every resource in their pod. +# Other agents have no access rights, +# unless specifically authorized in other .acl resources. +<#owner> + a acl:Authorization; + acl:agent <{user-profile-uri}>; + # Set the access to the root storage folder itself + acl:accessTo <./>; + # All resources will inherit this authorization, by default + acl:default <./>; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. +EOF; + + $profileUri = $this->getUserProfile($userId); + $defaultAcl = str_replace("{user-profile-uri}", $profileUri, $defaultAcl); + return $defaultAcl; + } + + private function generatePublicAppendAcl($userId) { + $publicAppendAcl = <<< EOF +# Inbox ACL resource for the user account +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:default <./>; + acl:mode + acl:Append. + +<#owner> + a acl:Authorization; + acl:agent <{user-profile-uri}>; + # Set the access to the root storage folder itself + acl:accessTo <./>; + # All resources will inherit this authorization, by default + acl:default <./>; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. +EOF; + + $profileUri = $this->getUserProfile($userId); + $publicAppendAcl = str_replace("{user-profile-uri}", $profileUri, $publicAppendAcl); + return $publicAppendAcl; + } + + private function generatePublicReadAcl($userId) { + $publicReadAcl = <<< EOF +# Inbox ACL resource for the user account +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:default <./>; + acl:mode + acl:Read. + +<#owner> + a acl:Authorization; + acl:agent <{user-profile-uri}>; + # Set the access to the root storage folder itself + acl:accessTo <./>; + # All resources will inherit this authorization, by default + acl:default <./>; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. +EOF; + + $profileUri = $this->getUserProfile($userId); + $publicReadAcl = str_replace("{user-profile-uri}", $profileUri, $publicReadAcl); + return $publicReadAcl; + } + + private function generateDefaultPublicTypeIndex() { + $publicTypeIndex = <<< EOF +# Public type index +@prefix : <#>. +@prefix solid: . + +<> + a solid:ListedDocument, solid:TypeIndex. +EOF; + + return $publicTypeIndex; + } + + private function generateDefaultPrivateTypeIndex() { + $privateTypeIndex = <<< EOF +# Private type index +@prefix : <#>. +@prefix solid: . + +<> + a solid:UnlistedDocument, solid:TypeIndex. +EOF; + + return $privateTypeIndex; + } + private function generateDefaultPreferences($userId) { + $preferences = <<< EOF +# Preferences +@prefix : <#>. +@prefix sp: . +@prefix dct: . +@prefix profile: <{user-profile-uri}>. +@prefix solid: . + +<> + a sp:ConfigurationFile; + dct:title "Preferences file". + +profile:me + a solid:Developer; + solid:privateTypeIndex ; + solid:publicTypeIndex . +EOF; + + $profileUri = $this->getUserProfile($userId); + $preferences = str_replace("{user-profile-uri}", $profileUri, $preferences); + return $preferences; + } + private function initializeStorage($userId) { + $this->userFolder = $this->rootFolder->getUserFolder($userId); + if (!$this->userFolder->nodeExists("solid")) { + $this->userFolder->newFolder("solid"); // Create the Solid directory for storage if it doesn't exist. + } + $this->solidFolder = $this->userFolder->get("solid"); + + $this->filesystem = $this->getFileSystem(); + + // Make sure the root folder has an acl file, as is required by the spec; + // Generate a default file granting the owner full access if there is nothing there. + if (!$this->filesystem->has("/.acl")) { + $defaultAcl = $this->generateDefaultAcl($userId); + $this->filesystem->write("/.acl", $defaultAcl); + } + + // Generate default folders and ACLs: + if (!$this->filesystem->has("/inbox")) { + $this->filesystem->createDir("/inbox"); + } + if (!$this->filesystem->has("/inbox/.acl")) { + $inboxAcl = $this->generatePublicAppendAcl($userId); + $this->filesystem->write("/inbox/.acl", $inboxAcl); + } + if (!$this->filesystem->has("/settings")) { + $this->filesystem->createDir("/settings"); + } + if (!$this->filesystem->has("/settings/privateTypeIndex.ttl")) { + $privateTypeIndex = $this->generateDefaultPrivateTypeIndex(); + $this->filesystem->write("/settings/privateTypeIndex.ttl", $privateTypeIndex); + } + if (!$this->filesystem->has("/settings/publicTypeIndex.ttl")) { + $publicTypeIndex = $this->generateDefaultPublicTypeIndex(); + $this->filesystem->write("/settings/publicTypeIndex.ttl", $publicTypeIndex); + } + if (!$this->filesystem->has("/settings/preferences.ttl")) { + $preferences = $this->generateDefaultPreferences($userId); + $this->filesystem->write("/settings/preferences.ttl", $preferences); + } + if (!$this->filesystem->has("/public")) { + $this->filesystem->createDir("/public"); + } + if (!$this->filesystem->has("/public/.acl")) { + $publicAcl = $this->generatePublicReadAcl($userId); + $this->filesystem->write("/public/.acl", $publicAcl); + } + if (!$this->filesystem->has("/private")) { + $this->filesystem->createDir("/private"); + } + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleRequest($userId, $path) { + $this->rawRequest = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); + $this->response = new \Laminas\Diactoros\Response(); + + $this->initializeStorage($userId); + + $this->resourceServer = new ResourceServer($this->filesystem, $this->response); + $this->WAC = new WAC($this->filesystem); + + $request = $this->rawRequest; + $baseUrl = $this->getStorageUrl($userId); + $this->resourceServer->setBaseUrl($baseUrl); + $this->WAC->setBaseUrl($baseUrl); + + $notifications = new SolidNotifications(); + $this->resourceServer->setNotifications($notifications); + + $dpop = $this->getDpop(); + + $error = false; + try { + $webId = $dpop->getWebId($request); + } catch(\Pdsinterop\Solid\Auth\Exception\Exception $e) { + $error = $e; + } + + if (!isset($webId)) { + $bearer = $this->getBearer(); + try { + $webId = $bearer->getWebId($request); + } catch(\Pdsinterop\Solid\Auth\Exception\Exception $e) { + $error = $e; + } + } + + if (!isset($webId)) { + $response = $this->resourceServer->getResponse() + ->withStatus(Http::STATUS_CONFLICT, "Invalid token"); + return $this->respond($response); + } + + $origin = $request->getHeaderLine("Origin"); + $allowedClients = $this->config->getAllowedClients($userId); + $allowedOrigins = array(); + foreach ($allowedClients as $clientId) { + $clientRegistration = $this->config->getClientRegistration($clientId); + if (isset($clientRegistration['client_name'])) { + $allowedOrigins[] = $clientRegistration['client_name']; + } + if (isset($clientRegistration['origin'])) { + $allowedOrigins[] = $clientRegistration['origin']; + } + } + if (!$this->WAC->isAllowed($request, $webId, $origin, $allowedOrigins)) { + $response = $this->resourceServer->getResponse() + ->withStatus(403, "Access denied"); + return $this->respond($response); + } + $response = $this->resourceServer->respondToRequest($request); + $response = $this->WAC->addWACHeaders($request, $response, $webId); + return $this->respond($response); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleGet($userId, $path) { + return $this->handleRequest($userId, $path); + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handlePost($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handlePut() { // $userId, $path) { + // FIXME: Adding the correct variables in the function name will make nextcloud + // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; + +<<<<<<< HEAD + // because we got here, the request uri should look like: + // - if we have user subdomains enabled: + // /index.php/apps/solid/storage{path} + // and otherwise: + // index.php/apps/solid/~{userId}/storage{path} +======= + // because we got here, the request uri should look like: + // /index.php/apps/solid/~{userId}/storage{path} + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^storage/", "", $path); +>>>>>>> 3100599 (replace @ with ~ in urls) + + // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; + if ($this->config->getUserSubDomainsEnabled()) { + $pathInfo = explode("storage/", $_SERVER['REQUEST_URI']); + $path = $pathInfo[1]; + $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; + } else { + $pathInfo = explode("~", $_SERVER['REQUEST_URI']); + $pathInfo = explode("/", $pathInfo[1], 2); + $userId = $pathInfo[0]; + $path = $pathInfo[1]; + $path = preg_replace("/^storage/", "", $path); + } + + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleDelete($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handleHead($userId, $path) { + return $this->handleRequest($userId, $path); + } + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function handlePatch($userId, $path) { + return $this->handleRequest($userId, $path); + } + + private function respond($response) { + $statusCode = $response->getStatusCode(); + $response->getBody()->rewind(); + $headers = $response->getHeaders(); + + $body = $response->getBody()->getContents(); + + $result = new PlainResponse($body); + + foreach ($headers as $header => $values) { + $result->addHeader($header, implode(", ", $values)); + } + +// $origin = $_SERVER['HTTP_ORIGIN']; +// $result->addHeader('Access-Control-Allow-Credentials', 'true'); +// $result->addHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); +// $result->addHeader('Access-Control-Allow-Origin', $origin); + + $policy = new EmptyContentSecurityPolicy(); + $policy->addAllowedStyleDomain("*"); + $policy->addAllowedStyleDomain("data:"); + $policy->addAllowedScriptDomain("*"); + $policy->addAllowedImageDomain("*"); + $policy->addAllowedFontDomain("*"); + $policy->addAllowedConnectDomain("*"); + $policy->allowInlineStyle(true); + // $policy->allowInlineScript(true); - removed, this function no longer exists in NC28 + $policy->allowEvalScript(true); + $result->setContentSecurityPolicy($policy); + + $result->setStatus($statusCode); + return $result; + } +} From 2fb8e0e5d0fac3778f954412694087ae3a9f67e1 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 26 May 2025 11:18:26 +0200 Subject: [PATCH 21/54] update pdsinterop packages --- solid/composer.json | 6 +-- solid/composer.lock | 122 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 102 insertions(+), 26 deletions(-) diff --git a/solid/composer.json b/solid/composer.json index 61dbb3b6..3df0b68d 100644 --- a/solid/composer.json +++ b/solid/composer.json @@ -30,9 +30,9 @@ "laminas/laminas-diactoros": "^2.8", "lcobucci/jwt": "^4.1", "pdsinterop/flysystem-nextcloud": "^0.2", - "pdsinterop/flysystem-rdf": "^0.5", - "pdsinterop/solid-auth": "v0.11.0", - "pdsinterop/solid-crud": "^0.7.3", + "pdsinterop/flysystem-rdf": "^0.6", + "pdsinterop/solid-auth": "^0.12.1", + "pdsinterop/solid-crud": "^0.8", "psr/log": "^1.1" }, "require-dev": { diff --git a/solid/composer.lock b/solid/composer.lock index 40fd3303..9b8301f3 100644 --- a/solid/composer.lock +++ b/solid/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1843d50801f15c12e9fb50345b3bfb3b", + "content-hash": "59e7e42f9da02ea5c9908f49de8cdae5", "packages": [ { "name": "arc/base", @@ -1455,24 +1455,24 @@ }, { "name": "pdsinterop/flysystem-rdf", - "version": "v0.5.0", + "version": "v0.6.0", "source": { "type": "git", "url": "https://github.com/pdsinterop/flysystem-rdf.git", - "reference": "2a0b105f66c16b664bcd56f30d76f464b18be065" + "reference": "cb72c2a0538b2a552a9281f2bd9e4a7f48ca035d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pdsinterop/flysystem-rdf/zipball/2a0b105f66c16b664bcd56f30d76f464b18be065", - "reference": "2a0b105f66c16b664bcd56f30d76f464b18be065", + "url": "https://api.github.com/repos/pdsinterop/flysystem-rdf/zipball/cb72c2a0538b2a552a9281f2bd9e4a7f48ca035d", + "reference": "cb72c2a0538b2a552a9281f2bd9e4a7f48ca035d", "shasum": "" }, "require": { - "easyrdf/easyrdf": "^1.1.1", "ext-mbstring": "*", "league/flysystem": "^1.0", "ml/json-ld": "^1.2", - "php": "^8.0" + "php": "^8.0", + "sweetrdf/easyrdf": "^1.1" }, "require-dev": { "phpunit/phpunit": "^8|^9" @@ -1490,22 +1490,22 @@ "description": "Flysystem plugin to transform RDF data between various serialization formats.", "support": { "issues": "https://github.com/pdsinterop/flysystem-rdf/issues", - "source": "https://github.com/pdsinterop/flysystem-rdf/tree/v0.5.0" + "source": "https://github.com/pdsinterop/flysystem-rdf/tree/v0.6.0" }, - "time": "2022-08-22T14:36:29+00:00" + "time": "2025-05-16T08:57:11+00:00" }, { "name": "pdsinterop/solid-auth", - "version": "v0.11.0", + "version": "v0.12.1", "source": { "type": "git", "url": "https://github.com/pdsinterop/php-solid-auth.git", - "reference": "0c5f65b0a9340fe9d50bef9d0e279db54610ffac" + "reference": "3674b864cbf8b70fa6da49bb8ee4e0f6c772cbc1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pdsinterop/php-solid-auth/zipball/0c5f65b0a9340fe9d50bef9d0e279db54610ffac", - "reference": "0c5f65b0a9340fe9d50bef9d0e279db54610ffac", + "url": "https://api.github.com/repos/pdsinterop/php-solid-auth/zipball/3674b864cbf8b70fa6da49bb8ee4e0f6c772cbc1", + "reference": "3674b864cbf8b70fa6da49bb8ee4e0f6c772cbc1", "shasum": "" }, "require": { @@ -1514,7 +1514,7 @@ "ext-openssl": "*", "laminas/laminas-diactoros": "^2.8", "lcobucci/jwt": "^4.1", - "league/oauth2-server": "^8.3.5", + "league/oauth2-server": "^8.5.5", "php": "^8.0", "web-token/jwt-core": "^2.2" }, @@ -1539,22 +1539,22 @@ "description": "OAuth2, OpenID and OIDC for Solid Server implementations.", "support": { "issues": "https://github.com/pdsinterop/php-solid-auth/issues", - "source": "https://github.com/pdsinterop/php-solid-auth/tree/v0.11.0" + "source": "https://github.com/pdsinterop/php-solid-auth/tree/v0.12.1" }, - "time": "2025-02-14T12:57:21+00:00" + "time": "2025-05-18T15:26:58+00:00" }, { "name": "pdsinterop/solid-crud", - "version": "v0.7.3", + "version": "v0.8.0", "source": { "type": "git", "url": "https://github.com/pdsinterop/php-solid-crud.git", - "reference": "c5369ef7b46d3d77a7686c3f4531e818e1797e27" + "reference": "ca1421770b17c69cc5989ce6864e86405030a50c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pdsinterop/php-solid-crud/zipball/c5369ef7b46d3d77a7686c3f4531e818e1797e27", - "reference": "c5369ef7b46d3d77a7686c3f4531e818e1797e27", + "url": "https://api.github.com/repos/pdsinterop/php-solid-crud/zipball/ca1421770b17c69cc5989ce6864e86405030a50c", + "reference": "ca1421770b17c69cc5989ce6864e86405030a50c", "shasum": "" }, "require": { @@ -1562,7 +1562,7 @@ "laminas/laminas-diactoros": "^2.14", "league/flysystem": "^1.0", "mjrider/flysystem-factory": "^0.7", - "pdsinterop/flysystem-rdf": "^0.5", + "pdsinterop/flysystem-rdf": "^0.6", "php": "^8.0", "pietercolpaert/hardf": "^0.3", "psr/http-factory": "^1.0", @@ -1586,9 +1586,9 @@ "description": "Solid HTTPS REST API specification compliant implementation for handling Resource CRUD", "support": { "issues": "https://github.com/pdsinterop/php-solid-crud/issues", - "source": "https://github.com/pdsinterop/php-solid-crud/tree/v0.7.3" + "source": "https://github.com/pdsinterop/php-solid-crud/tree/v0.8.0" }, - "time": "2024-01-17T10:48:57+00:00" + "time": "2025-05-16T09:04:57+00:00" }, { "name": "phrity/net-uri", @@ -2128,6 +2128,82 @@ ], "time": "2020-11-03T09:10:25+00:00" }, + { + "name": "sweetrdf/easyrdf", + "version": "1.7", + "source": { + "type": "git", + "url": "https://github.com/sweetrdf/easyrdf.git", + "reference": "6952b79bd1818817f20d0c64de54c7ecd5a24947" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sweetrdf/easyrdf/zipball/6952b79bd1818817f20d0c64de54c7ecd5a24947", + "reference": "6952b79bd1818817f20d0c64de54c7ecd5a24947", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "ext-pcre": "*", + "ext-xmlreader": "*", + "lib-libxml": "*", + "php": "^7.1|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "ml/json-ld": "^1.0", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^7.5|^8.5|^9.5", + "semsol/arc2": "^2.4", + "zendframework/zend-http": "^2.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "EasyRdf\\": "lib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nicholas Humfrey", + "email": "njh@aelius.com", + "homepage": "http://www.aelius.com/njh/", + "role": "Developer" + }, + { + "name": "Alexey Zakhlestin", + "email": "indeyets@gmail.com", + "homepage": "http://indeyets.ru/", + "role": "Developer" + }, + { + "name": "Konrad Abicht", + "email": "hi@inspirito.de", + "homepage": "http://inspirito.de/", + "role": "Maintainer, Developer" + } + ], + "description": "EasyRdf is a PHP library designed to make it easy to consume and produce RDF.", + "keywords": [ + "Linked Data", + "RDF", + "Semantic Web", + "Turtle", + "rdfa", + "sparql" + ], + "support": { + "issues": "https://github.com/sweetrdf/easyrdf/issues", + "source": "https://github.com/sweetrdf/easyrdf/tree/1.7" + }, + "time": "2022-09-19T07:53:57+00:00" + }, { "name": "textalk/websocket", "version": "1.6.3", From c5b40f721536d4265f39bfa39ec7408c3152916b Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 26 May 2025 14:45:13 +0200 Subject: [PATCH 22/54] update test vars from @alice to ~alice --- env-vars-server.list | 4 ++-- env-vars-testers.list | 10 +++++----- env-vars-thirdparty.list | 2 +- env.list | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/env-vars-server.list b/env-vars-server.list index 28239ba7..1f38d6fe 100644 --- a/env-vars-server.list +++ b/env-vars-server.list @@ -1,6 +1,6 @@ SERVER_ROOT=https://server -STORAGE_ROOT=https://server/apps/solid/@alice/storage/ -ALICE_WEBID=https://server/apps/solid/@alice/profile/card#me +STORAGE_ROOT=https://server/apps/solid/~alice/storage/ +ALICE_WEBID=https://server/apps/solid/~alice/profile/card#me COOKIE_TYPE=nextcloud-compatible USERNAME=alice PASSWORD=alice123 diff --git a/env-vars-testers.list b/env-vars-testers.list index 366167e0..a8a79563 100644 --- a/env-vars-testers.list +++ b/env-vars-testers.list @@ -1,11 +1,11 @@ -WEBID_ALICE=https://server/apps/solid/@alice/profile/card#me +WEBID_ALICE=https://server/apps/solid/~alice/profile/card#me OIDC_ISSUER_ALICE=https://server -STORAGE_ROOT_ALICE=https://server/apps/solid/@alice/storage/ -WEBID_BOB=https://thirdparty/apps/solid/@alice/profile/card#me +STORAGE_ROOT_ALICE=https://server/apps/solid/~alice/storage/ +WEBID_BOB=https://thirdparty/apps/solid/~alice/profile/card#me OIDC_ISSUER_BOB=https://thirdparty STORAGE_ROOT_BOB=https://thirdparty/ -ALICE_WEBID=https://server/apps/solid/@alice/profile/card#me +ALICE_WEBID=https://server/apps/solid/~alice/profile/card#me SERVER_ROOT_ESCAPED=https:\/\/server SERVER_ROOT=https://server -STORAGE_ROOT=https://server/apps/solid/@alice/storage/ +STORAGE_ROOT=https://server/apps/solid/~alice/storage/ SKIP_CONC=1 diff --git a/env-vars-thirdparty.list b/env-vars-thirdparty.list index 9a2c8416..1c889484 100644 --- a/env-vars-thirdparty.list +++ b/env-vars-thirdparty.list @@ -1,5 +1,5 @@ SERVER_ROOT=https://thirdparty -ALICE_WEBID=https://thirdparty/apps/solid/@alice/profile/card#me +ALICE_WEBID=https://thirdparty/apps/solid/~alice/profile/card#me COOKIE_TYPE=nextcloud-compatible USERNAME=alice PASSWORD=alice123 diff --git a/env.list b/env.list index 1256e61d..cef5c00e 100644 --- a/env.list +++ b/env.list @@ -1,2 +1,2 @@ -ALICE_WEBID=https://server/apps/solid/@alice/profile/card#me +ALICE_WEBID=https://server/apps/solid/~alice/profile/card#me COOKIE_TYPE=nextcloud-compatible From cfadbdb188de0a3da9cf6da3fae0c2591b3adcb7 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Fri, 30 May 2025 10:55:33 +0200 Subject: [PATCH 23/54] update solid-auth to 0.12.2 --- solid/composer.json | 2 +- solid/composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/solid/composer.json b/solid/composer.json index 3df0b68d..b49ad6ae 100644 --- a/solid/composer.json +++ b/solid/composer.json @@ -31,7 +31,7 @@ "lcobucci/jwt": "^4.1", "pdsinterop/flysystem-nextcloud": "^0.2", "pdsinterop/flysystem-rdf": "^0.6", - "pdsinterop/solid-auth": "^0.12.1", + "pdsinterop/solid-auth": "^0.12.2", "pdsinterop/solid-crud": "^0.8", "psr/log": "^1.1" }, diff --git a/solid/composer.lock b/solid/composer.lock index 9b8301f3..026aa830 100644 --- a/solid/composer.lock +++ b/solid/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "59e7e42f9da02ea5c9908f49de8cdae5", + "content-hash": "630d8401030511a28cf54157d9bbd4cf", "packages": [ { "name": "arc/base", @@ -1496,16 +1496,16 @@ }, { "name": "pdsinterop/solid-auth", - "version": "v0.12.1", + "version": "v0.12.2", "source": { "type": "git", "url": "https://github.com/pdsinterop/php-solid-auth.git", - "reference": "3674b864cbf8b70fa6da49bb8ee4e0f6c772cbc1" + "reference": "1d1160ee0f7ca71d3e34151aea94232e1cfa49ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pdsinterop/php-solid-auth/zipball/3674b864cbf8b70fa6da49bb8ee4e0f6c772cbc1", - "reference": "3674b864cbf8b70fa6da49bb8ee4e0f6c772cbc1", + "url": "https://api.github.com/repos/pdsinterop/php-solid-auth/zipball/1d1160ee0f7ca71d3e34151aea94232e1cfa49ff", + "reference": "1d1160ee0f7ca71d3e34151aea94232e1cfa49ff", "shasum": "" }, "require": { @@ -1539,9 +1539,9 @@ "description": "OAuth2, OpenID and OIDC for Solid Server implementations.", "support": { "issues": "https://github.com/pdsinterop/php-solid-auth/issues", - "source": "https://github.com/pdsinterop/php-solid-auth/tree/v0.12.1" + "source": "https://github.com/pdsinterop/php-solid-auth/tree/v0.12.2" }, - "time": "2025-05-18T15:26:58+00:00" + "time": "2025-05-28T14:53:41+00:00" }, { "name": "pdsinterop/solid-crud", From 52d734e2fbc259492da018427676519d777e7c13 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Fri, 30 May 2025 11:24:05 +0200 Subject: [PATCH 24/54] remove backup files --- solid/lib/Controller/AppController.php~ | 99 ---- solid/lib/Controller/CalendarController.php~ | 278 ----------- solid/lib/Controller/ContactsController.php~ | 279 ----------- solid/lib/Controller/GetStorageUrlTrait.php~ | 91 ---- solid/lib/Controller/PageController.php~ | 152 ------ solid/lib/Controller/ProfileController.php~ | 378 --------------- solid/lib/Controller/ServerController.php~ | 449 ------------------ solid/lib/Controller/StorageController.php~ | 471 ------------------- 8 files changed, 2197 deletions(-) delete mode 100644 solid/lib/Controller/AppController.php~ delete mode 100644 solid/lib/Controller/CalendarController.php~ delete mode 100644 solid/lib/Controller/ContactsController.php~ delete mode 100644 solid/lib/Controller/GetStorageUrlTrait.php~ delete mode 100644 solid/lib/Controller/PageController.php~ delete mode 100644 solid/lib/Controller/ProfileController.php~ delete mode 100644 solid/lib/Controller/ServerController.php~ delete mode 100644 solid/lib/Controller/StorageController.php~ diff --git a/solid/lib/Controller/AppController.php~ b/solid/lib/Controller/AppController.php~ deleted file mode 100644 index d0b99e92..00000000 --- a/solid/lib/Controller/AppController.php~ +++ /dev/null @@ -1,99 +0,0 @@ -userId = $userId; - $this->userManager = $userManager; - $this->contactsManager = $contactsManager; - $this->request = $request; - $this->urlGenerator = $urlGenerator; - $this->config = new ServerConfig($config, $urlGenerator, $userManager); - } - - private function getUserApps($userId) { - $userApps = []; - if ($this->userManager->userExists($userId)) { - $allowedClients = $this->config->getAllowedClients($userId); - foreach ($allowedClients as $clientId) { - $registration = $this->config->getClientRegistration($clientId); - $userApps[] = $registration['client_name']; - } - } - return $userApps; - } - - private function getAppsList() { - $path = __DIR__ . "/../solid-app-list.json"; - $appsListJson = file_get_contents($path); - $appsList = json_decode($appsListJson, true); - - $userApps = $this->getUserApps($this->userId); - - foreach ($appsList as $key => $app) { - $parsedOrigin = parse_url($app['launchUrl']); - $origin = $parsedOrigin['host']; - if (in_array($origin, $userApps, true)) { - $appsList[$key]['registered'] = 1; - } else { - $appsList[$key]['registered'] = 0; - } - } - return $appsList; - } - - private function getProfilePage() { - return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $this->userId, "path" => "/card"))) . "#me"; - } - - /** - * @NoAdminRequired - * @NoCSRFRequired - */ - public function appLauncher() { - $appsList = $this->getAppsList(); - if (!$appsList) { - return new JSONResponse(array(), Http::STATUS_NOT_FOUND); - } - $appLauncherData = array( - "appsListJson" => json_encode($appsList), - "webId" => json_encode($this->getProfilePage()), - "storageUrl" => json_encode($this->getStorageUrl($this->userId)), - 'solidNavigation' => array( - "profile" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.page.profile", array("userId" => $this->userId))), - "launcher" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.app.appLauncher", array())), - ) - ); - $templateResponse = new TemplateResponse('solid', 'applauncher', $appLauncherData); - $policy = new ContentSecurityPolicy(); - $policy->addAllowedStyleDomain("data:"); - $policy->addAllowedScriptDomain("'self'"); - $policy->addAllowedScriptDomain("'unsafe-inline'"); - $policy->addAllowedScriptDomain("'unsafe-eval'"); - $templateResponse->setContentSecurityPolicy($policy); - return $templateResponse; - } -} diff --git a/solid/lib/Controller/CalendarController.php~ b/solid/lib/Controller/CalendarController.php~ deleted file mode 100644 index 439dcdb0..00000000 --- a/solid/lib/Controller/CalendarController.php~ +++ /dev/null @@ -1,278 +0,0 @@ -config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); - $this->request = $request; - $this->urlGenerator = $urlGenerator; - $this->session = $session; - - $this->setJtiStorage($connection); - } - - private function getFileSystem($userId) { - // Make sure the root folder has an acl file, as is required by the spec; - // Generate a default file granting the owner full access. - $defaultAcl = $this->generateDefaultAcl($userId); - - // Create the Nextcloud Calendar Adapter - $adapter = new \Pdsinterop\Flysystem\Adapter\NextcloudCalendar($userId, $defaultAcl); - - $graph = new \EasyRdf\Graph(); - - // Create Formats objects - $formats = new \Pdsinterop\Rdf\Formats(); - - $serverParams = $this->rawRequest->getServerParams(); - $scheme = $serverParams['REQUEST_SCHEME']; - $domain = $serverParams['SERVER_NAME']; - $path = $serverParams['REQUEST_URI']; - $serverUri = "{$scheme}://{$domain}{$path}"; // FIXME: doublecheck that this is the correct url; - - // Create the RDF Adapter - $rdfAdapter = new \Pdsinterop\Rdf\Flysystem\Adapter\Rdf( - $adapter, - $graph, - $formats, - $serverUri - ); - - $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); - - $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); - - $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); - $filesystem->addPlugin($plugin); - - return $filesystem; - } - - private function generateDefaultAcl($userId) { - $defaultAcl = <<< EOF -# Root ACL resource for the user account -@prefix acl: . -@prefix foaf: . - -# The owner has full access to every resource in their pod. -# Other agents have no access rights, -# unless specifically authorized in other .acl resources. -<#owner> - a acl:Authorization; - acl:agent <{user-profile-uri}>; - # Set the access to the root storage folder itself - acl:accessTo <./>; - # All resources will inherit this authorization, by default - acl:default <./>; - # The owner has all of the access modes allowed - acl:mode - acl:Read, acl:Write, acl:Control. -EOF; - - $profileUri = $this->getUserProfile($userId); - $defaultAcl = str_replace("{user-profile-uri}", $profileUri, $defaultAcl); - return $defaultAcl; - } - - private function getUserProfile($userId) { - return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me"; - } - private function getCalendarUrl($userId) { - $calendarUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.calendar.handleHead", array("userId" => $userId, "path" => "foo"))); - $calendarUrl = preg_replace('/foo$/', '', $calendarUrl); - return $calendarUrl; - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleRequest($userId, $path) { - $this->calendarUserId = $userId; - - $this->rawRequest = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); - $this->response = new \Laminas\Diactoros\Response(); - - $this->filesystem = $this->getFileSystem($userId); - - $this->resourceServer = new ResourceServer($this->filesystem, $this->response); - $this->WAC = new WAC($this->filesystem); - - $request = $this->rawRequest; - $baseUrl = $this->getCalendarUrl($userId); - $this->resourceServer->setBaseUrl($baseUrl); - $this->WAC->setBaseUrl($baseUrl); - $notifications = new SolidNotifications(); - $this->resourceServer->setNotifications($notifications); - - $dpop = $this->getDpop(); - - try { - $webId = $dpop->getWebId($request); - } catch(\Pdsinterop\Solid\Auth\Exception\Exception $e) { - $response = $this->resourceServer->getResponse() - ->withStatus(Http::STATUS_CONFLICT, "Invalid token " . $e->getMessage()); - return $this->respond($response); - } - - if (!$this->WAC->isAllowed($request, $webId)) { - $response = $this->resourceServer->getResponse()->withStatus(403, "Access denied"); - return $this->respond($response); - } - - $response = $this->resourceServer->respondToRequest($request); - $response = $this->WAC->addWACHeaders($request, $response, $webId); - return $this->respond($response); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleGet($userId, $path) { - return $this->handleRequest($userId, $path); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handlePost($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ -<<<<<<< HEAD - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - - // because we got here, the request uri should look like: - // - if we have user subdomains enabled: - // /index.php/apps/solid/calendar{path} - // and otherwise: - // index.php/apps/solid/~{userId}/calendar{path} - - // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; - if ($this->config->getUserSubDomainsEnabled()) { - $pathInfo = explode("calendar/", $_SERVER['REQUEST_URI']); - $path = $pathInfo[1]; - $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; - } else { - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^calendar/", "", $path); - } - - return $this->handleRequest($userId, $path); - } -======= - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - - // because we got here, the request uri should look like: - // /index.php/apps/solid/~{userId}/storage{path} - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^calendar/", "", $path); - - return $this->handleRequest($userId, $path); - } ->>>>>>> 3100599 (replace @ with ~ in urls) - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleDelete($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleHead($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handlePatch($userId, $path) { - return $this->handleRequest($userId, $path); - } - - private function respond($response) { - $statusCode = $response->getStatusCode(); - $response->getBody()->rewind(); - $headers = $response->getHeaders(); - - $body = $response->getBody()->getContents(); - if ($statusCode > 399) { - $reason = $response->getReasonPhrase(); - $result = new JSONResponse($reason, $statusCode); - return $result; - } - - $result = new PlainResponse($body); - - foreach ($headers as $header => $values) { - foreach ($values as $value) { - $result->addHeader($header, $value); - } - } - - $result->setStatus($statusCode); - return $result; - } -} diff --git a/solid/lib/Controller/ContactsController.php~ b/solid/lib/Controller/ContactsController.php~ deleted file mode 100644 index f346036e..00000000 --- a/solid/lib/Controller/ContactsController.php~ +++ /dev/null @@ -1,279 +0,0 @@ -config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); - $this->request = $request; - $this->urlGenerator = $urlGenerator; - $this->session = $session; - - $this->setJtiStorage($connection); - } - - private function getFileSystem($userId) { - // Make sure the root folder has an acl file, as is required by the spec; - // Generate a default file granting the owner full access. - $defaultAcl = $this->generateDefaultAcl($userId); - - // Create the Nextcloud Contacts Adapter - $adapter = new \Pdsinterop\Flysystem\Adapter\NextcloudContacts($userId, $defaultAcl); - - $graph = new \EasyRdf\Graph(); - - // Create Formats objects - $formats = new \Pdsinterop\Rdf\Formats(); - - $serverParams = $this->rawRequest->getServerParams(); - $scheme = $serverParams['REQUEST_SCHEME']; - $domain = $serverParams['SERVER_NAME']; - $path = $serverParams['REQUEST_URI']; - $serverUri = "{$scheme}://{$domain}{$path}"; // FIXME: doublecheck that this is the correct url; - - // Create the RDF Adapter - $rdfAdapter = new \Pdsinterop\Rdf\Flysystem\Adapter\Rdf( - $adapter, - $graph, - $formats, - $serverUri - ); - - $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); - - $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); - - $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); - $filesystem->addPlugin($plugin); - - return $filesystem; - } - - private function generateDefaultAcl($userId) { - $defaultAcl = <<< EOF -# Root ACL resource for the user account -@prefix acl: . -@prefix foaf: . - -# The owner has full access to every resource in their pod. -# Other agents have no access rights, -# unless specifically authorized in other .acl resources. -<#owner> - a acl:Authorization; - acl:agent <{user-profile-uri}>; - # Set the access to the root storage folder itself - acl:accessTo <./>; - # All resources will inherit this authorization, by default - acl:default <./>; - # The owner has all of the access modes allowed - acl:mode - acl:Read, acl:Write, acl:Control. -EOF; - - $profileUri = $this->getUserProfile($userId); - $defaultAcl = str_replace("{user-profile-uri}", $profileUri, $defaultAcl); - return $defaultAcl; - } - - private function getUserProfile($userId) { - return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me"; - } - private function getContactsUrl($userId) { - $contactsUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.contacts.handleHead", array("userId" => $userId, "path" => "foo"))); - $contactsUrl = preg_replace('/foo$/', '', $contactsUrl); - return $contactsUrl; - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleRequest($userId, $path) { - $this->contactsUserId = $userId; - - $this->rawRequest = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); - $this->response = new \Laminas\Diactoros\Response(); - - $this->filesystem = $this->getFileSystem($userId); - - $this->resourceServer = new ResourceServer($this->filesystem, $this->response); - $this->WAC = new WAC($this->filesystem); - - $request = $this->rawRequest; - $baseUrl = $this->getContactsUrl($userId); - $this->resourceServer->setBaseUrl($baseUrl); - $this->WAC->setBaseUrl($baseUrl); - $notifications = new SolidNotifications(); - $this->resourceServer->setNotifications($notifications); - - $dpop = $this->getDpop(); - - try { - $webId = $dpop->getWebId($request); - } catch(\Pdsinterop\Solid\Auth\Exception\Exception $e) { - $response = $this->resourceServer->getResponse() - ->withStatus(Http::STATUS_CONFLICT, "Invalid token " . $e->getMessage()); - return $this->respond($response); - } - - if (!$this->WAC->isAllowed($request, $webId)) { - $response = $this->resourceServer->getResponse()->withStatus(403, "Access denied"); - return $this->respond($response); - } - - $response = $this->resourceServer->respondToRequest($request); - $response = $this->WAC->addWACHeaders($request, $response, $webId); - return $this->respond($response); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleGet($userId, $path) { - return $this->handleRequest($userId, $path); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handlePost($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ -<<<<<<< HEAD - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - - // because we got here, the request uri should look like: - // - if we have user subdomains enabled: - // /index.php/apps/solid/contacts{path} - // and otherwise: - // index.php/apps/solid/~{userId}/contacts{path} - - // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; - if ($this->config->getUserSubDomainsEnabled()) { - $pathInfo = explode("contacts/", $_SERVER['REQUEST_URI']); - $path = $pathInfo[1]; - $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; - } else { - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^contacts/", "", $path); - } - - return $this->handleRequest($userId, $path); - } -======= - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - - // because we got here, the request uri should look like: - // /index.php/apps/solid/~{userId}/storage{path} - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^contacts/", "", $path); - - return $this->handleRequest($userId, $path); - } ->>>>>>> 3100599 (replace @ with ~ in urls) - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleDelete($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleHead($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handlePatch($userId, $path) { - return $this->handleRequest($userId, $path); - } - - private function respond($response) { - $statusCode = $response->getStatusCode(); - $response->getBody()->rewind(); - $headers = $response->getHeaders(); - - $body = $response->getBody()->getContents(); - if ($statusCode > 399) { - $reason = $response->getReasonPhrase(); - $result = new JSONResponse($reason, $statusCode); - return $result; - } - - $result = new PlainResponse($body); - - foreach ($headers as $header => $values) { - foreach ($values as $value) { - $result->addHeader($header, $value); - } - } - - $result->setStatus($statusCode); - return $result; - } -} diff --git a/solid/lib/Controller/GetStorageUrlTrait.php~ b/solid/lib/Controller/GetStorageUrlTrait.php~ deleted file mode 100644 index 97a312b7..00000000 --- a/solid/lib/Controller/GetStorageUrlTrait.php~ +++ /dev/null @@ -1,91 +0,0 @@ -config = $config; - } - - final public function setUrlGenerator(IURLGenerator $urlGenerator): void - { - $this->urlGenerator = $urlGenerator; - } - - ////////////////////////////// CLASS PROPERTIES \\\\\\\\\\\\\\\\\\\\\\\\\\\\ - - protected ServerConfig $config; - protected IURLGenerator $urlGenerator; - - /////////////////////////////// PROTECTED API \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ - - /** - * @FIXME: Add check for bob.nextcloud.local/solid/alice to throw 404 - * @TODO: Use route without `@alice` in /apps/solid/@alice/profile/card#me when user-domains are enabled - */ - public function getStorageUrl($userId) { - $routeUrl = $this->urlGenerator->linkToRoute( - 'solid.storage.handleHead', - ['userId' => $userId, 'path' => 'foo'] - ); - - $storageUrl = $this->urlGenerator->getAbsoluteURL($routeUrl); - - // (?) $storageUrl = preg_replace('/foo$/', '', $storageUrl); - $storageUrl = preg_replace('/foo$/', '/', $storageUrl); - - if ($this->config->getUserSubDomainsEnabled()) { - $url = parse_url($storageUrl); - - if (strpos($url['host'], $userId . '.') !== false) { - $url['host'] = str_replace($userId . '.', '', $url['host']); - } - - $url['host'] = $userId . '.' . $url['host']; // $storageUrl = $userId . '.' . $storageUrl; - $storageUrl = $this->build_url($url); - } - - return $storageUrl; - } - - public function validateUrl(RequestInterface $request): bool { - $isValid = false; - - $host = $request->getUri()->getHost(); - $path = $request->getUri()->getPath(); - $pathParts = explode('/', $path); - - $pathUsers = array_filter($pathParts, static function ($value) { - return str_starts_with($value, '@'); - }); - - if (count($pathUsers) === 1) { - $pathUser = reset($pathUsers); - $subDomainUser = explode('.', $host)[0]; - - $isValid = $pathUser === '@' . $subDomainUser; - } - - return $isValid; - } - - ////////////////////////////// UTILITY METHODS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ - - private function build_url(array $parts) { - // @FIXME: Replace with existing more robust URL builder - return (isset($parts['scheme']) ? "{$parts['scheme']}:" : '') . - (isset($parts['host']) ? "//{$parts['host']}" : '') . - (isset($parts['port']) ? ":{$parts['port']}" : '') . - (isset($parts['path']) ? "{$parts['path']}" : '') . - (isset($parts['query']) ? "?{$parts['query']}" : '') . - (isset($parts['fragment']) ? "#{$parts['fragment']}" : ''); - } -} diff --git a/solid/lib/Controller/PageController.php~ b/solid/lib/Controller/PageController.php~ deleted file mode 100644 index 3109ad8e..00000000 --- a/solid/lib/Controller/PageController.php~ +++ /dev/null @@ -1,152 +0,0 @@ -userId = $userId; - $this->userManager = $userManager; - $this->request = $request; - $this->urlGenerator = $urlGenerator; - $this->config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); - } - - /** - * CAUTION: the @Stuff turns off security checks; for this page no admin is - * required and no CSRF check. If you don't know what CSRF is, read - * it up in the docs or you might create a security hole. This is - * basically the only required method to add this exemption, don't - * add it to any other method if you don't exactly know what it does - * - * @NoAdminRequired - * @NoCSRFRequired - */ - public function index() { - return new TemplateResponse('solid', 'index'); // templates/index.php - } - - private function getUserProfile($userId) { - if ($this->userManager->userExists($userId)) { - $user = $this->userManager->get($userId); - if ($user !== null) { - $profile = array( - 'id' => $userId, - 'displayName' => $user->getDisplayName(), - 'profileUri' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me", - 'solidNavigation' => array( - "profile" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.page.profile", array("userId" => $userId))), - "launcher" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.app.appLauncher", array())), - ) - ); - return $profile; - } - } - return false; - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function profile($userId) { - // header("Access-Control-Allow-Headers: *, authorization, accept, content-type"); - // header("Access-Control-Allow-Credentials: true"); - $profile = $this->getUserProfile($userId); - if (!$profile) { - return new JSONResponse(array(), Http::STATUS_NOT_FOUND); - } - $templateResponse = new TemplateResponse('solid', 'profile', $profile); - $policy = new ContentSecurityPolicy(); - $policy->addAllowedStyleDomain("data:"); - $templateResponse->setContentSecurityPolicy($policy); - return $templateResponse; - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function approval($clientId) { - $clientRegistration = $this->config->getClientRegistration($clientId); - $params = array( - "clientId" => $clientId, - "clientName" => $clientRegistration['client_name'], - "serverName" => "Nextcloud", - "returnUrl" => $_GET['returnUrl'], - ); - $templateResponse = new TemplateResponse('solid', 'sharing', $params); - - $policy = new ContentSecurityPolicy(); - $policy->addAllowedStyleDomain("data:"); - - $parsedOrigin = parse_url($clientRegistration['redirect_uris'][0]); - $origin = $parsedOrigin['host']; - if ($origin) { - $policy->addAllowedFormActionDomain($parsedOrigin['scheme'] . "://" . $origin); - $templateResponse->setContentSecurityPolicy($policy); - } - return $templateResponse; - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function customscheme() { - $templateResponse = new TemplateResponse('solid', 'customscheme'); - return $templateResponse; - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleApproval($clientId) { - $approval = $_POST['approval']; - if ($approval == "allow") { - $this->config->addAllowedClient($this->userId, $clientId); - } else { - $this->config->removeAllowedClient($this->userId, $clientId); - } - $authUrl = $_POST['returnUrl']; - - $result = new JSONResponse("ok"); - - $result->setStatus("302"); - $result->addHeader("Location", $authUrl); - return $result; - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleRevoke($clientId) { - $this->config->removeAllowedClient($this->userId, $clientId); - $result = new JSONResponse("ok"); - return $result; - } -} diff --git a/solid/lib/Controller/ProfileController.php~ b/solid/lib/Controller/ProfileController.php~ deleted file mode 100644 index 70ba5e12..00000000 --- a/solid/lib/Controller/ProfileController.php~ +++ /dev/null @@ -1,378 +0,0 @@ -config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); - $this->request = $request; - $this->urlGenerator = $urlGenerator; - $this->userManager = $userManager; - $this->contactsManager = $contactsManager; - $this->session = $session; - - $this->setJtiStorage($connection); - } - - private function getFileSystem($userId) { - // Make sure the root folder has an acl file, as is required by the spec; - // Generate a default file granting the owner full access. - $defaultAcl = $this->generateDefaultAcl($userId); - $profile = $this->generateTurtleProfile($userId); - - // Create the Nextcloud Calendar Adapter - $adapter = new \Pdsinterop\Flysystem\Adapter\NextcloudProfile($userId, $profile, $defaultAcl, $this->config); - - $graph = new \EasyRdf\Graph(); - // Create Formats objects - $formats = new \Pdsinterop\Rdf\Formats(); - - $serverParams = $this->rawRequest->getServerParams(); - $scheme = $serverParams['REQUEST_SCHEME']; - $domain = $serverParams['SERVER_NAME']; - $path = $serverParams['REQUEST_URI']; - $serverUri = "{$scheme}://{$domain}{$path}"; // FIXME: doublecheck that this is the correct url; - - // Create the RDF Adapter - $rdfAdapter = new \Pdsinterop\Rdf\Flysystem\Adapter\Rdf( - $adapter, - $graph, - $formats, - $serverUri - ); - - $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); - - $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); - - $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); - $filesystem->addPlugin($plugin); - - return $filesystem; - } - - private function generateDefaultAcl($userId) { - $defaultAcl = <<< EOF -# Root ACL resource for the user account -@prefix acl: . -@prefix foaf: . - -# The profile is readable by the public -<#public> - a acl:Authorization; - acl:agentClass foaf:Agent; - acl:accessTo <./>; - acl:default <./>; - acl:mode acl:Read. - -# The owner has full access to every resource in their pod. -# Other agents have no access rights, -# unless specifically authorized in other .acl resources. -<#owner> - a acl:Authorization; - acl:agent <{user-profile-uri}>; - # Set the access to the root storage folder itself - acl:accessTo <./>; - # All resources will inherit this authorization, by default - acl:default <./>; - # The owner has all of the access modes allowed - acl:mode - acl:Read, acl:Write, acl:Control. -EOF; - - $profileUri = $this->getUserProfileUri($userId); - $defaultAcl = str_replace("{user-profile-uri}", $profileUri, $defaultAcl); - return $defaultAcl; - } - - private function getUserProfileUri($userId) { - return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me"; - } - private function getProfileUrl($userId) { - $profileUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleHead", array("userId" => $userId, "path" => "foo"))); - $profileUrl = preg_replace('/foo$/', '', $profileUrl); - return $profileUrl; - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleRequest($userId, $path) { - $this->userId = $userId; - - $this->rawRequest = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); - $this->response = new \Laminas\Diactoros\Response(); - - $this->filesystem = $this->getFileSystem($userId); - - $this->resourceServer = new ResourceServer($this->filesystem, $this->response); - $this->WAC = new WAC($this->filesystem); - - $request = $this->rawRequest; - $baseUrl = $this->getProfileUrl($userId); - $this->resourceServer->setBaseUrl($baseUrl); - $this->WAC->setBaseUrl($baseUrl); - $notifications = new SolidNotifications(); - $this->resourceServer->setNotifications($notifications); - - $dpop = $this->getDpop(); - - if ($request->getHeaderLine("DPop")) { - try { - $webId = $dpop->getWebId($request); - } catch(\Pdsinterop\Solid\Auth\Exception\Exception $e) { - $response = $this->resourceServer->getResponse() - ->withStatus(Http::STATUS_CONFLICT, "Invalid token " . $e->getMessage()); - return $this->respond($response); - } - } else { - $webId = ""; - } - - if (!$this->WAC->isAllowed($request, $webId)) { - $response = $this->resourceServer->getResponse()->withStatus(403, "Access denied"); - return $this->respond($response); - } - - $response = $this->resourceServer->respondToRequest($request); - $response = $this->WAC->addWACHeaders($request, $response, $webId); - return $this->respond($response); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleGet($userId, $path) { - //TODO: check that the $userId matches the userDomain, if enabled. - return $this->handleRequest($userId, $path); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handlePost($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - -<<<<<<< HEAD - // because we got here, the request uri should look like: - // - if we have user subdomains enabled: - // /index.php/apps/solid/profile{path} - // and otherwise: - // index.php/apps/solid/~{userId}/profile{path} -======= - // because we got here, the request uri should look like: - // /index.php/apps/solid/~{userId}/storage{path} - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^profile/", "", $path); ->>>>>>> 3100599 (replace @ with ~ in urls) - - // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; - if ($this->config->getUserSubDomainsEnabled()) { - $pathInfo = explode("profile/", $_SERVER['REQUEST_URI']); - $path = $pathInfo[1]; - $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; - } else { - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^profile/", "", $path); - } - - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleDelete($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleHead($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handlePatch($userId, $path) { - return $this->handleRequest($userId, $path); - } - - private function respond($response) { - $statusCode = $response->getStatusCode(); - $response->getBody()->rewind(); - $headers = $response->getHeaders(); - - $body = $response->getBody()->getContents(); - if ($statusCode > 399) { - $reason = $response->getReasonPhrase(); - $result = new JSONResponse($reason, $statusCode); - return $result; - } - - $result = new PlainResponse($body); - - foreach ($headers as $header => $values) { - foreach ($values as $value) { - $result->addHeader($header, $value); - } - } -// $origin = $_SERVER['HTTP_ORIGIN'] ?? "*"; -// $result->addHeader('Access-Control-Allow-Credentials', 'true'); -// $result->addHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); -// $result->addHeader('Access-Control-Allow-Origin', $origin); - $result->setStatus($statusCode); - return $result; - } - - private function getUserProfile($userId) { - if ($this->userManager->userExists($userId)) { - $user = $this->userManager->get($userId); - $addressBooks = $this->contactsManager->getUserAddressBooks(); - $friends = []; - foreach($addressBooks as $k => $v) { - $results = $addressBooks[$k]->search('', ['FN'], ['types' => true]); - foreach($results as $found) { - if (isset($found['URL']) && is_array($found['URL'])) { - foreach($found['URL'] as $i => $obj) { - array_push($friends, $obj['value']); - } - } - } - } - //TODO: privateTypeIndex and publisTypeIndex need to user getStorageURL - if ($user !== null) { - $profile = array( - 'id' => $userId, - 'displayName' => $user->getDisplayName(), - 'profileUri' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me", - 'friends' => $friends, - 'inbox' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/inbox/"))), - 'preferences' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/settings/preferences.ttl"))), - 'privateTypeIndex' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/settings/privateTypeIndex.ttl"))), - 'publicTypeIndex' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/settings/publicTypeIndex.ttl"))), - 'storage' => $this->getStorageUrl($userId), - 'issuer' => $this->urlGenerator->getBaseURL() - ); - return $profile; - } - } - return false; - } - - private function generateTurtleProfile($userId) { - $profile = $this->getUserProfile($userId); - if (!$profile) { - return ""; - } - ob_start(); - ?>@prefix : <#>. - @prefix solid: . - @prefix pro: <./>. - @prefix foaf: . - @prefix schem: . - @prefix acl: . - @prefix ldp: . - @prefix inbox: <>. - @prefix sp: . - @prefix ser: <>. - - pro:card a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. - - :me - a schem:Person, foaf:Person; - ldp:inbox inbox:; - sp:preferencesFile <>; - sp:storage ser:; - solid:account ser:; - solid:privateTypeIndex <>; - solid:publicTypeIndex <>; - solid:oidcIssuer <>; - $friend) { - ?> - foaf:knows <>; - - foaf:name ""; - "". - urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))); - $baseProfile = $this->config->getProfileData($userId); - $graph = new \EasyRdf\Graph(); - $graph->parse($baseProfile, "turtle", $baseUrl); - $graph->parse($generatedProfile, "turtle", $baseUrl); - $combinedProfile = $graph->serialise("turtle"); - return $combinedProfile; - } -} diff --git a/solid/lib/Controller/ServerController.php~ b/solid/lib/Controller/ServerController.php~ deleted file mode 100644 index 9c9044ee..00000000 --- a/solid/lib/Controller/ServerController.php~ +++ /dev/null @@ -1,449 +0,0 @@ -config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); - $this->userId = $userId; - $this->userManager = $userManager; - $this->request = $request; - $this->urlGenerator = $urlGenerator; - $this->session = $session; - - $this->setJtiStorage($connection); - - $this->authServerConfig = $this->createAuthServerConfig(); - $this->authServerFactory = (new \Pdsinterop\Solid\Auth\Factory\AuthorizationServerFactory($this->authServerConfig))->create(); - - $this->tokenGenerator = new \Pdsinterop\Solid\Auth\TokenGenerator( - $this->authServerConfig, - $this->getDpopValidFor(), - $this->getDpop() - ); - } - - private function getOpenIdEndpoints() { - return [ - 'issuer' => $this->urlGenerator->getBaseURL(), - 'authorization_endpoint' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.authorize")), - 'jwks_uri' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.jwks")), - "check_session_iframe" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.session")), - "end_session_endpoint" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.logout")), - "token_endpoint" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.token")), - "userinfo_endpoint" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.userinfo")), - "registration_endpoint" => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.register")) - ]; - } - - private function getKeys() { - $encryptionKey = $this->config->getEncryptionKey(); - $privateKey = $this->config->getPrivateKey(); - $key = openssl_pkey_get_private($privateKey); - $publicKey = openssl_pkey_get_details($key)['key']; - return [ - "encryptionKey" => $encryptionKey, - "privateKey" => $privateKey, - "publicKey" => $publicKey - ]; - } - - private function createAuthServerConfig() { - $clientId = isset($_GET['client_id']) ? $_GET['client_id'] : null; - $client = $this->getClient($clientId); - $keys = $this->getKeys(); - try { - return (new \Pdsinterop\Solid\Auth\Factory\ConfigFactory( - $client, - $keys['encryptionKey'], - $keys['privateKey'], - $keys['publicKey'], - $this->getOpenIdEndpoints() - ))->create(); - } catch(\Throwable $e) { - // var_dump($e); - return null; - } - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function cors($path) { - $origin = $_SERVER['HTTP_ORIGIN']; - return (new DataResponse('OK')); -// ->addHeader('Access-Control-Allow-Origin', $origin) -// ->addHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') -// ->addHeader('Access-Control-Allow-Methods', 'POST') -// ->addHeader('Access-Control-Allow-Credentials', 'true'); - } - - /** - * @NoAdminRequired - * @NoCSRFRequired - */ - public function authorize() { - // Create a request - if (!$this->userManager->userExists($this->userId)) { - $result = new JSONResponse('Authorization required'); - $result->setStatus(401); - return $result; -// return $result->addHeader('Access-Control-Allow-Origin', '*'); - } - - if (isset($_GET['request'])) { - $jwtConfig = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($this->config->getPrivateKey())); - try { - $token = $jwtConfig->parser()->parse($_GET['request']); - $this->session->set("nonce", $token->claims()->get('nonce')); - } catch(\Exception $e) { - $this->session->set("nonce", $_GET['nonce']); - } - } - - $getVars = $_GET; - if (!isset($getVars['grant_type'])) { - $getVars['grant_type'] = 'implicit'; - } - $getVars['response_type'] = $this->getResponseType(); - $getVars['scope'] = "openid" ; - - if (!isset($getVars['redirect_uri'])) { - if (!isset($token)) { - $result = new JSONResponse('Bad request, does not contain valid token'); - $result->setStatus(400); - return $result; -// return $result->addHeader('Access-Control-Allow-Origin', '*'); - } - try { - $getVars['redirect_uri'] = $token->claims()->get("redirect_uri"); - } catch(\Exception $e) { - $result = new JSONResponse('Bad request, missing redirect uri'); - $result->setStatus(400); - return $result; -// return $result->addHeader('Access-Control-Allow-Origin', '*'); - } - } - - if (preg_match("/^http(s)?:/", $getVars['client_id'])) { - $parsedOrigin = parse_url($getVars['redirect_uri']); - $origin = $parsedOrigin['scheme'] . '://' . $parsedOrigin['host']; - if (isset($parsedOrigin['port'])) { - $origin .= ":" . $parsedOrigin['port']; - } - $clientData = array( - "client_id_issued_at" => time(), - "client_name" => $getVars['client_id'], - "origin" => $origin, - "redirect_uris" => array( - $getVars['redirect_uri'] - ) - ); - $clientId = $this->config->saveClientRegistration($origin, $clientData)['client_id']; - $clientId = $this->config->saveClientRegistration($getVars['client_id'], $clientData)['client_id']; - $returnUrl = $getVars['redirect_uri']; - } else { - $clientId = $getVars['client_id']; - $returnUrl = $_SERVER['REQUEST_URI']; - } - - $clientRegistration = $this->config->getClientRegistration($clientId); - if (isset($clientRegistration['blocked']) && ($clientRegistration['blocked'] === true)) { - $result = new JSONResponse('Unauthorized client'); - $result->setStatus(403); - return $result; - } - - $approval = $this->checkApproval($clientId); - if (!$approval) { - $result = new JSONResponse('Approval required'); - $result->setStatus(302); - $approvalUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.page.approval", array("clientId" => $clientId, "returnUrl" => $returnUrl))); - $result->addHeader("Location", $approvalUrl); - return $result; // ->addHeader('Access-Control-Allow-Origin', '*'); - } - - $parsedOrigin = parse_url($clientRegistration['redirect_uris'][0]); - if ($parsedOrigin['scheme'] != "https" && !isset($_GET['customscheme'])) { - $result = new JSONResponse('Custom schema'); - $result->setStatus(302); - $originalRequest = parse_url($_SERVER['REQUEST_URI']); - $customSchemeUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.page.customscheme")) . ($originalRequest['query'] ? "?" . $originalRequest['query'] . "&customscheme=" . $parsedOrigin['scheme'] : ''); - $result->addHeader("Location", $customSchemeUrl); - return $result; - } - - $user = new \Pdsinterop\Solid\Auth\Entity\User(); - $user->setIdentifier($this->getProfilePage()); - - $request = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $getVars, $_POST, $_COOKIE, $_FILES); - $response = new \Laminas\Diactoros\Response(); - $server = new \Pdsinterop\Solid\Auth\Server($this->authServerFactory, $this->authServerConfig, $response); - - $response = $server->respondToAuthorizationRequest($request, $user, $approval); - $response = $this->tokenGenerator->addIdTokenToResponse( - $response, - $clientId, - $this->getProfilePage(), - $this->session->get("nonce"), - $this->config->getPrivateKey() - ); - - return $this->respond($response); // ->addHeader('Access-Control-Allow-Origin', '*'); - } - - private function checkApproval($clientId) { - $allowedClients = $this->config->getAllowedClients($this->userId); - if ($clientId == md5("tester")) { // FIXME: Double check that this is not a security issue; It is only here to help the test suite; - return \Pdsinterop\Solid\Auth\Enum\Authorization::APPROVED; - } - if ($clientId == md5("https://tester")) { // FIXME: Double check that this is not a security issue; It is only here to help the test suite; - return \Pdsinterop\Solid\Auth\Enum\Authorization::APPROVED; - } - if (in_array($clientId, $allowedClients)) { - return \Pdsinterop\Solid\Auth\Enum\Authorization::APPROVED; - } else { - return \Pdsinterop\Solid\Auth\Enum\Authorization::DENIED; - } - } - - private function getProfilePage() { - return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $this->userId, "path" => "/card"))) . "#me"; - } - - private function getResponseType() { - $responseTypes = explode(" ", $_GET['response_type']); - foreach ($responseTypes as $responseType) { - switch ($responseType) { - case "token": - return "token"; - break; - case "code": - return "code"; - break; - } - } - return "token"; // default to token response type; - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function session() { - return new JSONResponse("ok"); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function token() { - $request = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); - $code = $request->getParsedBody()['code']; - $clientId = $request->getParsedBody()['client_id']; - - $httpDpop = $request->getServerParams()['HTTP_DPOP']; - - $response = new \Laminas\Diactoros\Response(); - $server = new \Pdsinterop\Solid\Auth\Server($this->authServerFactory, $this->authServerConfig, $response); - $response = $server->respondToAccessTokenRequest($request); - - // FIXME: not sure if decoding this here is the way to go. - // FIXME: because this is a public page, the nonce from the session is not available here. - $codeInfo = $this->tokenGenerator->getCodeInfo($code); - $response = $this->tokenGenerator->addIdTokenToResponse( - $response, - $clientId, - $codeInfo['user_id'], - ($_SESSION['nonce'] ?? ''), - $this->config->getPrivateKey(), - $httpDpop - ); - - return $this->respond($response); // ->addHeader('Access-Control-Allow-Origin', '*'); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function userinfo() { - return new JSONResponse("ok"); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function logout() { - $this->userService->logout(); - return new JSONResponse("ok"); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function register() { - $clientData = file_get_contents('php://input'); - $clientData = json_decode($clientData, true); - if (!$clientData['redirect_uris']) { - return new JSONResponse("Missing redirect URIs"); - } - $clientData['client_id_issued_at'] = time(); - $parsedOrigin = parse_url($clientData['redirect_uris'][0]); - $origin = $parsedOrigin['scheme'] . '://' . $parsedOrigin['host']; - if (isset($parsedOrigin['port'])) { - $origin .= ":" . $parsedOrigin['port']; - } - - $clientData = $this->config->saveClientRegistration($origin, $clientData); - $registration = array( - 'client_id' => $clientData['client_id'], - /* - FIXME: returning client_secret will trigger calls with basic auth to us. To get this to work, we need this patch: - // File /var/www/vhosts/solid-nextcloud/site/www/lib/base.php not changed so no update needed - // ($request->getRawPathInfo() !== '/apps/oauth2/api/v1/token') && - // ($request->getRawPathInfo() !== '/apps/solid/token') - */ - // 'client_secret' => $clientData['client_secret'], // FIXME: Returning this means we need to patch Nextcloud to accept tokens on calls to - - 'registration_client_uri' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.server.registeredClient", array("clientId" => $clientData['client_id']))), - 'client_id_issued_at' => $clientData['client_id_issued_at'], - 'redirect_uris' => $clientData['redirect_uris'], - ); - $registration = $this->tokenGenerator->respondToRegistration($registration, $this->config->getPrivateKey()); - return (new JSONResponse($registration)); -// ->addHeader('Access-Control-Allow-Origin', $origin) -// ->addHeader('Access-Control-Allow-Methods', 'POST'); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function registeredClient($clientId) { - $clientRegistration = $this->config->getClientRegistration($clientId); - unset($clientRegistration['client_secret']); - return new JSONResponse($clientRegistration); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function jwks() { - $response = new \Laminas\Diactoros\Response(); - $server = new \Pdsinterop\Solid\Auth\Server($this->authServerFactory, $this->authServerConfig, $response); - $response = $server->respondToJwksMetadataRequest(); - return $this->respond($response); - } - - private function respond($response) { - $statusCode = $response->getStatusCode(); - $response->getBody()->rewind(); - $headers = $response->getHeaders(); - - $body = json_decode($response->getBody()->getContents()); - if ($statusCode > 399) { - // var_dump($body); - $reason = $response->getReasonPhrase(); - $result = new JSONResponse($reason, $statusCode); - return $result; - } - - if ($body == null) { - $body = 'ok'; - } - $result = new JSONResponse($body); - - foreach ($headers as $header => $values) { - foreach ($values as $value) { - $result->addHeader($header, $value); - } - } - $result->setStatus($statusCode); -// $result->addHeader('Access-Control-Allow-Origin', '*'); - return $result; - } - - private function getClient($clientId) { - $clientRegistration = $this->config->getClientRegistration($clientId); - - if ($clientId && count($clientRegistration)) { - return new \Pdsinterop\Solid\Auth\Config\Client( - $clientId, - $clientRegistration['client_secret'] ?? '', - $clientRegistration['redirect_uris'], - $clientRegistration['client_name'] - ); - } else { - return new \Pdsinterop\Solid\Auth\Config\Client('','',array(),''); - } - } -} diff --git a/solid/lib/Controller/StorageController.php~ b/solid/lib/Controller/StorageController.php~ deleted file mode 100644 index 07541d13..00000000 --- a/solid/lib/Controller/StorageController.php~ +++ /dev/null @@ -1,471 +0,0 @@ -config = new \OCA\Solid\ServerConfig($config, $urlGenerator, $userManager); - $this->rootFolder = $rootFolder; - $this->request = $request; - $this->urlGenerator = $urlGenerator; - $this->session = $session; - - $this->setJtiStorage($connection); - } - - private function getFileSystem() { - // Create the Nextcloud Adapter - $adapter = new \Pdsinterop\Flysystem\Adapter\Nextcloud($this->solidFolder); - $graph = new \EasyRdf\Graph(); - - // Create Formats objects - $formats = new \Pdsinterop\Rdf\Formats(); - - $serverParams = $this->rawRequest->getServerParams(); - $scheme = $serverParams['REQUEST_SCHEME']; - $domain = $serverParams['SERVER_NAME']; - $path = $serverParams['REQUEST_URI']; - $serverUri = "{$scheme}://{$domain}{$path}"; // FIXME: doublecheck that this is the correct url; - - // Create the RDF Adapter - $rdfAdapter = new \Pdsinterop\Rdf\Flysystem\Adapter\Rdf( - $adapter, - $graph, - $formats, - $serverUri - ); - - $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); - - $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); - - $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); - $filesystem->addPlugin($plugin); - - return $filesystem; - } - - private function getUserProfile($userId) { - return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.profile.handleGet", array("userId" => $userId, "path" => "/card"))) . "#me"; - } - - private function generateDefaultAcl($userId) { - $defaultAcl = <<< EOF -# Root ACL resource for the user account -@prefix acl: . -@prefix foaf: . - -# The homepage is readable by the public -<#public> - a acl:Authorization; - acl:agentClass foaf:Agent; - acl:accessTo <./>; - acl:mode acl:Read. - -# The owner has full access to every resource in their pod. -# Other agents have no access rights, -# unless specifically authorized in other .acl resources. -<#owner> - a acl:Authorization; - acl:agent <{user-profile-uri}>; - # Set the access to the root storage folder itself - acl:accessTo <./>; - # All resources will inherit this authorization, by default - acl:default <./>; - # The owner has all of the access modes allowed - acl:mode - acl:Read, acl:Write, acl:Control. -EOF; - - $profileUri = $this->getUserProfile($userId); - $defaultAcl = str_replace("{user-profile-uri}", $profileUri, $defaultAcl); - return $defaultAcl; - } - - private function generatePublicAppendAcl($userId) { - $publicAppendAcl = <<< EOF -# Inbox ACL resource for the user account -@prefix acl: . -@prefix foaf: . - -<#public> - a acl:Authorization; - acl:agentClass foaf:Agent; - acl:accessTo <./>; - acl:default <./>; - acl:mode - acl:Append. - -<#owner> - a acl:Authorization; - acl:agent <{user-profile-uri}>; - # Set the access to the root storage folder itself - acl:accessTo <./>; - # All resources will inherit this authorization, by default - acl:default <./>; - # The owner has all of the access modes allowed - acl:mode - acl:Read, acl:Write, acl:Control. -EOF; - - $profileUri = $this->getUserProfile($userId); - $publicAppendAcl = str_replace("{user-profile-uri}", $profileUri, $publicAppendAcl); - return $publicAppendAcl; - } - - private function generatePublicReadAcl($userId) { - $publicReadAcl = <<< EOF -# Inbox ACL resource for the user account -@prefix acl: . -@prefix foaf: . - -<#public> - a acl:Authorization; - acl:agentClass foaf:Agent; - acl:accessTo <./>; - acl:default <./>; - acl:mode - acl:Read. - -<#owner> - a acl:Authorization; - acl:agent <{user-profile-uri}>; - # Set the access to the root storage folder itself - acl:accessTo <./>; - # All resources will inherit this authorization, by default - acl:default <./>; - # The owner has all of the access modes allowed - acl:mode - acl:Read, acl:Write, acl:Control. -EOF; - - $profileUri = $this->getUserProfile($userId); - $publicReadAcl = str_replace("{user-profile-uri}", $profileUri, $publicReadAcl); - return $publicReadAcl; - } - - private function generateDefaultPublicTypeIndex() { - $publicTypeIndex = <<< EOF -# Public type index -@prefix : <#>. -@prefix solid: . - -<> - a solid:ListedDocument, solid:TypeIndex. -EOF; - - return $publicTypeIndex; - } - - private function generateDefaultPrivateTypeIndex() { - $privateTypeIndex = <<< EOF -# Private type index -@prefix : <#>. -@prefix solid: . - -<> - a solid:UnlistedDocument, solid:TypeIndex. -EOF; - - return $privateTypeIndex; - } - private function generateDefaultPreferences($userId) { - $preferences = <<< EOF -# Preferences -@prefix : <#>. -@prefix sp: . -@prefix dct: . -@prefix profile: <{user-profile-uri}>. -@prefix solid: . - -<> - a sp:ConfigurationFile; - dct:title "Preferences file". - -profile:me - a solid:Developer; - solid:privateTypeIndex ; - solid:publicTypeIndex . -EOF; - - $profileUri = $this->getUserProfile($userId); - $preferences = str_replace("{user-profile-uri}", $profileUri, $preferences); - return $preferences; - } - private function initializeStorage($userId) { - $this->userFolder = $this->rootFolder->getUserFolder($userId); - if (!$this->userFolder->nodeExists("solid")) { - $this->userFolder->newFolder("solid"); // Create the Solid directory for storage if it doesn't exist. - } - $this->solidFolder = $this->userFolder->get("solid"); - - $this->filesystem = $this->getFileSystem(); - - // Make sure the root folder has an acl file, as is required by the spec; - // Generate a default file granting the owner full access if there is nothing there. - if (!$this->filesystem->has("/.acl")) { - $defaultAcl = $this->generateDefaultAcl($userId); - $this->filesystem->write("/.acl", $defaultAcl); - } - - // Generate default folders and ACLs: - if (!$this->filesystem->has("/inbox")) { - $this->filesystem->createDir("/inbox"); - } - if (!$this->filesystem->has("/inbox/.acl")) { - $inboxAcl = $this->generatePublicAppendAcl($userId); - $this->filesystem->write("/inbox/.acl", $inboxAcl); - } - if (!$this->filesystem->has("/settings")) { - $this->filesystem->createDir("/settings"); - } - if (!$this->filesystem->has("/settings/privateTypeIndex.ttl")) { - $privateTypeIndex = $this->generateDefaultPrivateTypeIndex(); - $this->filesystem->write("/settings/privateTypeIndex.ttl", $privateTypeIndex); - } - if (!$this->filesystem->has("/settings/publicTypeIndex.ttl")) { - $publicTypeIndex = $this->generateDefaultPublicTypeIndex(); - $this->filesystem->write("/settings/publicTypeIndex.ttl", $publicTypeIndex); - } - if (!$this->filesystem->has("/settings/preferences.ttl")) { - $preferences = $this->generateDefaultPreferences($userId); - $this->filesystem->write("/settings/preferences.ttl", $preferences); - } - if (!$this->filesystem->has("/public")) { - $this->filesystem->createDir("/public"); - } - if (!$this->filesystem->has("/public/.acl")) { - $publicAcl = $this->generatePublicReadAcl($userId); - $this->filesystem->write("/public/.acl", $publicAcl); - } - if (!$this->filesystem->has("/private")) { - $this->filesystem->createDir("/private"); - } - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleRequest($userId, $path) { - $this->rawRequest = \Laminas\Diactoros\ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); - $this->response = new \Laminas\Diactoros\Response(); - - $this->initializeStorage($userId); - - $this->resourceServer = new ResourceServer($this->filesystem, $this->response); - $this->WAC = new WAC($this->filesystem); - - $request = $this->rawRequest; - $baseUrl = $this->getStorageUrl($userId); - $this->resourceServer->setBaseUrl($baseUrl); - $this->WAC->setBaseUrl($baseUrl); - - $notifications = new SolidNotifications(); - $this->resourceServer->setNotifications($notifications); - - $dpop = $this->getDpop(); - - $error = false; - try { - $webId = $dpop->getWebId($request); - } catch(\Pdsinterop\Solid\Auth\Exception\Exception $e) { - $error = $e; - } - - if (!isset($webId)) { - $bearer = $this->getBearer(); - try { - $webId = $bearer->getWebId($request); - } catch(\Pdsinterop\Solid\Auth\Exception\Exception $e) { - $error = $e; - } - } - - if (!isset($webId)) { - $response = $this->resourceServer->getResponse() - ->withStatus(Http::STATUS_CONFLICT, "Invalid token"); - return $this->respond($response); - } - - $origin = $request->getHeaderLine("Origin"); - $allowedClients = $this->config->getAllowedClients($userId); - $allowedOrigins = array(); - foreach ($allowedClients as $clientId) { - $clientRegistration = $this->config->getClientRegistration($clientId); - if (isset($clientRegistration['client_name'])) { - $allowedOrigins[] = $clientRegistration['client_name']; - } - if (isset($clientRegistration['origin'])) { - $allowedOrigins[] = $clientRegistration['origin']; - } - } - if (!$this->WAC->isAllowed($request, $webId, $origin, $allowedOrigins)) { - $response = $this->resourceServer->getResponse() - ->withStatus(403, "Access denied"); - return $this->respond($response); - } - $response = $this->resourceServer->respondToRequest($request); - $response = $this->WAC->addWACHeaders($request, $response, $webId); - return $this->respond($response); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleGet($userId, $path) { - return $this->handleRequest($userId, $path); - } - - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handlePost($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handlePut() { // $userId, $path) { - // FIXME: Adding the correct variables in the function name will make nextcloud - // throw an error about accessing put twice, so we will find out the userId and path from $_SERVER instead; - -<<<<<<< HEAD - // because we got here, the request uri should look like: - // - if we have user subdomains enabled: - // /index.php/apps/solid/storage{path} - // and otherwise: - // index.php/apps/solid/~{userId}/storage{path} -======= - // because we got here, the request uri should look like: - // /index.php/apps/solid/~{userId}/storage{path} - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^storage/", "", $path); ->>>>>>> 3100599 (replace @ with ~ in urls) - - // In the first case, we'll get the username from the SERVER_NAME. In the latter, it will come from the URL; - if ($this->config->getUserSubDomainsEnabled()) { - $pathInfo = explode("storage/", $_SERVER['REQUEST_URI']); - $path = $pathInfo[1]; - $userId = explode(".", $_SERVER['SERVER_NAME'])[0]; - } else { - $pathInfo = explode("~", $_SERVER['REQUEST_URI']); - $pathInfo = explode("/", $pathInfo[1], 2); - $userId = $pathInfo[0]; - $path = $pathInfo[1]; - $path = preg_replace("/^storage/", "", $path); - } - - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleDelete($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handleHead($userId, $path) { - return $this->handleRequest($userId, $path); - } - /** - * @PublicPage - * @NoAdminRequired - * @NoCSRFRequired - */ - public function handlePatch($userId, $path) { - return $this->handleRequest($userId, $path); - } - - private function respond($response) { - $statusCode = $response->getStatusCode(); - $response->getBody()->rewind(); - $headers = $response->getHeaders(); - - $body = $response->getBody()->getContents(); - - $result = new PlainResponse($body); - - foreach ($headers as $header => $values) { - $result->addHeader($header, implode(", ", $values)); - } - -// $origin = $_SERVER['HTTP_ORIGIN']; -// $result->addHeader('Access-Control-Allow-Credentials', 'true'); -// $result->addHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); -// $result->addHeader('Access-Control-Allow-Origin', $origin); - - $policy = new EmptyContentSecurityPolicy(); - $policy->addAllowedStyleDomain("*"); - $policy->addAllowedStyleDomain("data:"); - $policy->addAllowedScriptDomain("*"); - $policy->addAllowedImageDomain("*"); - $policy->addAllowedFontDomain("*"); - $policy->addAllowedConnectDomain("*"); - $policy->allowInlineStyle(true); - // $policy->allowInlineScript(true); - removed, this function no longer exists in NC28 - $policy->allowEvalScript(true); - $result->setContentSecurityPolicy($policy); - - $result->setStatus($statusCode); - return $result; - } -} From a30527951c0a5e4b56bbb5014f32197bd71a45ea Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 26 May 2025 15:38:58 +0200 Subject: [PATCH 25/54] update function name to camelcase --- solid/lib/Controller/GetStorageUrlTrait.php | 4 ++-- solid/tests/Unit/Controller/GetStorageUrlTraitTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/solid/lib/Controller/GetStorageUrlTrait.php b/solid/lib/Controller/GetStorageUrlTrait.php index 97a312b7..a5ab4a82 100644 --- a/solid/lib/Controller/GetStorageUrlTrait.php +++ b/solid/lib/Controller/GetStorageUrlTrait.php @@ -50,7 +50,7 @@ public function getStorageUrl($userId) { } $url['host'] = $userId . '.' . $url['host']; // $storageUrl = $userId . '.' . $storageUrl; - $storageUrl = $this->build_url($url); + $storageUrl = $this->buildUrl($url); } return $storageUrl; @@ -79,7 +79,7 @@ public function validateUrl(RequestInterface $request): bool { ////////////////////////////// UTILITY METHODS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ - private function build_url(array $parts) { + private function buildUrl(array $parts) { // @FIXME: Replace with existing more robust URL builder return (isset($parts['scheme']) ? "{$parts['scheme']}:" : '') . (isset($parts['host']) ? "//{$parts['host']}" : '') . diff --git a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php index 32d70052..5790cfb7 100644 --- a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php +++ b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php @@ -83,7 +83,7 @@ public function testGetStorageUrlWithUserSubDomainsDisabled($url, $userId, $expe /** * @testdox GetStorageUrlTrait should return a string when called with a UrlGenerator and Configuration * @covers ::getStorageUrl - * @covers ::build_url + * @covers ::buildUrl * * @dataProvider provideSubDomainsEnabledUrls */ From bf17391062ac008e13e76d76ed85d693f901ff2e Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 26 May 2025 15:39:45 +0200 Subject: [PATCH 26/54] whitespace fix --- solid/lib/Controller/AppController.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/solid/lib/Controller/AppController.php b/solid/lib/Controller/AppController.php index d0b99e92..db34f5bd 100644 --- a/solid/lib/Controller/AppController.php +++ b/solid/lib/Controller/AppController.php @@ -23,8 +23,7 @@ class AppController extends Controller { private $userId; private $userManager; - public function __construct($AppName, IRequest $request, IConfig $config, IUserManager $userManager, IManager $contactsManager, IURLGenerator $urlGenerator, $userId - ){ + public function __construct($AppName, IRequest $request, IConfig $config, IUserManager $userManager, IManager $contactsManager, IURLGenerator $urlGenerator, $userId) { parent::__construct($AppName, $request); $this->userId = $userId; $this->userManager = $userManager; From 99072b3c760716367e0a5223c3391587b16249dd Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 26 May 2025 15:40:11 +0200 Subject: [PATCH 27/54] whitespace --- solid/lib/BaseServerConfig.php | 1 - 1 file changed, 1 deletion(-) diff --git a/solid/lib/BaseServerConfig.php b/solid/lib/BaseServerConfig.php index 8147ba0b..7c391893 100644 --- a/solid/lib/BaseServerConfig.php +++ b/solid/lib/BaseServerConfig.php @@ -5,7 +5,6 @@ use OCP\IConfig; class BaseServerConfig { - ////////////////////////////// CLASS PROPERTIES \\\\\\\\\\\\\\\\\\\\\\\\\\\\ public const ERROR_INVALID_ARGUMENT = 'Invalid %s for %s: %s. Must be one of %s.'; From 07638adb8b65a44ac3fe78b33746941c39957a3c Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 26 May 2025 15:40:52 +0200 Subject: [PATCH 28/54] update visibility --- solid/tests/Unit/Controller/GetStorageUrlTraitTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php index 5790cfb7..34c73f5c 100644 --- a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php +++ b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php @@ -20,8 +20,8 @@ class GetStorageUrlTraitTest extends TestCase { ////////////////////////////////// FIXTURES \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ - const MOCK_URL = 'mock url'; - const MOCK_USER_ID = 'mock user id'; + private const MOCK_URL = 'mock url'; + private const MOCK_USER_ID = 'mock user id'; private $trait; From 2439b1c864784093399354f442a13747faf38450 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 26 May 2025 15:41:19 +0200 Subject: [PATCH 29/54] move docblock to top --- solid/appinfo/routes.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/solid/appinfo/routes.php b/solid/appinfo/routes.php index 469902b6..5475341a 100644 --- a/solid/appinfo/routes.php +++ b/solid/appinfo/routes.php @@ -1,11 +1,4 @@ 'page#approval', 'url' => '/sharing/{clientId}', 'verb' => 'GET'], ['name' => 'page#handleRevoke', 'url' => '/revoke/{clientId}', 'verb' => 'DELETE'], From ae7719d563e930d27c36a0ce20b48cd2aec73504 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Fri, 30 May 2025 12:12:06 +0200 Subject: [PATCH 30/54] use empty string instead of / in the replacement. This will upstream result in that the path passed to solid-crud is '/' for the root instead of an empty string. --- solid/lib/Controller/GetStorageUrlTrait.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/solid/lib/Controller/GetStorageUrlTrait.php b/solid/lib/Controller/GetStorageUrlTrait.php index a5ab4a82..5be0f308 100644 --- a/solid/lib/Controller/GetStorageUrlTrait.php +++ b/solid/lib/Controller/GetStorageUrlTrait.php @@ -29,7 +29,7 @@ final public function setUrlGenerator(IURLGenerator $urlGenerator): void /** * @FIXME: Add check for bob.nextcloud.local/solid/alice to throw 404 - * @TODO: Use route without `@alice` in /apps/solid/@alice/profile/card#me when user-domains are enabled + * @TODO: Use route without `~alice` in /apps/solid/~alice/profile/card#me when user-domains are enabled */ public function getStorageUrl($userId) { $routeUrl = $this->urlGenerator->linkToRoute( @@ -39,8 +39,7 @@ public function getStorageUrl($userId) { $storageUrl = $this->urlGenerator->getAbsoluteURL($routeUrl); - // (?) $storageUrl = preg_replace('/foo$/', '', $storageUrl); - $storageUrl = preg_replace('/foo$/', '/', $storageUrl); + $storageUrl = preg_replace('/foo$/', '', $storageUrl); if ($this->config->getUserSubDomainsEnabled()) { $url = parse_url($storageUrl); @@ -64,14 +63,14 @@ public function validateUrl(RequestInterface $request): bool { $pathParts = explode('/', $path); $pathUsers = array_filter($pathParts, static function ($value) { - return str_starts_with($value, '@'); + return str_starts_with($value, '~'); }); if (count($pathUsers) === 1) { $pathUser = reset($pathUsers); $subDomainUser = explode('.', $host)[0]; - $isValid = $pathUser === '@' . $subDomainUser; + $isValid = $pathUser === '~' . $subDomainUser; } return $isValid; From 4caab8c736bad0bd33d8d39e3fa636d5c9b34475 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Fri, 30 May 2025 12:28:50 +0200 Subject: [PATCH 31/54] make the storage url end in a slash in the profile --- solid/lib/Controller/ProfileController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solid/lib/Controller/ProfileController.php b/solid/lib/Controller/ProfileController.php index 66391438..0f7cfec8 100644 --- a/solid/lib/Controller/ProfileController.php +++ b/solid/lib/Controller/ProfileController.php @@ -306,7 +306,7 @@ private function getUserProfile($userId) { 'preferences' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/settings/preferences.ttl"))), 'privateTypeIndex' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/settings/privateTypeIndex.ttl"))), 'publicTypeIndex' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute("solid.storage.handleGet", array("userId" => $userId, "path" => "/settings/publicTypeIndex.ttl"))), - 'storage' => $this->getStorageUrl($userId), + 'storage' => $this->getStorageUrl($userId) . "/", 'issuer' => $this->urlGenerator->getBaseURL() ); return $profile; From ce7f573c4c754462028c4b7c32e8d0f848e30ad1 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sat, 31 May 2025 11:24:33 +0200 Subject: [PATCH 32/54] fix shell and xml runs --- .github/workflows/shell.yml | 9 ++------- .github/workflows/xml.yml | 7 ++----- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/shell.yml b/.github/workflows/shell.yml index 3ef3d51d..d3026535 100644 --- a/.github/workflows/shell.yml +++ b/.github/workflows/shell.yml @@ -51,10 +51,5 @@ jobs: - uses: actions/checkout@v4 - uses: docker://pipelinecomponents/shellcheck with: - args: >- - find . - -not -path '*/.git/*' - -name '*.sh' - -type f - -print0 - | xargs -0 -r -n1 shellcheck + # yamllint disable-line rule:line-length + args: /bin/sh -c "find . -not -path '*/.git/*' -name '*.sh' -type f -print0 | xargs -0 -r -n1 shellcheck" diff --git a/.github/workflows/xml.yml b/.github/workflows/xml.yml index 62d0c2eb..0c30f8f5 100644 --- a/.github/workflows/xml.yml +++ b/.github/workflows/xml.yml @@ -38,8 +38,5 @@ jobs: - uses: actions/checkout@v4 - uses: docker://pipelinecomponents/xmllint with: - args: >- - find . - -iname '*.xml' - -type f - -exec xmllint --noout {} \+ + # yamllint disable-line rule:line-length + args: /bin/sh -c "find . -iname '*.xml' -type f -exec xmllint --noout {} \+" From 4ab38da106b8fd7d80d5a8c4439b31fe7e416ff6 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sun, 1 Jun 2025 13:09:10 +0200 Subject: [PATCH 33/54] restore nextcloud_version --- Dockerfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 84340bee..9324cbad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,5 @@ -#ARG NEXTCLOUD_VERSION -#FROM nextcloud:${NEXTCLOUD_VERSION} -FROM nextcloud:31 +ARG NEXTCLOUD_VERSION +FROM nextcloud:${NEXTCLOUD_VERSION} RUN apt-get update && apt-get install --no-install-recommends -yq \ git \ From 5da5f67f1f42e6daa73a497bcfb4661bd8f822e0 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sun, 1 Jun 2025 13:09:10 +0200 Subject: [PATCH 34/54] restore nextcloud_version --- Dockerfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 84340bee..9324cbad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,5 @@ -#ARG NEXTCLOUD_VERSION -#FROM nextcloud:${NEXTCLOUD_VERSION} -FROM nextcloud:31 +ARG NEXTCLOUD_VERSION +FROM nextcloud:${NEXTCLOUD_VERSION} RUN apt-get update && apt-get install --no-install-recommends -yq \ git \ From f5c1018b8216d754f26136aa0edf96d2a7cbc6e3 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Sun, 1 Jun 2025 13:12:11 +0200 Subject: [PATCH 35/54] update version number --- solid/appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solid/appinfo/info.xml b/solid/appinfo/info.xml index 16cc1db6..ae64a0ff 100644 --- a/solid/appinfo/info.xml +++ b/solid/appinfo/info.xml @@ -11,7 +11,7 @@ It supports the webid-oidc-dpop-pkce login flow to connect to a Solid App with y When you do this, the Solid App can store data in your Nextcloud account through the Solid protocol. ]]> - 0.9.1 + 0.10.0 agpl Auke van Slooten Solid From 6c8689690ee6a805a43643e142e56ddac837a622 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Sun, 1 Jun 2025 20:24:39 +0200 Subject: [PATCH 36/54] Add project autoloader to Application boot. --- solid/lib/AppInfo/Application.php | 1 + 1 file changed, 1 insertion(+) diff --git a/solid/lib/AppInfo/Application.php b/solid/lib/AppInfo/Application.php index 5436450b..7b3ed3b5 100644 --- a/solid/lib/AppInfo/Application.php +++ b/solid/lib/AppInfo/Application.php @@ -76,5 +76,6 @@ public function register(IRegistrationContext $context): void { } public function boot(IBootContext $context): void { + require_once(__DIR__.'/../../vendor/autoload.php'); } } From 0a5733f6010cc208cdb44ca3e414407107d7c4ee Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Sun, 1 Jun 2025 20:25:04 +0200 Subject: [PATCH 37/54] Fix broken unit-test. --- .../Unit/Controller/PageControllerTest.php | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/solid/tests/Unit/Controller/PageControllerTest.php b/solid/tests/Unit/Controller/PageControllerTest.php index 2b488abf..b15ba6d1 100644 --- a/solid/tests/Unit/Controller/PageControllerTest.php +++ b/solid/tests/Unit/Controller/PageControllerTest.php @@ -1,25 +1,47 @@ getMockBuilder('OCP\IRequest')->getMock(); - $this->controller = new PageController('solid', $request, $this->userId); + public function setUp(): void + { + $mockConfig = $this->getMockBuilder(IConfig::class)->getMock(); + $mockRequest = $this->getMockBuilder(IRequest::class)->getMock(); + $mockUrlGenerator = $this->getMockBuilder(IURLGenerator::class)->getMock(); + $mockUserManager = $this->getMockBuilder(IUserManager::class)->getMock(); + + $this->controller = new PageController( + 'solid', + $mockRequest, + $mockConfig, + $mockUserManager, + $mockUrlGenerator, + self::MOCK_USER_ID + ); } - public function testIndex() { + /** + * @covers ::index + * @uses \OCA\Solid\BaseServerConfig::__construct + * @uses \OCA\Solid\ServerConfig::__construct + */ + public function testIndex() + { $result = $this->controller->index(); $this->assertEquals('index', $result->getTemplateName()); From 4aeb908aa0c3df68d45c83b4700a4c95d21b57b4 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Sun, 1 Jun 2025 20:25:54 +0200 Subject: [PATCH 38/54] Add unit-test code-coverage docblocks. --- solid/tests/Unit/JtiReplayDetectorTest.php | 30 +++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/solid/tests/Unit/JtiReplayDetectorTest.php b/solid/tests/Unit/JtiReplayDetectorTest.php index 5395a60d..888e73e8 100644 --- a/solid/tests/Unit/JtiReplayDetectorTest.php +++ b/solid/tests/Unit/JtiReplayDetectorTest.php @@ -9,20 +9,20 @@ use OCP\DB\IResult; use PHPUnit\Framework\TestCase; +/** + * @coversDefaultClass \OCA\Solid\JtiReplayDetector + * @covers ::__construct + * @covers :: + */ class JtiReplayDetectorTest extends TestCase { - public static function setUpBeforeClass(): void - { - require_once __DIR__.'/../../lib/JtiReplayDetector.php'; - } - private function createMocks($result) { $mockIDBConnection = $this->createMock(IDBConnection::class); $mockQueryBuilder = $this->createMock(IQueryBuilder::class); $mockExpr = $this->createMock(IExpressionBuilder::class); $mockResult = $this->createMock(IResult::class); - + $mockIDBConnection->expects($this->any()) ->method('getQueryBuilder') ->willReturn($mockQueryBuilder); @@ -34,7 +34,7 @@ private function createMocks($result) ->willReturn($mockExpr); $mockExpr->expects($this->any()) ->method('eq') - ->willReturn(""); + ->willReturn(''); $mockQueryBuilder->expects($this->once()) ->method('from') ->willReturnSelf(); @@ -67,13 +67,16 @@ private function createMocks($result) return $mockIDBConnection; } + /** + * @covers ::detect + */ public function testJtiDetected(): void - { + { $dateInterval = new DateInterval('PT90S'); $mockIDBConnection = $this->createMocks(true); - + $detector = new JtiReplayDetector($dateInterval, $mockIDBConnection); - + $mockUUID = 'mockUUID-with-some-more-text'; $mockURI = 'mockURI'; $result = $detector->detect($mockUUID, $mockURI); @@ -81,13 +84,16 @@ public function testJtiDetected(): void $this->assertTrue($result); } + /** + * @covers ::detect + */ public function testJtiNotDetected(): void { $dateInterval = new DateInterval('PT90S'); $mockIDBConnection = $this->createMocks(false); - + $detector = new JtiReplayDetector($dateInterval, $mockIDBConnection); - + $mockUUID = 'mockUUID-with-some-more-text'; $mockURI = 'mockURI'; $result = $detector->detect($mockUUID, $mockURI); From 03b5abf35aff6da5a5a118843ca17d30ae2274cc Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Sun, 1 Jun 2025 20:26:35 +0200 Subject: [PATCH 39/54] Remove unneeded backwards-compatible PHPUnit autoloader call. --- solid/tests/bootstrap.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/solid/tests/bootstrap.php b/solid/tests/bootstrap.php index 91a9a5b9..2f8f24ba 100644 --- a/solid/tests/bootstrap.php +++ b/solid/tests/bootstrap.php @@ -14,8 +14,4 @@ // Fix for "Autoload path not allowed: .../solid/tests/testcase.php" \OC_App::loadApp('solid'); -if(!class_exists('PHPUnit_Framework_TestCase')) { - require_once('PHPUnit/Autoload.php'); -} - OC_Hook::clear(); From 87f41ec48e6d961dddf3f41b144c4d7496328fa8 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Sun, 1 Jun 2025 20:27:34 +0200 Subject: [PATCH 40/54] Remove Nextcloud as development dependency. --- solid/composer.json | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/solid/composer.json b/solid/composer.json index 61dbb3b6..d1de8712 100644 --- a/solid/composer.json +++ b/solid/composer.json @@ -37,33 +37,6 @@ }, "require-dev": { "doctrine/dbal": "*", - "nextcloud/server": "*", "phpunit/phpunit": "^8 || ^9" - }, - "repositories": [ - { - "type": "package", - "package": { - "name": "nextcloud/server", - "version": "27.0.0", - "dist": { - "url": "https://github.com/nextcloud/server/archive/refs/tags/v27.0.0.zip", - "type": "zip" - }, - "source": { - "url": "https://github.com/nextcloud/server.git", - "type": "git", - "reference": "master" - }, - "autoload": { - "psr-4": { - "": "lib/private/legacy", - "OC\\": "lib/private", - "OC\\Core\\": "core/", - "OCP\\": "lib/public" - } - } - } - } - ] + } } From fcbe414539511cf7c574e32d8ee856e64e5cb39e Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Sun, 1 Jun 2025 20:39:04 +0200 Subject: [PATCH 41/54] Add script to determine required xDebug version. --- docker/xdebug.sh | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docker/xdebug.sh diff --git a/docker/xdebug.sh b/docker/xdebug.sh new file mode 100644 index 00000000..c3dd2693 --- /dev/null +++ b/docker/xdebug.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -o errexit # Exit script when a command exits with non-zero status. +#set -o errtrace # Exit on error inside any functions or sub-shells. +set -o nounset # Exit script on use of an undefined variable. +#set -o pipefail # Return exit status of the last command in the pipe that exited with a non-zero exit code + +if [ -z "${NEXTCLOUD_VERSION}" ]; then + echo >&2 'The "NEXTCLOUD_VERSION" variable MUST be set during build: docker build --build-arg "NEXTCLOUD_VERSION=..."' + exit 65 +else + echo "NEXTCLOUD_VERSION is set to '${NEXTCLOUD_VERSION}'" +fi + +PHP_VERSION="${PHP_VERSION:-$(php -r 'echo PHP_VERSION;')}" +PHP_MAJOR="${PHP_VERSION%%.*}" +PHP_MINOR="$(echo "${PHP_VERSION}" | awk -F. '{print $2}')" + +if [ -z "${XDEBUG_VERSION:-}" ]; then + if [ "$PHP_MAJOR" -eq 8 ]; then + XDEBUG_VERSION=3.4.3 + elif [ "$PHP_MAJOR" -eq 7 ]; then + if [ "$PHP_MINOR" -ge 2 ] && [ "$PHP_MINOR" -le 4 ]; then + XDEBUG_VERSION=3.1.6 + elif [ "$PHP_MINOR" -eq 1 ]; then + XDEBUG_VERSION=2.9.8 + elif [ "$PHP_MINOR" -eq 0 ]; then + XDEBUG_VERSION=2.7.2 + else + echo >&2 "Unsupported PHP 7 minor version: $PHP_MINOR" + exit 66 + fi + elif [ "$PHP_MAJOR" -eq 5 ]; then + if [ "$PHP_MINOR" -ge 5 ] && [ "$PHP_MINOR" -le 6 ]; then + XDEBUG_VERSION=2.5.5 + elif [ "$PHP_MINOR" -eq 4 ]; then + XDEBUG_VERSION=2.4.1 + else + echo >&2 "Unsupported PHP 5 minor version: $PHP_MINOR" + exit 67 + fi + else + echo >&2 "Unsupported PHP version: ${PHP_VERSION}" + exit 68 + fi +fi + +echo "${XDEBUG_VERSION}" > /xdebug.version From 577658e37b17903bb61719db154cac299eee7812 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Sun, 1 Jun 2025 20:40:21 +0200 Subject: [PATCH 42/54] Add xDebug install and Nextcloud init to Dockerfile. --- Dockerfile | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9324cbad..d632ef24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,18 @@ ARG NEXTCLOUD_VERSION +ARG XDEBUG_VERSION + FROM nextcloud:${NEXTCLOUD_VERSION} -RUN apt-get update && apt-get install --no-install-recommends -yq \ +SHELL ["/bin/bash", "-c"] + +RUN apt-get update \ + && read -ra PHPIZE_DEPS <<< "${PHPIZE_DEPS}" \ + && apt-get install --no-install-recommends -yq \ git \ sudo \ vim \ zip \ + "${PHPIZE_DEPS[@]}" \ && rm -rf /var/lib/apt/lists/* \ && a2enmod ssl \ && mkdir /tls \ @@ -15,13 +22,24 @@ RUN apt-get update && apt-get install --no-install-recommends -yq \ -subj "/C=RO/ST=Bucharest/L=Bucharest/O=IT/CN=www.example.ro" COPY solid/ /usr/src/nextcloud/apps/solid +COPY docker/xdebug.sh /xdebug.sh COPY init.sh / COPY init-live.sh / COPY site.conf /etc/apache2/sites-enabled/000-default.conf COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer -RUN composer install --working-dir=/usr/src/nextcloud/apps/solid --no-dev --prefer-dist \ - && rm /usr/local/bin/composer + +RUN composer install --working-dir=/usr/src/nextcloud/apps/solid --prefer-dist \ + && sh /xdebug.sh \ + && pecl install "xdebug-$(cat /xdebug.version)" \ + && docker-php-ext-enable xdebug \ + && NEXTCLOUD_ADMIN_PASSWORD='alice123' \ + NEXTCLOUD_ADMIN_USER='alice' \ + NEXTCLOUD_TRUSTED_DOMAINS='localhost server thirdparty nextcloud.local *.nextcloud.local' \ + NEXTCLOUD_UPDATE=1 \ + /entrypoint.sh 'echo' \ + && php /var/www/html/console.php maintenance:install --admin-user='alice' --admin-pass='alice123' \ + && php /var/www/html/console.php app:enable solid WORKDIR /var/www/html EXPOSE 443 From eac291993350ef29469989f1b70c75e072c49302 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Sun, 1 Jun 2025 21:26:02 +0200 Subject: [PATCH 43/54] Update composer.lock file. --- solid/composer.lock | 140 ++++++++++++++++++++------------------------ 1 file changed, 65 insertions(+), 75 deletions(-) diff --git a/solid/composer.lock b/solid/composer.lock index 40fd3303..aadefedb 100644 --- a/solid/composer.lock +++ b/solid/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1843d50801f15c12e9fb50345b3bfb3b", + "content-hash": "ad606694b5bceca2bc068799024e08f9", "packages": [ { "name": "arc/base", @@ -670,20 +670,20 @@ }, { "name": "league/event", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/thephpleague/event.git", - "reference": "d2cc124cf9a3fab2bb4ff963307f60361ce4d119" + "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/event/zipball/d2cc124cf9a3fab2bb4ff963307f60361ce4d119", - "reference": "d2cc124cf9a3fab2bb4ff963307f60361ce4d119", + "url": "https://api.github.com/repos/thephpleague/event/zipball/062ebb450efbe9a09bc2478e89b7c933875b0935", + "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935", "shasum": "" }, "require": { - "php": ">=5.4.0" + "php": ">=7.1.0" }, "require-dev": { "henrikbjorn/phpspec-code-coverage": "~1.0.1", @@ -718,9 +718,9 @@ ], "support": { "issues": "https://github.com/thephpleague/event/issues", - "source": "https://github.com/thephpleague/event/tree/master" + "source": "https://github.com/thephpleague/event/tree/2.3.0" }, - "time": "2018-11-26T11:52:41+00:00" + "time": "2025-03-14T19:51:10+00:00" }, { "name": "league/flysystem", @@ -1647,24 +1647,25 @@ }, { "name": "phrity/util-errorhandler", - "version": "1.1.1", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/sirn-se/phrity-util-errorhandler.git", - "reference": "483228156e06673963902b1cc1e6bd9541ab4d5e" + "reference": "61813189e4525fde4aecad3df849829d526d6f76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sirn-se/phrity-util-errorhandler/zipball/483228156e06673963902b1cc1e6bd9541ab4d5e", - "reference": "483228156e06673963902b1cc1e6bd9541ab4d5e", + "url": "https://api.github.com/repos/sirn-se/phrity-util-errorhandler/zipball/61813189e4525fde4aecad3df849829d526d6f76", + "reference": "61813189e4525fde4aecad3df849829d526d6f76", "shasum": "" }, "require": { - "php": "^7.4 | ^8.0" + "php": "^8.1" }, "require-dev": { "php-coveralls/php-coveralls": "^2.0", - "phpunit/phpunit": "^9.0 | ^10.0 | ^11.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.0 | ^11.0 | ^12.0", "squizlabs/php_codesniffer": "^3.5" }, "type": "library", @@ -1692,9 +1693,9 @@ ], "support": { "issues": "https://github.com/sirn-se/phrity-util-errorhandler/issues", - "source": "https://github.com/sirn-se/phrity-util-errorhandler/tree/1.1.1" + "source": "https://github.com/sirn-se/phrity-util-errorhandler/tree/1.2.0" }, - "time": "2024-09-12T06:49:16+00:00" + "time": "2025-05-26T18:26:51+00:00" }, { "name": "pietercolpaert/hardf", @@ -2260,16 +2261,16 @@ "packages-dev": [ { "name": "doctrine/dbal", - "version": "4.2.2", + "version": "4.2.3", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "19a2b7deb5fe8c2df0ff817ecea305e50acb62ec" + "reference": "33d2d7fe1269b2301640c44cf2896ea607b30e3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/19a2b7deb5fe8c2df0ff817ecea305e50acb62ec", - "reference": "19a2b7deb5fe8c2df0ff817ecea305e50acb62ec", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/33d2d7fe1269b2301640c44cf2896ea607b30e3e", + "reference": "33d2d7fe1269b2301640c44cf2896ea607b30e3e", "shasum": "" }, "require": { @@ -2346,7 +2347,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.2.2" + "source": "https://github.com/doctrine/dbal/tree/4.2.3" }, "funding": [ { @@ -2362,30 +2363,33 @@ "type": "tidelift" } ], - "time": "2025-01-16T08:40:56+00:00" + "time": "2025-03-07T18:29:05+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.4", + "version": "1.1.5", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", - "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12", - "phpstan/phpstan": "1.4.10 || 2.0.3", + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -2405,9 +2409,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.4" + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" }, - "time": "2024-12-07T21:18:45+00:00" + "time": "2025-04-07T20:06:18+00:00" }, { "name": "doctrine/instantiator", @@ -2481,16 +2485,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", "shasum": "" }, "require": { @@ -2529,7 +2533,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" }, "funding": [ { @@ -2537,42 +2541,20 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" - }, - { - "name": "nextcloud/server", - "version": "27.0.0", - "source": { - "type": "git", - "url": "https://github.com/nextcloud/server.git", - "reference": "master" - }, - "dist": { - "type": "zip", - "url": "https://github.com/nextcloud/server/archive/refs/tags/v27.0.0.zip" - }, - "type": "library", - "autoload": { - "psr-4": { - "": "lib/private/legacy", - "OC\\": "lib/private", - "OC\\Core\\": "core/", - "OCP\\": "lib/public" - } - } + "time": "2025-04-29T12:36:36+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.5.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", "shasum": "" }, "require": { @@ -2615,9 +2597,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-05-31T08:24:38+00:00" }, { "name": "phar-io/manifest", @@ -3058,16 +3040,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.22", + "version": "9.6.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c" + "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f80235cb4d3caa59ae09be3adf1ded27521d1a9c", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", + "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", "shasum": "" }, "require": { @@ -3078,7 +3060,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -3141,7 +3123,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.22" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.23" }, "funding": [ { @@ -3152,12 +3134,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-12-05T13:48:26+00:00" + "time": "2025-05-02T06:40:34+00:00" }, { "name": "sebastian/cli-parser", @@ -4175,7 +4165,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -4185,6 +4175,6 @@ "ext-mbstring": "*", "ext-openssl": "*" }, - "platform-dev": [], - "plugin-api-version": "2.2.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } From 1758eed5bde633435f01a8258f88407e2e2b4799 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Sun, 1 Jun 2025 21:48:57 +0200 Subject: [PATCH 44/54] Change PHP GitHub Action (GHA) to run PHPUnit in the Solid Nextcloud Docker image. --- .github/workflows/php.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 8bd8937d..6c41e7bd 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -91,7 +91,15 @@ jobs: working-directory: "solid" env: COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GITHUB_TOKEN }}"}}' - - run: bin/phpunit --configuration .config/phpunit.xml.dist + - run: | + docker run \ + --env 'XDEBUG_MODE=coverage' \ + --rm \ + --volume="./solid:/var/www/html/apps/solid" \ + --workdir=/var/www/html/apps/solid \ + ghcr.io/pdsinterop/solid-nextcloud:179_merge-30 \ + bin/phpunit --configuration phpunit.xml + # 03.quality.php.scan.dependencies-vulnerabilities.yml scan-dependencies-vulnerabilities: name: Scan Dependencies Vulnerabilities From 1a5177e6c43a8585313e58d823c2b4bc3d5db968 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Sun, 1 Jun 2025 21:59:28 +0200 Subject: [PATCH 45/54] Change PHP GitHub Action (GHA) for PHPUnit to use Nextcloud versions (instead of PHP versions). --- .github/workflows/php.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 6c41e7bd..54ebeccd 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -73,18 +73,16 @@ jobs: strategy: fail-fast: false matrix: - php: - - '8.0' # from 2020-11 to 2022-11 (2023-11) - - '8.1' # from 2021-11 to 2023-11 (2025-12) - - '8.2' # from 2022-12 to 2024-12 (2026-12) - - '8.3' # from 2023-11 to 2025-12 (2027-12) + nextcloud_version: + - 28 + - 29 + - 30 steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - coverage: xdebug ini-values: error_reporting=E_ALL, display_errors=On - php-version: ${{ matrix.php }} + php-version: 8.3 - name: Install and Cache Composer dependencies uses: "ramsey/composer-install@v2" with: @@ -97,7 +95,7 @@ jobs: --rm \ --volume="./solid:/var/www/html/apps/solid" \ --workdir=/var/www/html/apps/solid \ - ghcr.io/pdsinterop/solid-nextcloud:179_merge-30 \ + ghcr.io/pdsinterop/solid-nextcloud:179_merge-${{ matrix.nextcloud_version }} \ bin/phpunit --configuration phpunit.xml # 03.quality.php.scan.dependencies-vulnerabilities.yml From fe0822c437c24b3163b59c04edda3a6ed7f7a144 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 2 Jun 2025 09:24:53 +0200 Subject: [PATCH 46/54] update composer.lock --- solid/composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/solid/composer.lock b/solid/composer.lock index a9d4a2e7..23eec227 100644 --- a/solid/composer.lock +++ b/solid/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "630d8401030511a28cf54157d9bbd4cf", + "content-hash": "a5c29aca1210cdfafb93daaada201232", "packages": [ { "name": "arc/base", @@ -4241,7 +4241,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -4251,6 +4251,6 @@ "ext-mbstring": "*", "ext-openssl": "*" }, - "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-dev": [], + "plugin-api-version": "2.2.0" } From 283587616d55b26ae2d135849e6fbaff56348757 Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 2 Jun 2025 09:28:08 +0200 Subject: [PATCH 47/54] fix unit test --- .../Controller/GetStorageUrlTraitTest.php | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php index 34c73f5c..68e8ab8c 100644 --- a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php +++ b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php @@ -163,13 +163,13 @@ public function provideRequests() public function provideSubDomainsDisabledUrls() { return [ - ['url' => 'example.com/foo', 'userId' => 'alice', 'expected' => 'example.com//'], - ['url' => 'https://example.com/foo', 'userId' => 'alice', 'expected' => 'https://example.com//'], - ['url' => 'http://example.com/foo', 'userId' => 'alice', 'expected' => 'http://example.com//'], - ['url' => 'https://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'https://bob.example.com//'], - ['url' => 'http://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'http://bob.example.com//'], - ['url' => 'https://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'https://bob.example.com//'], - ['url' => 'http://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'http://bob.example.com//'], + ['url' => 'example.com/foo', 'userId' => 'alice', 'expected' => 'example.com/'], + ['url' => 'https://example.com/foo', 'userId' => 'alice', 'expected' => 'https://example.com/'], + ['url' => 'http://example.com/foo', 'userId' => 'alice', 'expected' => 'http://example.com/'], + ['url' => 'https://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'https://bob.example.com/'], + ['url' => 'http://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'http://bob.example.com/'], + ['url' => 'https://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'https://bob.example.com/'], + ['url' => 'http://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'http://bob.example.com/'], ]; } @@ -177,14 +177,14 @@ public function provideSubDomainsEnabledUrls() { return [ // @FIXME: "Undefined array key 'host'" caused by the use of `parse_url` - // ['url' => 'example.com/foo', 'userId' => 'alice', 'expected' => 'example.com//'], - - ['url' => 'https://example.com/foo', 'userId' => 'alice', 'expected' => 'https://alice.example.com//'], - ['url' => 'http://example.com/foo', 'userId' => 'alice', 'expected' => 'http://alice.example.com//'], - ['url' => 'https://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'https://alice.bob.example.com//'], - ['url' => 'http://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'http://alice.bob.example.com//'], - ['url' => 'https://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'https://bob.example.com//'], - ['url' => 'http://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'http://bob.example.com//'], + // ['url' => 'example.com/foo', 'userId' => 'alice', 'expected' => 'example.com/'], + + ['url' => 'https://example.com/foo', 'userId' => 'alice', 'expected' => 'https://alice.example.com/'], + ['url' => 'http://example.com/foo', 'userId' => 'alice', 'expected' => 'http://alice.example.com/'], + ['url' => 'https://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'https://alice.bob.example.com/'], + ['url' => 'http://bob.example.com/foo', 'userId' => 'alice', 'expected' => 'http://alice.bob.example.com/'], + ['url' => 'https://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'https://bob.example.com/'], + ['url' => 'http://bob.example.com/foo', 'userId' => 'bob', 'expected' => 'http://bob.example.com/'], ]; } } From 4d97c3dc21afd4c2fc371f45106a7dbd3549d6ae Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 2 Jun 2025 09:35:44 +0200 Subject: [PATCH 48/54] var dumps --- solid/lib/Controller/GetStorageUrlTrait.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/solid/lib/Controller/GetStorageUrlTrait.php b/solid/lib/Controller/GetStorageUrlTrait.php index 5be0f308..4b3a8c7e 100644 --- a/solid/lib/Controller/GetStorageUrlTrait.php +++ b/solid/lib/Controller/GetStorageUrlTrait.php @@ -65,10 +65,15 @@ public function validateUrl(RequestInterface $request): bool { $pathUsers = array_filter($pathParts, static function ($value) { return str_starts_with($value, '~'); }); - +var_dump($host); +var_dump($path); +var_dump($pathParts); +var_dump($pathUsers); if (count($pathUsers) === 1) { $pathUser = reset($pathUsers); $subDomainUser = explode('.', $host)[0]; +var_dump($pathUser); +var_dump($subDomainUser); $isValid = $pathUser === '~' . $subDomainUser; } From c716ed52bb63724ebaec08c6c4c55b5e581ddaff Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 2 Jun 2025 09:37:37 +0200 Subject: [PATCH 49/54] remove var dumps, replace @alice with ~alice --- solid/lib/Controller/GetStorageUrlTrait.php | 6 ------ solid/tests/Unit/Controller/GetStorageUrlTraitTest.php | 8 ++++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/solid/lib/Controller/GetStorageUrlTrait.php b/solid/lib/Controller/GetStorageUrlTrait.php index 4b3a8c7e..e963ed9e 100644 --- a/solid/lib/Controller/GetStorageUrlTrait.php +++ b/solid/lib/Controller/GetStorageUrlTrait.php @@ -65,15 +65,9 @@ public function validateUrl(RequestInterface $request): bool { $pathUsers = array_filter($pathParts, static function ($value) { return str_starts_with($value, '~'); }); -var_dump($host); -var_dump($path); -var_dump($pathParts); -var_dump($pathUsers); if (count($pathUsers) === 1) { $pathUser = reset($pathUsers); $subDomainUser = explode('.', $host)[0]; -var_dump($pathUser); -var_dump($subDomainUser); $isValid = $pathUser === '~' . $subDomainUser; } diff --git a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php index 68e8ab8c..263af99b 100644 --- a/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php +++ b/solid/tests/Unit/Controller/GetStorageUrlTraitTest.php @@ -150,13 +150,13 @@ public function provideRequests() return [ 'invalid: invalid URL' => ['request' => $request->withUri(new Uri('!@#$%^&*()_')), 'expected' => false], - 'invalid: no domain user' => ['request' => $request->withUri(new Uri('https://example.com/@alice/profile/card#me')), 'expected' => false], + 'invalid: no domain user' => ['request' => $request->withUri(new Uri('https://example.com/~alice/profile/card#me')), 'expected' => false], 'invalid: no path or domain user' => ['request' => $request->withUri(new Uri('https://example.com/')), 'expected' => false], 'invalid: no path user' => ['request' => $request->withUri(new Uri('https://alice.example.com/profile/card#me')), 'expected' => false], 'invalid: no URL' => ['request' => $request, 'expected' => false], - 'invalid: path and domain user mismatch' => ['request' => $request->withUri(new Uri('https://bob.example.com/@alice/profile/card#me')), 'expected' => false], - 'valid: minimal path and domain user match' => ['request' => $request->withUri(new Uri('https://alice.example.com/apps/@alice')), 'expected' => true], - 'valid: path and domain user match' => ['request' => $request->withUri(new Uri('https://alice.example.com/apps/solid/@alice/profile/card#me')), 'expected' => true], + 'invalid: path and domain user mismatch' => ['request' => $request->withUri(new Uri('https://bob.example.com/~alice/profile/card#me')), 'expected' => false], + 'valid: minimal path and domain user match' => ['request' => $request->withUri(new Uri('https://alice.example.com/apps/~alice')), 'expected' => true], + 'valid: path and domain user match' => ['request' => $request->withUri(new Uri('https://alice.example.com/apps/solid/~alice/profile/card#me')), 'expected' => true], ]; } From 8b1c36db41ce95f12b7afed8bb082fec445c0a5d Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 2 Jun 2025 09:47:37 +0200 Subject: [PATCH 50/54] revert auto-install --- Dockerfile | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index d632ef24..34ac011c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,17 +29,8 @@ COPY site.conf /etc/apache2/sites-enabled/000-default.conf COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer -RUN composer install --working-dir=/usr/src/nextcloud/apps/solid --prefer-dist \ - && sh /xdebug.sh \ - && pecl install "xdebug-$(cat /xdebug.version)" \ - && docker-php-ext-enable xdebug \ - && NEXTCLOUD_ADMIN_PASSWORD='alice123' \ - NEXTCLOUD_ADMIN_USER='alice' \ - NEXTCLOUD_TRUSTED_DOMAINS='localhost server thirdparty nextcloud.local *.nextcloud.local' \ - NEXTCLOUD_UPDATE=1 \ - /entrypoint.sh 'echo' \ - && php /var/www/html/console.php maintenance:install --admin-user='alice' --admin-pass='alice123' \ - && php /var/www/html/console.php app:enable solid +RUN composer install --working-dir=/usr/src/nextcloud/apps/solid --no-dev --prefer-dist + && rm /usr/local/bin/composer WORKDIR /var/www/html EXPOSE 443 From 28b07a54b11aa3dbaede969580b77a26b777a2ba Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Mon, 2 Jun 2025 09:48:48 +0200 Subject: [PATCH 51/54] add missing \ --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 34ac011c..a0a217c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ COPY site.conf /etc/apache2/sites-enabled/000-default.conf COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer -RUN composer install --working-dir=/usr/src/nextcloud/apps/solid --no-dev --prefer-dist +RUN composer install --working-dir=/usr/src/nextcloud/apps/solid --no-dev --prefer-dist \ && rm /usr/local/bin/composer WORKDIR /var/www/html From f6458672fa58e703dd5fd8eb8048f63f8748263a Mon Sep 17 00:00:00 2001 From: Yvo Brevoort Date: Wed, 12 Mar 2025 17:03:53 +0100 Subject: [PATCH 52/54] update simply-edit and simply.everything --- solid/js/vendor/simplyedit/simply-edit.js | 2259 +++++++------- .../js/vendor/simplyedit/simply.everything.js | 2734 ++++++++++------- 2 files changed, 2797 insertions(+), 2196 deletions(-) diff --git a/solid/js/vendor/simplyedit/simply-edit.js b/solid/js/vendor/simplyedit/simply-edit.js index e140b304..27eb96a9 100644 --- a/solid/js/vendor/simplyedit/simply-edit.js +++ b/solid/js/vendor/simplyedit/simply-edit.js @@ -17,9 +17,6 @@ if (!scriptEl) { scriptEl = document.querySelector("[data-api-key]"); } - if (!scriptEl) { - scriptEl = document.querySelector("[src*='simply-edit.js']"); - } return scriptEl; }; @@ -37,7 +34,7 @@ var scriptURL = document.createElement('a'); scriptURL.href = url; scriptURL.pathname = scriptURL.pathname.replace('simply-edit.js', '').replace(/\/js\/$/, '/'); - if (apiKey !== "" && apiKey !== "muze" && apiKey !== "github") { + if (apiKey !== "") { scriptURL.pathname = scriptURL.pathname + apiKey + "/"; } return scriptURL.href; @@ -103,7 +100,10 @@ var dataFields; if (target.nodeType == document.ELEMENT_NODE && target.getAttribute("data-simply-field")) { dataFields = [target]; - if (target.getAttribute("data-simply-content") === 'fixed') { // special case - if the target field has content fixed, we need to handle its children as well. + if ( + (target.getAttribute("data-simply-content") === 'fixed') || + (target.getAttribute("data-simply-content") === 'attributes') + ) { // special case - if the target field has content fixed or attributes, we need to handle its children as well. var extraFields = target.querySelectorAll("[data-simply-field]"); for (var x=0; x -1); + if (isSub) { + continue; + } + editor.list.init(dataLists[i], listDataItem, useDataBinding); } + if (clone.nodeType == document.ELEMENT_NODE && clone.getAttribute("data-simply-list")) { editor.list.init(clone, listDataItem, useDataBinding); } @@ -825,11 +860,37 @@ list.dataBinding.pauseListeners(list); } + var transformer = list.getAttribute('data-simply-transformer'); + if (transformer) { + if (editor.transformers[transformer] && (typeof editor.transformers[transformer].render === "function")) { + try { + listData = editor.transformers[transformer].render.call(list, listData); + } catch(e) { + console.log("Error thrown in transformer " + transformer); + console.log(e); + } + } else { + console.log("Warning: transformer " + transformer + " is not defined"); + } + } + + if (list.previousValue == JSON.stringify(listData)) { + if (list.dataBinding) { + list.dataBinding.resumeListeners(list); + } + return; // value is the same as the previous time we set it, just keep it; + } + + list.previousValue = JSON.stringify(listData); var previousStyle = list.getAttribute("style"); list.style.height = list.offsetHeight + "px"; // this will prevent the screen from bouncing and messing up the scroll offset. editor.list.clear(list); editor.list.append(list, listData); - list.setAttribute("style", previousStyle); + if (previousStyle) { + list.setAttribute("style", previousStyle); + } else { + list.removeAttribute("style"); + } editor.list.emptyClass(list); if (list.dataBinding) { list.dataBinding.resumeListeners(list); @@ -857,7 +918,7 @@ // Grr... android browser imports the nodes, except the contents of subtemplates. Find them and put them back where they belong. var originalTemplates = template.content.querySelectorAll("template"); - var importedTemplates = clone.querySelectorAll("template"); + var importedTemplates = clone.querySelectorAll("template:not([simply-component])"); for (i=0; i " + newparent); - value._bindings_[subbinding].parentKey = newparent; - if (value[subbinding] && value[subbinding].length) { - for (var i=0; i [data-simply-list-item]"); + //for (i=0; i [data-simply-list-item]"); + for (i=0; i 5) { - console.log("Warning: databinding resolve loop detected!"); + }; + + this.handleEvent = function (event) { + var target = event.currentTarget; + var dataBinding = target.dataBinding; + var elementBinding = target.elementBinding; + + if (typeof dataBinding === 'undefined') { + return; + } + if (dataBinding.paused) { + return; + } + if (target.dataBindingPaused) { + event.stopPropagation(); + return; + } + if (dataBinding.mode === "list") { + if (event.relatedNode && (target != event.relatedNode)) { + return; + } + } + + var i, data, items; + + switch (event.type) { + case "change": + case "databinding:valuechanged": + // Allow the browser to fix what it thinks needs to be fixed (node to be removed, cleaned etc) before setting the new data; + + // these are needed to keep the focus in an element while typing; + elementBinding.pauseListeners(); + dataBinding.set(elementBinding.getter()); + elementBinding.resumeListeners(); + + // these are needed to update after the browser is done doing its thing; window.setTimeout(function() { - binding.resolveCounter = 0; - }, 300); // 300 is a guess; could be any other number. It needs to be long enough so that everyone can settle down before we start resolving again. + elementBinding.pauseListeners(); + dataBinding.set(elementBinding.getter()); + elementBinding.resumeListeners(); + }, 1); // allow the rest of the mutation event to occur; + break; + } + elementBinding.fireEvent("domchanged"); + }; + this.fireEvent = function(event) { + self.dataBinding.fireEvent(self.element, event); + }; + this.fireParent = function(event) { + self.dataBinding.fireEvent(self.element.parentNode, event); + }; + this.isInDocument = function() { + if (document.contains && document.contains(this.element)) { + return true; + } + var parent = element.parentNode; + while (parent) { + if (parent === document) { return true; } - return false; - }; - - var setElements = function() { - if (binding.elementTimer) { - window.clearTimeout(binding.elementTimer); - } - for (var i=0; i -1) { - binding.removeListeners(element); - binding.elements.splice(binding.elements.indexOf(element), 1); - } - }; - - this.cleanupBindings = function() { - if (binding.elements.length < 2) { - return; + shadowValue._bindings_[i] = valueBindings[i]; } - - var inDocument = function(element) { - if (document.contains && document.contains(element)) { - return true; + } + + if (typeof oldValue !== "undefined" && !isEqual(oldValue, shadowValue)) { + binding.config.resolve.call(binding, key, dereference(shadowValue), dereference(oldValue)); + } + //if (typeof shadowValue === "object") { + // shadowValue = dereference(shadowValue); + //} + updateConvertedDataParent(shadowValue); + monitorChildData(shadowValue); + }; + + var updateConvertedDataParent = function(data) { + if ( + binding.config.data._parentBindings_ && + binding.config.data._parentBindings_[binding.key] && + binding.config.data._parentBindings_[binding.key].config.data._simplyListEntryMapping + ) { + var listEntryMapping = binding.config.data._parentBindings_[binding.key].config.data._simplyListEntryMapping; + var convertedParent = binding.config.data._parentBindings_[binding.key].config.data._simplyConvertedParent; + var arrayPaths = binding.config.data._parentBindings_[binding.key].config.data[listEntryMapping]._parentBindings_[binding.key].parentKey.split("/"); + var arrayIndex = arrayPaths.pop(); + arrayIndex = arrayPaths.pop(); + binding.config.data._parentBindings_[binding.key].config.data[binding.key] = data; + var parentData = convertedParent._parentBindings_[arrayIndex].config.data; + var parentKey = arrayPaths.pop(); + parentData[parentKey][arrayIndex][binding.key] = data; + } + }; + + var monitorChildData = function(data) { + // Watch for changes in our child data, because these also need to register as changes in the databound data/elements; + // This allows the use of simple data structures (1 key deep) as databound values and still resolve changes on a specific entry; + var parentData = data; + + if (typeof data === "object") { + var monitor = function(data, key) { + if (!data.hasOwnProperty("_parentBindings_")) { + var bindings = {}; + + Object.defineProperty(data, "_parentBindings_", { + get : function() { + return bindings; + }, + set : function(value) { + bindings[key] = binding; + } + }); + Object.defineProperty(data, "_parentData_", { + get : function() { + return parentData; + } + }); } - var parent = element.parentNode; - while (parent) { - if (parent === document) { - return true; + data._parentBindings_[key] = binding; + + var myvalue = data[key]; + + var renumber = function(key, value, parentBinding) { + var oldparent, newparent; + if (value && value._bindings_) { + for (var subbinding in value._bindings_) { + oldparent = value._bindings_[subbinding].parentKey; + newparent = parentBinding.parentKey + parentBinding.key + "/" + key + "/"; + // console.log(oldparent + " => " + newparent); + value._bindings_[subbinding].parentKey = newparent; + if (value[subbinding] && value[subbinding].length && (typeof value[subbinding] !== "string")) { + for (var i=0; i 5) { + console.log("Warning: databinding resolve loop detected!"); + window.setTimeout(function() { + binding.resolveCounter = 0; + }, 300); // 300 is a guess; could be any other number. It needs to be long enough so that everyone can settle down before we start resolving again. + return true; } + return false; }; - - dataBinding.prototype.addListeners = function(element) { - if (element.dataBinding) { - element.dataBinding.removeListeners(element); + + var setElements = function() { + if (binding.elementTimer) { + window.clearTimeout(binding.elementTimer); } - if (typeof element.mutationObserver === "undefined") { - if (typeof MutationObserver === "function") { - element.mutationObserver = new MutationObserver(this.handleMutation); + for (var i=0; i [data-simply-list-item]"); - for (i=0; i [data-simply-list-item]"); - for (i=0; i -1) { + element.removeListeners(); + binding.elements.splice(binding.elements.indexOf(element), 1); } - self.fireEvent(target, "domchanged"); }; - // Housekeeping, remove references to deleted nodes - document.addEventListener("DOMNodeRemoved", function(evt) { - var target = evt.target; - if (target.nodeType != document.ELEMENT_NODE) { // We don't care about removed text nodes; + this.cleanupBindings = function() { + if (binding.elements.length < 2) { return; } - if (!target.dataBinding) { // nor any element that doesn't have a databinding; - return; + + binding.elements.forEach(function(element) { + if (!element.isInDocument()) { + element.markedForRemoval = true; + } else { + element.markedForRemoval = false; + } + }); + + if (binding.cleanupTimer) { + clearTimeout(binding.cleanupTimer); } - window.setTimeout(function() { // chrome sometimes 'helpfully' removes the element and then inserts it back, probably as a rendering optimalization. We're fine cleaning up in a bit, if still needed. - if (!target.parentNode && target.dataBinding) { - target.dataBinding.unbind(target); - delete target.dataBinding; + + binding.cleanupTimer = window.setTimeout(function() { + binding.elements.filter(function(element) { + if (element.markedForRemoval && !element.isInDocument()) { + element.dataBinding.unbind(element); + return false; + } + element.markedForRemoval = false; + return true; + }); + }, 1000); // If after 1 second the element is still not in the dom, remove the binding; + }; + + initBindings(data, key); + // Call the custom init function, if it is there; + if (typeof binding.config.init === "function") { + binding.config.init.call(binding); + } + + if (binding.mode == "list") { + document.addEventListener("databind:resolved", function() { + if (!binding.skipOldValueUpdate) { + oldValue = dereference(binding.get()); } - }, 1000); - }); - - // polyfill to add :scope selector for IE - (function() { - if (!HTMLElement.prototype.querySelectorAll) { - throw new Error('rootedQuerySelectorAll: This polyfill can only be used with browsers that support querySelectorAll'); - } - - // A temporary element to query against for elements not currently in the DOM - // We'll also use this element to test for :scope support - var container = document.createElement('div'); - - // Check if the browser supports :scope - try { - // Browser supports :scope, do nothing - container.querySelectorAll(':scope *'); - } - catch (e) { - // Match usage of scope - var scopeRE = /\s*:scope/gi; - - // Overrides - function overrideNodeMethod(prototype, methodName) { - // Store the old method for use later - var oldMethod = prototype[methodName]; - - // Override the method - prototype[methodName] = function(query) { - var nodeList, - gaveId = false, - gaveContainer = false; - - if (query.match(scopeRE)) { - if (!this.parentNode) { - // Add to temporary container - container.appendChild(this); - gaveContainer = true; - } - - parentNode = this.parentNode; - - if (!this.id) { - // Give temporary ID - this.id = 'rootedQuerySelector_id_'+(new Date()).getTime(); - gaveId = true; - } - - // Remove :scope - query = query.replace(scopeRE, '#' + this.id + " "); - - // Find elements against parent node - // nodeList = oldMethod.call(parentNode, '#'+this.id+' '+query); - nodeList = parentNode[methodName](query); - // Reset the ID - if (gaveId) { - this.id = ''; - } - - // Remove from temporary container - if (gaveContainer) { - container.removeChild(this); - } - - return nodeList; - } - else { - // No immediate child selector used - return oldMethod.call(this, query); - } - }; - } - - // Browser doesn't support :scope, add polyfill - overrideNodeMethod(HTMLElement.prototype, 'querySelector'); - overrideNodeMethod(HTMLElement.prototype, 'querySelectorAll'); - } - }()); - - editor.init({ - endpoint : document.querySelector("[data-simply-endpoint]") ? document.querySelector("[data-simply-endpoint]").getAttribute("data-simply-endpoint") : null, - toolbars : defaultToolbars, - profile : 'live' + }); + } +}; + +dataBinding.prototype.resumeListeners = function(element) { + element.dataBindingPaused--; + if (element.dataBindingPaused < 0) { + console.log("Warning: resume called of non-paused databinding"); + element.dataBindingPaused = 0; + } + if (element.dataBindingPaused === 0) { + if (element.mutationObserver) { + element.mutationObserver.observe(element, element.mutationObserverConfig); + element.mutationObserver.status = "observing"; + } else { + console.log("Warning: no mutation observer found"); + } + } +}; +dataBinding.prototype.pauseListeners = function(element) { + element.dataBindingPaused++; + if (element.mutationObserver) { + element.mutationObserver.status = "disconnected"; + element.mutationObserver.disconnect(); + } +}; + +// Housekeeping, remove references to deleted nodes +var removalObserver = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.type == "childList") { + mutation.removedNodes.forEach(function(target) { + if (target.nodeType != document.ELEMENT_NODE) { // We don't care about removed text nodes; + return; + } + if (!target.dataBinding) { // nor any element that doesn't have a databinding; + return; + } + window.setTimeout(function() { // chrome sometimes 'helpfully' removes the element and then inserts it back, probably as a rendering optimalization. We're fine cleaning up in a bit, if still needed. + if (!target.parentNode && target.dataBinding && target.elementBinding) { + target.dataBinding.unbind(target.elementBinding); + // if (target.dataBinding.mode == "field") { + // target.dataBinding.set(); + // } + delete target.dataBinding; + } + }, 400); + }); + } }); +}); + +removalObserver.observe(document.body, { + "childList" : true, + "subtree" : true +}); + +// polyfill to add :scope selector for IE +(function() { + if (!HTMLElement.prototype.querySelectorAll) { + throw new Error('rootedQuerySelectorAll: This polyfill can only be used with browsers that support querySelectorAll'); + } + + // A temporary element to query against for elements not currently in the DOM + // We'll also use this element to test for :scope support + var container = document.createElement('div'); + + // Check if the browser supports :scope + try { + // Browser supports :scope, do nothing + container.querySelectorAll(':scope *'); + } + catch (e) { + // Match usage of scope + var scopeRE = /\s*:scope/gi; + + // Overrides + function overrideNodeMethod(prototype, methodName) { + // Store the old method for use later + var oldMethod = prototype[methodName]; + + // Override the method + prototype[methodName] = function(query) { + var nodeList, + gaveId = false, + gaveContainer = false; + + if (query.match(scopeRE)) { + if (!this.parentNode) { + // Add to temporary container + container.appendChild(this); + gaveContainer = true; + } + + parentNode = this.parentNode; + + if (!this.id) { + // Give temporary ID + this.id = 'rootedQuerySelector_id_'+(new Date()).getTime(); + gaveId = true; + } + + // Remove :scope + query = query.replace(scopeRE, '#' + this.id + " "); + + // Find elements against parent node + // nodeList = oldMethod.call(parentNode, '#'+this.id+' '+query); + nodeList = parentNode[methodName](query); + // Reset the ID + if (gaveId) { + this.id = ''; + } + + // Remove from temporary container + if (gaveContainer) { + container.removeChild(this); + } + + return nodeList; + } + else { + // No immediate child selector used + return oldMethod.call(this, query); + } + }; + } + + // Browser doesn't support :scope, add polyfill + overrideNodeMethod(HTMLElement.prototype, 'querySelector'); + overrideNodeMethod(HTMLElement.prototype, 'querySelectorAll'); + } }()); diff --git a/solid/js/vendor/simplyedit/simply.everything.js b/solid/js/vendor/simplyedit/simply.everything.js index ed6561a5..57e66774 100644 --- a/solid/js/vendor/simplyedit/simply.everything.js +++ b/solid/js/vendor/simplyedit/simply.everything.js @@ -1,227 +1,364 @@ -this.simply = (function(simply, global) { - - simply.view = function(app, view) { - - app.view = view || {}; - - var load = function() { - var data = app.view; - var path = editor.data.getDataPath(app.container); - app.view = editor.currentData[path]; - Object.keys(data).forEach(function(key) { - app.view[key] = data[key]; - }); - }; - - if (global.editor && editor.currentData) { - load(); - } else { - document.addEventListener('simply-content-loaded', function() { - load(); - }); - } - - return app.view; - }; +/** + * simply.observe + * This component lets you observe changes in a json compatible data structure + * It doesn't support linking the same object multiple times + * It doesn't register deletion of properties using the delete keyword, assign + * null to the property instead. + * It doesn't register addition of new properties. + * It doesn't register directly assigning new entries in an array on a previously + * non-existant index. + * + * usage: + * + * (function) simply.observe( (object) model, (string) path, (function) callback) + * + * var model = { foo: { bar: 'baz' } }; + * var removeObserver = simply.observe(model, 'foo.bar', function(value, sourcePath) { + * console.log(sourcePath+': '+value); + * }; + * + * The function returns a function that removes the observer when called. + * + * The component can observe in place changes in arrays, either by changing + * an item in a specific index, by calling methods on the array that change + * the array in place or by reassigning the array with a new value. + * + * The sourcePath contains the exact entry that was changed, the value is the + * value for the path passed to simply.observe. + * If an array method was called that changes the array in place, the sourcePath + * also contains that method and its arguments JSON serialized. + * + * sourcePath parts are always seperated with '.', even for array indexes. + * so if foo = [ 'bar' ], the path to 'bar' would be 'foo.0' + */ - return simply; -})(this.simply || {}, this); -this.simply = (function(simply, global) { + /* + FIXME: child properties added after initial observe() call aren't added to the + childListeners. onMissingChildren can't then find them. + TODO: onMissingChildren must loop through all fields to get only the direct child +properties for a given parent, keep seperate index for this? + */ - var routeInfo = []; +(function (global) { + 'use strict'; - function parseRoutes(routes) { - var paths = Object.keys(routes); - var matchParams = /:(\w+|\*)/g; - var matches, params, path; - for (var i=0; ipath.length; + }); + if (!allChildren.length) { + return; + } + var object = getByPath(model, path); + var keysSeen = {}; + allChildren.forEach(function(childPath) { + var key = head(childPath.substr(path.length+1)); + if (typeof object[key] == 'undefined') { + if (!keysSeen[key]) { + callback(object, key, path+'.'+key); + keysSeen[key] = true; + } + } else { + onMissingChildren(model, path+'.'+key, callback); + } + }); + } + + function addChangeListener(model, path, callback) { + if (!changeListeners.has(model)) { + changeListeners.set(model, {}); + } + if (!changeListeners.get(model)[path]) { + changeListeners.get(model)[path] = []; + } + changeListeners.get(model)[path].push(callback); + + if (!parentListeners.has(model)) { + parentListeners.set(model, {}); + } + var parentPath = parent(path); + onParents(model, parentPath, function(parentOb, key, currPath) { + if (!parentListeners.get(model)[currPath]) { + parentListeners.get(model)[currPath] = []; + } + parentListeners.get(model)[currPath].push(path); + }); + + if (!childListeners.has(model)) { + childListeners.set(model, {}); + } + onChildren(model, path, function(childOb, key, currPath) { + if (!childListeners.get(model)[currPath]) { + childListeners.get(model)[currPath] = []; + } + childListeners.get(model)[currPath].push(path); + }); + } + + function removeChangeListener(model, path, callback) { + if (!changeListeners.has(model)) { + return; + } + if (changeListeners.get(model)[path]) { + changeListeners.get(model)[path] = changeListeners.get(model)[path].filter(function(f) { + return f != callback; + }); + } + } + + function pauseObservers() { + observersPaused++; + } + + function resumeObservers() { + observersPaused--; + } + + function attach(model, path, options) { + + var attachArray = function(object, path) { + var desc = Object.getOwnPropertyDescriptor(object, 'push'); + if (!desc || desc.configurable) { + for (var f of ['push','pop','reverse','shift','sort','splice','unshift','copyWithin']) { + (function(f) { + try { + Object.defineProperty(object, f, { + value: function() { + pauseObservers(); + var result = Array.prototype[f].apply(this, arguments); + attach(model, path); + var args = [].slice.call(arguments).map(function(arg) { + return JSON.stringify(arg); + }); + resumeObservers(); + signalChange(model, path, this, path+'.'+f+'('+args.join(',')+')'); + return result; + }, + readable: false, + enumerable: false, + configurable: false + }); + } catch(e) { + console.error('simply.observer: Error: Couldn\'t redefine array method '+f+' on '+path, e); + } + }(f)); + } + for (var i=0, l=object.length; ipath.length; - }); - if (!allChildren.length) { - return; - } - var object = getByPath(model, path); - var keysSeen = {}; - allChildren.forEach(function(childPath) { - var key = head(childPath.substr(path.length+1)); - if (typeof object[key] == 'undefined') { - if (!keysSeen[key]) { - callback(object, key, path+'.'+key); - keysSeen[key] = true; - } - } else { - onMissingChildren(model, path+'.'+key, callback); + }, + init: function(params) { + if (params.root) { + options.root = params.root; } - }); - } - - function addChangeListener(model, path, callback) { - if (!changeListeners.has(model)) { - changeListeners.set(model, {}); - } - if (!changeListeners.get(model)[path]) { - changeListeners.get(model)[path] = []; } - changeListeners.get(model)[path].push(callback); - - if (!parentListeners.has(model)) { - parentListeners.set(model, {}); - } - var parentPath = parent(path); - onParents(model, parentPath, function(parentOb, key, currPath) { - if (!parentListeners.get(model)[currPath]) { - parentListeners.get(model)[currPath] = []; - } - parentListeners.get(model)[currPath].push(path); - }); + }; - if (!childListeners.has(model)) { - childListeners.set(model, {}); + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = route; + } else { + if (!global.simply) { + global.simply = {}; } - onChildren(model, path, function(childOb, key, currPath) { - if (!childListeners.get(model)[currPath]) { - childListeners.get(model)[currPath] = []; - } - childListeners.get(model)[currPath].push(path); - }); + global.simply.route = route; } +})(this); +(function(global) { + 'use strict'; - function removeChangeListener(model, path, callback) { - if (!changeListeners.has(model)) { - return; - } - if (changeListeners.get(model)[path]) { - changeListeners.get(model)[path] = changeListeners.get(model)[path].filter(function(f) { - return f != callback; - }); - } - } + var listeners = {}; - function pauseObservers() { - observersPaused++; - } - - function resumeObservers() { - observersPaused--; - } - - function attach(model, path, options) { - - var attachArray = function(object, path) { - var desc = Object.getOwnPropertyDescriptor(object, 'push'); - if (!desc || desc.configurable) { - for (var f of ['push','pop','reverse','shift','sort','splice','unshift','copyWithin']) { - (function(f) { - try { - Object.defineProperty(object, f, { - value: function() { - pauseObservers(); - var result = Array.prototype[f].apply(this, arguments); - attach(model, path); - var args = [].slice.call(arguments).map(function(arg) { - return JSON.stringify(arg); - }); - resumeObservers(); - signalChange(model, path, this, path+'.'+f+'('+args.join(',')+')'); - return result; - }, - readable: false, - enumerable: false, - configurable: false - }); - } catch(e) { - console.error('simply.observer: Error: Couldn\'t redefine array method '+f+' on '+path, e); - } - }(f)); - } - for (var i=0, l=object.length; i=0) { + knownCollections[name].splice(index, 1); + } + } + }, + update: function(element, value) { + element.value = value; + element.dispatchEvent(new Event('change', { + bubbles: true, + cancelable: true + })); + } }; - var handleChanges = throttle(function() { - runWhenIdle(function() { - var links = document.querySelectorAll('link[rel="simply-include"],link[rel="simply-include-once"]'); - if (links.length) { - includeLinks(links); + function findCollection(el) { + while (el && !el.dataset.simplyCollection) { + el = el.parentElement; + } + return el; + } + + global.addEventListener('change', function(evt) { + var root = null; + var name = ''; + if (evt.target.dataset.simplyElement) { + root = findCollection(evt.target); + if (root && root.dataset) { + name = root.dataset.simplyCollection; } - }); - }); - - var observe = function() { - observer = new MutationObserver(handleChanges); - observer.observe(document, { - subtree: true, - childList: true, - }); - }; - - observe(); + } + if (name && knownCollections[name]) { + var inputs = root.querySelectorAll('[data-simply-element]'); + var elements = [].reduce.call(inputs, function(elements, input) { + elements[input.dataset.simplyElement] = input; + return elements; + }, {}); + for (var i=knownCollections[name].length-1; i>=0; i--) { + var result = knownCollections[name][i].call(evt.target.form, elements); + if (result === false) { + break; + } + } + } + }, true); - return simply; + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = collect; + } else { + if (!global.simply) { + global.simply = {}; + } + global.simply.collect = collect; + } -})(this.simply || {}, this); -this.simply = (function(simply, global) { +})(this); +(function(global) { + 'use strict'; var defaultCommands = { 'simply-hide': function(el, value) { @@ -957,6 +951,16 @@ this.simply = (function(simply, global) { { match: 'input,select,textarea', get: function(el) { + if (el.tagName==='SELECT' && el.multiple) { + var values = [], opt; + for (var i=0,l=el.options.length;i { + if (e.isComposing || e.keyCode === 229) { + return; } - if (knownCollections[name].indexOf(callback) == -1) { - knownCollections[name].push(callback); + if (e.defaultPrevented) { + return; } - }, - removeListener: function(name, callback) { - if (knownCollections[name]) { - var index = knownCollections[name].indexOf(callback); - if (index>=0) { - knownCollections[name].splice(index, 1); - } + if (!e.target) { + return; } - }, - update: function(element, value) { - element.value = value; - element.dispatchEvent(new Event('change', { - bubbles: true, - cancelable: true - })); - } - }; - function findCollection(el) { - while (el && !el.dataset.simplyCollection) { - el = el.parentElement; + let selectedKeyboard = 'default'; + if (e.target.closest('[data-simply-keyboard]')) { + selectedKeyboard = e.target.closest('[data-simply-keyboard]').dataset.simplyKeyboard; + } + let key = ''; + if (e.ctrlKey && e.keyCode!=17) { + key+='Control+'; + } + if (e.metaKey && e.keyCode!=224) { + key+='Meta+'; + } + if (e.altKey && e.keyCode!=18) { + key+='Alt+'; + } + if (e.shiftKey && e.keyCode!=16) { + key+='Shift+'; + } + key+=e.key; + + if (keys[selectedKeyboard] && keys[selectedKeyboard][key]) { + let keyboard = keys[selectedKeyboard] + keyboard.app = app; + keyboard[key].call(keyboard,e); + } + }); + + return keys; + } + + + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = keyboard; + } else { + if (!global.simply) { + global.simply = {}; } - return el; + global.simply.keyboard = keyboard; } - - document.addEventListener('change', function(evt) { - var root = null; - var name = ''; - if (evt.target.dataset.simplyElement) { - root = findCollection(evt.target); - if (root && root.dataset) { - name = root.dataset.simplyCollection; +})(this); +(function(global) { + 'use strict'; + + var defaultActions = { + 'simply-hide': function(el) { + el.classList.remove('simply-visible'); + return Promise.resolve(); + }, + 'simply-show': function(el) { + el.classList.add('simply-visible'); + return Promise.resolve(); + }, + 'simply-select': function(el,group,target,targetGroup) { + if (group) { + this.call('simply-deselect', this.app.container.querySelectorAll('[data-simply-group='+group+']')); } - } - if (name && knownCollections[name]) { - var inputs = root.querySelectorAll('[data-simply-element]'); - var elements = [].reduce.call(inputs, function(elements, input) { - elements[input.dataset.simplyElement] = input; - return elements; - }, {}); - for (var i=knownCollections[name].length-1; i>=0; i--) { - var result = knownCollections[name][i].call(evt.target.form, elements); - if (result === false) { + el.classList.add('simply-selected'); + if (target) { + this.call('simply-select',target,targetGroup); + } + return Promise.resolve(); + }, + 'simply-toggle-select': function(el,group,target,targetGroup) { + if (!el.classList.contains('simply-selected')) { + this.call('simply-select',el,group,target,targetGroup); + } else { + this.call('simply-deselect',el,target); + } + return Promise.resolve(); + }, + 'simply-toggle-class': function(el,className,target) { + if (!target) { + target = el; + } + return Promise.resolve(target.classList.toggle(className)); + }, + 'simply-deselect': function(el,target) { + if ( typeof el.length=='number' && typeof el.item=='function') { + el = Array.prototype.slice.call(el); + } + if ( Array.isArray(el) ) { + for (var i=0,l=el.length; i1 && curr) { - var key = parts.shift(); - if (typeof curr[key] == 'undefined' || curr[key]==null) { - curr[key] = {}; - } - curr = curr[key]; +})(this); +(function(global) { + 'use strict'; + + var resize = function(app, config) { + if (!config) { + config = {}; + } + if (!config.sizes) { + config.sizes = { + 'simply-tiny' : 0, + 'simply-xsmall' : 480, + 'simply-small' : 768, + 'simply-medium' : 992, + 'simply-large' : 1200 + }; } - curr[parts.shift()] = value; - } - function setValue(el, value, binding) { - if (el!=focusedElement) { - var fieldType = getFieldType(binding.fieldTypes, el); - if (fieldType) { - fieldType.set.call(el, (typeof value != 'undefined' ? value : ''), binding); - el.dispatchEvent(new Event('simply.bind.resolved', { - bubbles: true, - cancelable: false - })); + var lastSize = 0; + function resizeSniffer() { + var size = app.container.getBoundingClientRect().width; + if ( lastSize==size ) { + return; + } + lastSize = size; + var sizes = Object.keys(config.sizes); + var match = sizes.pop(); + while (match) { + if ( size=0;i--) { - if (el.matches(setters[i])) { - return binding.fieldTypes[setters[i]].get.call(el); - } + if ( global.attachEvent ) { + app.container.attachEvent('onresize', resizeSniffer); + } else { + global.setInterval(resizeSniffer, 200); } - } - function getFieldType(fieldTypes, el) { - var setters = Object.keys(fieldTypes); - for(var i=setters.length-1;i>=0;i--) { - if (el.matches(setters[i])) { - return fieldTypes[setters[i]]; - } + if ( simply.toolbar ) { + var toolbars = app.container.querySelectorAll('.simply-toolbar'); + [].forEach.call(toolbars, function(toolbar) { + simply.toolbar.init(toolbar); + if (simply.toolbar.scroll) { + simply.toolbar.scroll(toolbar); + } + }); } - return null; - } - function getPath(el, attribute) { - var attributes = attribute.split(','); - for (var attr of attributes) { - if (el.hasAttribute(attr)) { - return el.getAttribute(attr); - } + return resizeSniffer; + }; + + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = resize; + } else { + if (!global.simply) { + global.simply = {}; } - return null; + global.simply.resize = resize; } +})(this);(function (global) { + 'use strict'; - function throttle( callbackFunction, intervalTime ) { + var throttle = function( callbackFunction, intervalTime ) { var eventId = 0; return function() { var myArguments = arguments; @@ -1236,7 +1373,7 @@ this.simply = (function(simply, global) { }, intervalTime ); } }; - } + }; var runWhenIdle = (function() { if (global.requestIdleCallback) { @@ -1247,165 +1384,649 @@ this.simply = (function(simply, global) { return global.requestAnimationFrame; })(); - function Binding(config, force) { - this.config = config; - if (!this.config) { - this.config = {}; + var rebaseHref = function(relative, base) { + let url = new URL(relative, base) + if (include.cacheBuster) { + url.searchParams.set('cb',include.cacheBuster) + } + return url.href + }; + + var observer, loaded = {}; + var head = global.document.querySelector('head'); + var currentScript = global.document.currentScript; + if (!currentScript) { + var getScriptURL = (function() { + var scripts = document.getElementsByTagName('script'); + var index = scripts.length - 1; + var myScript = scripts[index]; + return function() { return myScript.src; }; + })(); + var currentScriptURL = getScriptURL(); + } else { + var currentScriptURL = currentScript.src; + } + + var waitForPreviousScripts = function() { + // because of the async=false attribute, this script will run after + // the previous scripts have been loaded and run + // simply.include.next.js only fires the simply-next-script event + // that triggers the Promise.resolve method + return new Promise(function(resolve) { + var next = global.document.createElement('script'); + next.src = rebaseHref('simply.include.next.js', currentScriptURL); + next.async = false; + global.document.addEventListener('simply-include-next', function() { + head.removeChild(next); + resolve(); + }, { once: true, passive: true}); + head.appendChild(next); + }); + }; + + var scriptLocations = []; + + var include = { + cacheBuster: null, + scripts: function(scripts, base) { + var arr = []; + for(var i = scripts.length; i--; arr.unshift(scripts[i])); + var importScript = function() { + var script = arr.shift(); + if (!script) { + return; + } + var attrs = [].map.call(script.attributes, function(attr) { + return attr.name; + }); + var clone = global.document.createElement('script'); + attrs.forEach(function(attr) { + clone.setAttribute(attr, script.getAttribute(attr)); + }); + clone.removeAttribute('data-simply-location'); + if (!clone.src) { + // this is an inline script, so copy the content and wait for previous scripts to run + clone.innerHTML = script.innerHTML; + waitForPreviousScripts() + .then(function() { + var node = scriptLocations[script.dataset.simplyLocation]; + node.parentNode.insertBefore(clone, node); + node.parentNode.removeChild(node); + importScript(); + }); + } else { + clone.src = rebaseHref(clone.src, base); + if (!clone.hasAttribute('async') && !clone.hasAttribute('defer')) { + clone.async = false; //important! do not use clone.setAttribute('async', false) - it has no effect + } + var node = scriptLocations[script.dataset.simplyLocation]; + node.parentNode.insertBefore(clone, node); + node.parentNode.removeChild(node); + loaded[clone.src]=true; + importScript(); + } + }; + if (arr.length) { + importScript(); + } + }, + html: function(html, link) { + var fragment = global.document.createRange().createContextualFragment(html); + var stylesheets = fragment.querySelectorAll('link[rel="stylesheet"],style'); + // add all stylesheets to head + [].forEach.call(stylesheets, function(stylesheet) { + if (stylesheet.href) { + stylesheet.href = rebaseHref(stylesheet.href, link.href); + } + head.appendChild(stylesheet); + }); + // remove the scripts from the fragment, as they will not run in the + // order in which they are defined + var scriptsFragment = global.document.createDocumentFragment(); + // FIXME: this loses the original position of the script + // should add a placeholder so we can reinsert the clone + var scripts = fragment.querySelectorAll('script'); + [].forEach.call(scripts, function(script) { + var placeholder = global.document.createComment(script.src || 'inline script'); + script.parentNode.insertBefore(placeholder, script); + script.dataset.simplyLocation = scriptLocations.length; + scriptLocations.push(placeholder); + scriptsFragment.appendChild(script); + }); + // add the remainder before the include link + link.parentNode.insertBefore(fragment, link ? link : null); + global.setTimeout(function() { + if (global.editor && global.editor.data && fragment.querySelector('[data-simply-field],[data-simply-list]')) { + //TODO: remove this dependency and let simply.bind listen for dom node insertions (and simply-edit.js use simply.bind) + global.editor.data.apply(global.editor.currentData, global.document); + } + simply.include.scripts(scriptsFragment.childNodes, link ? link.href : global.location.href ); + }, 10); } - if (!this.config.model) { - this.config.model = {}; + }; + + var included = {}; + var includeLinks = function(links) { + // mark them as in progress, so handleChanges doesn't find them again + var remainingLinks = [].reduce.call(links, function(remainder, link) { + if (link.rel=='simply-include-once' && included[link.href]) { + link.parentNode.removeChild(link); + } else { + included[link.href]=true; + link.rel = 'simply-include-loading'; + remainder.push(link); + } + return remainder; + }, []); + [].forEach.call(remainingLinks, function(link) { + if (!link.href) { + return; + } + // fetch the html + fetch(link.href) + .then(function(response) { + if (response.ok) { + console.log('simply-include: loaded '+link.href); + return response.text(); + } else { + console.log('simply-include: failed to load '+link.href); + } + }) + .then(function(html) { + // if succesfull import the html + simply.include.html(html, link); + // remove the include link + link.parentNode.removeChild(link); + }); + }); + }; + + var handleChanges = throttle(function() { + runWhenIdle(function() { + var links = global.document.querySelectorAll('link[rel="simply-include"],link[rel="simply-include-once"]'); + if (links.length) { + includeLinks(links); + } + }); + }); + + var observe = function() { + observer = new MutationObserver(handleChanges); + observer.observe(global.document, { + subtree: true, + childList: true, + }); + }; + + observe(); + handleChanges(); // check if there are include links in the dom already + + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = include; + } else { + if (!global.simply) { + global.simply = {}; } - if (!this.config.attribute) { - this.config.attribute = 'data-simply-bind'; + global.simply.include = include; + } + + +})(this); +(function(global) { + 'use strict'; + var view = function(app, view) { + + app.view = view || {}; + + var load = function() { + var data = app.view; + var path = global.editor.data.getDataPath(app.container); + app.view = global.editor.currentData[path]; + Object.keys(data).forEach(function(key) { + app.view[key] = data[key]; + }); + }; + + if (global.editor && global.editor.currentData) { + load(); + } else { + global.document.addEventListener('simply-content-loaded', function() { + load(); + }); } - if (!this.config.selector) { - this.config.selector = '[data-simply-bind]'; + + return app.view; + }; + + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = view; + } else { + if (!global.simply) { + global.simply = {}; } - if (!this.config.container) { - this.config.container = document; + global.simply.view = view; + } +})(this); +(function(global) { + 'use strict'; + + function etag() { + let d = ''; + while (d.length < 32) d += Math.random().toString(16).substr(2); + const vr = ((parseInt(d.substr(16, 1), 16) & 0x3) | 0x8).toString(16); + return `${d.substr(0, 8)}-${d.substr(8, 4)}-4${d.substr(13, 3)}-${vr}${d.substr(17, 3)}-${d.substr(20, 12)}`; + } + + function ViewModel(name, data, options) { + this.name = name; + this.data = data || []; + this.data.etag = etag(); + this.view = { + options: {}, + data: [] //Array.from(this.data).slice() + }; + this.options = options || {}; + this.plugins = { + start: [], + select: [], + order: [], + render: [], + finish: [] + }; + } + + ViewModel.prototype.update = function(params) { + if (!params) { + params = {}; } - if (typeof this.config.twoway == 'undefined') { - this.config.twoway = true; + if (params.data) { + // this.data is a reference to the data passed, so that any changes in it will get applied + // to the original + this.data = params.data; + this.data.etag = etag() } - this.fieldTypes = { - '*': { - set: function(value) { - this.innerHTML = value; - }, - get: function() { - return this.innerHTML; + // the view is a shallow copy of the array, so that changes in sort order and filtering + // won't get applied to the original, but databindings on its children will still work + this.view.data = Array.from(this.data).slice(); + this.view.data.etag = this.data.etag; + let data = this.view.data; + let plugins = this.plugins.start.concat(this.plugins.select, this.plugins.order, this.plugins.render, this.plugins.finish); + plugins.forEach(plugin => { + data = plugin.call(this, params, data); + if (!data) { + data = this.view.data; + } + this.view.data = data + }); + + if (global.editor) { + global.editor.addDataSource(this.name,{ + load: function(el, callback) { + callback(self.view.data); } + }); + updateDataSource(this.name); + } + }; + + ViewModel.prototype.addPlugin = function(pipe, plugin) { + if (typeof this.plugins[pipe] == 'undefined') { + throw new Error('Unknown pipeline '+pipe); + } + this.plugins[pipe].push(plugin); + }; + + ViewModel.prototype.removePlugin = function(pipe, plugin) { + if (typeof this.plugins[pipe] == 'undefined') { + throw new Error('Unknown pipeline '+pipe); + } + this.plugins[pipe] = this.plugins[pipe].filter(function(p) { + return p != plugin; + }); + }; + + var updateDataSource = function(name) { + global.document.querySelectorAll('[data-simply-data="'+name+'"]').forEach(function(list) { + global.editor.list.applyDataSource(list, name); + }); + }; + + var createSort = function(options) { + var defaultOptions = { + name: 'sort', + getSort: function(params) { + return Array.prototype.sort; } }; - if (this.config.fieldTypes) { - Object.assign(this.fieldTypes, this.config.fieldTypes); - } - this.attach(this.config.container.querySelectorAll(this.config.selector), this.config.model, force); - if (this.config.twoway) { - var self = this; - var observer = new MutationObserver( - throttle(function() { - runWhenIdle(function() { - self.attach(self.config.container.querySelectorAll(self.config.selector), self.config.model); - }); - }) - ); - observer.observe(this.config.container, { - subtree: true, - childList: true - }); + options = Object.assign(defaultOptions, options || {}); + + return function(params) { + this.options[options.name] = options; + if (params[options.name]) { + options = Object.assign(options, params[options.name]); + } + this.view.data.sort(options.getSort.call(this, options)); + }; + }; + + var createPaging = function(options) { + var defaultOptions = { + name: 'paging', + page: 1, + pageSize: 100, + max: 1, + prev: 0, + next: 0 + }; + options = Object.assign(defaultOptions, options || {}); + + return function(params) { + this.options[options.name] = options; + if (this.view.data) { + options.max = Math.max(1, Math.ceil(Array.from(this.view.data).length / options.pageSize)); + } else { + options.max = 1; + } + if (this.view.changed) { + options.page = 1; // reset to page 1 when something in the view data has changed + } + if (params[options.name]) { + options = Object.assign(options, params[options.name]); + } + options.page = Math.max(1, Math.min(options.max, options.page)); // clamp page nr + options.prev = options.page - 1; // calculate previous page, 0 is allowed + if (options.page {}; + cache.$options = Object.assign({}, options); + return new Proxy( cache, getApiHandler(cache.$options) ); + }, - var attachElement = function(jsonPath) { - el.dataset.simplyBound = true; - initElement(el); - setValue(el, getByPath(model, jsonPath), self); - simply.observe(model, jsonPath, function(value) { - if (el != focusedElement) { - setValue(el, value, self); + /** + * Fetches the options.baseURL using the fetch api and returns a promise + * Extra options in addition to those of global.fetch(): + * - user (and password): if set, a basic authentication header will be added + * - paramsFormat: either 'formData', 'json' or 'search' + * By default params, if set, will be added to the baseURL as searchParams + * @param method one of the http verbs, e.g. get, post, etc. + * @param options the options for fetch(), with some additions + * @param params the parameters to send with the request, as javascript/json data + * @return Promise + */ + fetch: function(method, params, options) { + if (!options.url) { + if (!options.baseURL) { + throw new Error('No url or baseURL in options object'); } - }); - }; - - var addMutationObserver = function(jsonPath) { - if (el.dataset.simplyList) { - return; - } - var update = throttle(function() { - runWhenIdle(function() { - var v = getValue(el, self); - var s = getByPath(model, jsonPath); - if (v != s) { - focusedElement = el; - setByPath(model, jsonPath, v); - focusedElement = null; - } - }); - }, 250); - var observer = new MutationObserver(function() { - if (observersPaused) { - return; + while (options.baseURL[options.baseURL.length-1]=='/') { + options.baseURL = options.baseURL.substr(0, options.baseURL.length-1); } - update(); - }); - observer.observe(el, { - characterData: true, - subtree: true, - childList: true, - attributes: true - }); - if (!observers.has(el)) { - observers.set(el, []); + var url = new URL(options.baseURL+options.path); + } else { + var url = options.url; } - observers.get(el).push(observer); - return observer; - }; - - /** - * Runs the init() method of the fieldType, if it is defined. - **/ - var initElement = function(el) { - if (initialized.has(el)) { - return; + var fetchOptions = Object.assign({}, options); + if (!fetchOptions.headers) { + fetchOptions.headers = {}; } - initialized.set(el, true); - var selectors = Object.keys(self.fieldTypes); - for (var i=selectors.length-1; i>=0; i--) { - if (self.fieldTypes[selectors[i]].init && el.matches(selectors[i])) { - self.fieldTypes[selectors[i]].init.call(el, self); - return; + if (params) { + if (method=='GET') { + var paramsFormat = 'search'; + } else { + var paramsFormat = options.paramsFormat; + } + switch(paramsFormat) { + case 'formData': + var formData = new FormData(); + for (const name in params) { + formData.append(name, params[name]); + } + if (!fetchOptions.headers['Content-Type']) { + fetchOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + break; + case 'json': + var formData = JSON.stringify(params); + if (!fetchOptions.headers['Content-Type']) { + fetchOptions.headers['Content-Type'] = 'application/json'; + } + break; + case 'search': + var searchParams = url.searchParams; //new URLSearchParams(url.search.slice(1)); + for (const name in params) { + searchParams.set(name, params[name]); + } + url.search = searchParams.toString(); + break; + default: + throw Error('Unknown options.paramsFormat '+options.paramsFormat+'. Select one of formData, json or search.'); + break; } } - }; - - var self = this; - if (el instanceof HTMLElement) { - if (!force && el.dataset.simplyBound) { - return; + if (formData) { + fetchOptions.body = formData } - var jsonPath = getPath(el, this.config.attribute); - if (illegalNesting(el)) { - el.dataset.simplyBound = 'Error: nested binding'; - console.error('Error: found nested data-binding element:',el); - return; + if (options.user) { + fetchOptions.headers['Authorization'] = 'Basic '+btoa(options.user+':'+options.password); } - attachElement(jsonPath); - if (this.config.twoway) { - addMutationObserver(jsonPath); + fetchOptions.method = method.toUpperCase(); + var fetchURL = url.toString() + return fetch(fetchURL, fetchOptions); + }, + /** + * Creates a function to call one or more graphql queries + */ + graphqlQuery: function(url, query, options) { + options = Object.assign({ paramsFormat: 'json', url: url, responseFormat: 'json' }, options); + return function(params, operationName) { + let postParams = { + query: query + }; + if (operationName) { + postParams.operationName = operationName; + } + postParams.variables = params || {}; + return simply.api.fetch('POST', postParams, options ) + .then(function(response) { + return simply.api.getResult(response, options); + }); + } + }, + /** + * Handles the response and returns a Promise with the response data as specified + * @param response Response + * @param options + * - responseFormat: one of 'text', 'formData', 'blob', 'arrayBuffer', 'unbuffered' or 'json'. + * The default is json. + */ + getResult: function(response, options) { + if (response.ok) { + switch(options.responseFormat) { + case 'text': + return response.text(); + break; + case 'formData': + return response.formData(); + break; + case 'blob': + return response.blob(); + break; + case 'arrayBuffer': + return response.arrayBuffer(); + break; + case 'unbuffered': + return response.body; + break; + case 'json': + default: + return response.json(); + break; + } + } else { + throw { + status: response.status, + message: response.statusText, + response: response + } } - } else { - [].forEach.call(el, function(element) { - self.attach(element, model, force); - }); + }, + logError: function(error, options) { + console.error(error.status, error.message); } - }; + } - Binding.prototype.pauseObservers = function() { - observersPaused++; + var defaultOptions = { + path: '', + responseFormat: 'json', + paramsFormat: 'search', + verbs: ['get','post'], + handlers: { + fetch: api.fetch, + result: api.getResult, + error: api.logError + } }; - Binding.prototype.resumeObservers = function() { - observersPaused--; - }; + function cd(path, name) { + name = name.replace(/\//g,''); + if (!path.length || path[path.length-1]!=='/') { + path+='/'; + } + return path+encodeURIComponent(name); + } - simply.bind = function(config, force) { - return new Binding(config, force); - }; + function fetchChain(prop, params) { + var options = this; + return this.handlers.fetch + .call(this, prop, params, options) + .then(function(res) { + return options.handlers.result.call(options, res, options); + }) + .catch(function(error) { + return options.handlers.error.call(options, error, options); + }); + } + + function getApiHandler(options) { + options.handlers = Object.assign({}, defaultOptions.handlers, options.handlers); + options = Object.assign({}, defaultOptions, options); + + return { + get: function(cache, prop) { + if (!cache[prop]) { + if (options.verbs.indexOf(prop)!=-1) { + cache[prop] = function(params) { + return fetchChain.call(options, prop, params); + } + } else { + cache[prop] = api.proxy(Object.assign({}, options, { + path: cd(options.path, prop) + })); + } + } + return cache[prop]; + }, + apply: function(cache, thisArg, params) { + return fetchChain.call(options, 'get', params[0] ? params[0] : null) + } + } + } + + + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + module.exports = api; + } else { + if (!global.simply) { + global.simply = {}; + } + global.simply.api = api; + } + +})(this); +(function(global) { + 'use strict'; - return simply; -})(this.simply || {}, this);this.simply = (function(simply, global) { - simply.app = function(options) { + var app = function(options) { if (!options) { options = {}; } @@ -1419,12 +2040,22 @@ this.simply = (function(simply, global) { } if ( options.routes ) { simply.route.load(options.routes); + if (options.routeEvents) { + Object.keys(options.routeEvents).forEach(function(action) { + Object.keys(options.routeEvents[action]).forEach(function(route) { + options.routeEvents[action][route].forEach(function(callback) { + simply.route.addListener(action, route, callback); + }); + }); + }); + } simply.route.handleEvents(); global.setTimeout(function() { simply.route.match(global.location.pathname+global.location.hash); - },1); + }); } - this.container = options.container || document.body; + this.container = options.container || document.body; + this.keyboard = simply.keyboard ? simply.keyboard(this, options.keyboard || {}) : false; this.actions = simply.action ? simply.action(this, options.actions) : false; this.commands = simply.command ? simply.command(this, options.commands) : false; this.resize = simply.resize ? simply.resize(this, options.resize) : false; @@ -1442,187 +2073,16 @@ this.simply = (function(simply, global) { return this.container.querySelector('[data-simply-id='+id+']') || document.getElementById(id); }; - var app = new simplyApp(options); - - return app; - }; - - return simply; -})(this.simply || {}, this); -this.simply = (function(simply, global) { - - var listeners = {}; - - simply.activate = { - addListener: function(name, callback) { - if (!listeners[name]) { - listeners[name] = []; - } - listeners[name].push(callback); - initialCall(name); - }, - removeListener: function(name, callback) { - if (!listeners[name]) { - return false; - } - listeners[name] = listeners[name].filter(function(listener) { - return listener!=callback; - }); - } - }; - - var initialCall = function(name) { - var nodes = document.querySelectorAll('[data-simply-activate="'+name+'"]'); - if (nodes) { - [].forEach.call(nodes, function(node) { - callListeners(node); - }); - } - }; - - var callListeners = function(node) { - if (node && node.dataset.simplyActivate - && listeners[node.dataset.simplyActivate] - ) { - listeners[node.dataset.simplyActivate].forEach(function(callback) { - callback.call(node); - }); - } - }; - - var handleChanges = function(changes) { - var activateNodes = []; - for (var change of changes) { - if (change.type=='childList') { - [].forEach.call(change.addedNodes, function(node) { - if (node.querySelectorAll) { - var toActivate = [].slice.call(node.querySelectorAll('[data-simply-activate]')); - if (node.matches('[data-simply-activate]')) { - toActivate.push(node); - } - activateNodes = activateNodes.concat(toActivate); - } - }); - } - } - if (activateNodes.length) { - activateNodes.forEach(function(node) { - callListeners(node); - }); - } - }; - - var observer = new MutationObserver(handleChanges); - observer.observe(document, { - subtree: true, - childList: true - }); - - return simply; -})(this.simply || {}, this); -this.simply = (function(simply, global) { - var defaultActions = { - 'simply-hide': function(el) { - el.classList.remove('simply-visible'); - return Promise.resolve(); - }, - 'simply-show': function(el) { - el.classList.add('simply-visible'); - return Promise.resolve(); - }, - 'simply-select': function(el,group,target,targetGroup) { - if (group) { - this.call('simply-deselect', this.app.container.querySelectorAll('[data-simply-group='+group+']')); - } - el.classList.add('simply-selected'); - if (target) { - this.call('simply-select',target,targetGroup); - } - return Promise.resolve(); - }, - 'simply-toggle-select': function(el,group,target,targetGroup) { - if (!el.classList.contains('simply-selected')) { - this.call('simply-select',el,group,target,targetGroup); - } else { - this.call('simply-deselect',el,target); - } - return Promise.resolve(); - }, - 'simply-toggle-class': function(el,className,target) { - if (!target) { - target = el; - } - return Promise.resolve(target.classList.toggle(className)); - }, - 'simply-deselect': function(el,target) { - if ( typeof el.length=='number' && typeof el.item=='function') { - el = Array.prototype.slice.call(el); - } - if ( Array.isArray(el) ) { - for (var i=0,l=el.length; i Date: Wed, 12 Mar 2025 17:16:38 +0100 Subject: [PATCH 53/54] add file requirement --- solid/templates/applauncher/index.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/solid/templates/applauncher/index.php b/solid/templates/applauncher/index.php index d6fc1d65..bfd38195 100644 --- a/solid/templates/applauncher/index.php +++ b/solid/templates/applauncher/index.php @@ -44,6 +44,14 @@ +