├── .devcontainer ├── compose.yml ├── devcontainer.json └── nightly-next-version │ ├── compose.yml │ └── devcontainer.json ├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── docker.yml │ └── lint.yml ├── .gitignore ├── .mailmap ├── .reviewdog.yml ├── .swift-format ├── Dockerfile ├── LICENSE ├── Package.resolved ├── Package.swift ├── Public ├── scripts │ └── profile-view.js └── styles │ ├── base.css │ └── status.css ├── README.md ├── Resources └── Views │ ├── did │ ├── index.leaf │ └── show.leaf │ ├── error │ └── default.leaf │ ├── handle │ ├── index.leaf │ └── show.leaf │ ├── index.leaf │ └── templates │ ├── base.leaf │ ├── error.leaf │ ├── search.leaf │ └── status.leaf ├── Sources ├── Commands │ ├── CleanupCacheCommand.swift │ ├── ImportDidCommand.swift │ └── ImportExportedLogCommand.swift ├── Controllers │ ├── DidController.swift │ ├── HandleController.swift │ └── ViewOrRedirect.swift ├── Extensions │ ├── Command+Concurrency.swift │ ├── Environment++.swift │ ├── LeafTags+innerText.swift │ ├── QueueName+polling.swift │ ├── Queues+scheduleEvery.swift │ └── RedisClient+Concurrency.swift ├── Jobs │ ├── Dispatched │ │ ├── FetchDidJob.swift │ │ ├── ImportAuditableLogJob.swift │ │ ├── ImportExportedLogJob.swift │ │ └── PollingPlcServerExportJob.swift │ ├── PollingJobNotificationHook.swift │ └── Scheduled │ │ ├── ScheduledPollingHistoryCleanupJob.swift │ │ ├── ScheduledPollingJob.swift │ │ └── ScheduledPollingRecoveryJob.swift ├── Middleware │ ├── ErrorMiddleware.swift │ └── RouteLoggingMiddleware.swift ├── Migrations │ ├── AddCompletedColumnToPollingHistoryTable.swift │ ├── AddDidColumnToPollingJobStatusesTable.swift │ ├── AddFailedColumnToPollingHistoryTable.swift │ ├── AddPrevDidColumnForPrevForeignKey.swift │ ├── AddReasonColumnToBannedDidsTable.swift │ ├── ChangePrimaryKeyToCompositeDidAndCid.swift │ ├── ChangePrimaryKeyToNaturalKeyOfDidAndCid.swift │ ├── ChangeToNullableCidAndCreatedAtColumn.swift │ ├── CreateBannedDidsTable.swift │ ├── CreateDidsTable.swift │ ├── CreateHandlesTable.swift │ ├── CreateIndexForForeignKeyOfOperationsTable.swift │ ├── CreateOperationsTable.swift │ ├── CreatePersonalDataServersTable.swift │ ├── CreatePollingHistoryTable.swift │ ├── CreatePollingJobStatusesTable.swift │ └── MergeBannedDidsTableToDidsTable.swift ├── Models │ ├── Did.swift │ ├── Handle.swift │ ├── Middleware │ │ ├── DidMiddleware.swift │ │ └── HandleMiddleware.swift │ ├── Operation.swift │ ├── PersonalDataServer.swift │ ├── PollingHistory.swift │ └── PollingJobStatus.swift ├── Repositories │ ├── DidRepository.swift │ └── HandleRepository.swift ├── Utilities │ ├── ExportedOperation.swift │ ├── MergeSort.swift │ └── TreeSort.swift ├── Views │ ├── BaseContext.swift │ ├── ExternalLinkTag.swift │ ├── NavLinkTag.swift │ └── SearchContext.swift ├── configure.swift ├── entrypoint.swift ├── registerCommands.swift ├── registerJobs.swift ├── registerMiddleware.swift ├── registerMigrations.swift ├── registerRoutes.swift └── registerViews.swift ├── compose.yml └── renovate.json /.devcontainer/compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | development: 4 | image: ghcr.io/kphrx/swift-devcontainer:6.0-noble 5 | volumes: 6 | - ../..:/workspaces:cached 7 | command: sleep infinity 8 | 9 | db: 10 | image: postgres:17-alpine 11 | restart: always 12 | network_mode: service:development 13 | healthcheck: 14 | test: ["CMD", "pg_isready", "-U", "vapor_username", "-d", "vapor_database"] 15 | timeout: 60s 16 | environment: 17 | POSTGRES_USER: vapor_username 18 | POSTGRES_PASSWORD: vapor_password 19 | POSTGRES_DB: vapor_database 20 | POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" 21 | 22 | redis: 23 | image: redis:8-alpine 24 | restart: always 25 | network_mode: service:development 26 | healthcheck: 27 | test: ["CMD", "redis-cli", "--raw", "incr", "ping"] 28 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "dockerComposeFile": ["compose.yml"], 3 | "service": "development", 4 | "features": { 5 | "ghcr.io/devcontainers/features/git:1": { 6 | "version": "latest" 7 | }, 8 | "ghcr.io/devcontainers/features/node:1": { 9 | "version": "lts", 10 | "pnpmVersion": "none", 11 | "installYarnUsingApt": false 12 | }, 13 | "ghcr.io/devcontainers/features/sshd:1": { 14 | "version": "latest" 15 | }, 16 | "ghcr.io/devcontainers/features/docker-in-docker:2": { 17 | "dockerDashComposeVersion": "v2" 18 | } 19 | }, 20 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 21 | "shutdownAction": "stopCompose", 22 | "forwardPorts": [8080], 23 | "customizations": {} 24 | } 25 | -------------------------------------------------------------------------------- /.devcontainer/nightly-next-version/compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | development: 4 | image: ghcr.io/kphrx/swift-devcontainer:nightly-main-noble 5 | -------------------------------------------------------------------------------- /.devcontainer/nightly-next-version/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "dockerComposeFile": ["../compose.yml", "compose.yml"], 3 | "service": "development", 4 | "features": { 5 | "ghcr.io/devcontainers/features/git:1": { 6 | "version": "latest" 7 | }, 8 | "ghcr.io/devcontainers/features/node:1": { 9 | "version": "lts", 10 | "pnpmVersion": "none", 11 | "installYarnUsingApt": false 12 | }, 13 | "ghcr.io/devcontainers/features/sshd:1": { 14 | "version": "latest" 15 | }, 16 | "ghcr.io/devcontainers/features/docker-in-docker:2": { 17 | "dockerDashComposeVersion": "v2" 18 | } 19 | }, 20 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 21 | "shutdownAction": "stopCompose", 22 | "forwardPorts": [8080], 23 | "customizations": {} 24 | } 25 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .swiftpm/ 2 | .build/ 3 | 4 | .git/ 5 | .gitignore 6 | 7 | .devcontainer/ 8 | .github/ 9 | 10 | .dockerignore 11 | Dockerfile 12 | 13 | *.md 14 | LICENSE 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "swift" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | dependencies: 14 | patterns: 15 | - "*" 16 | 17 | - package-ecosystem: "docker" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | 22 | - package-ecosystem: "github-actions" 23 | directory: "/" 24 | schedule: 25 | interval: "weekly" 26 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | container: swift:${{ matrix.swift-version }} 17 | strategy: 18 | matrix: 19 | swift-version: 20 | - '6.0-noble' 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Restore dependencies cache 25 | uses: actions/cache@v4 26 | with: 27 | path: | 28 | .build/repositories 29 | .build/checkouts 30 | .build/workspace-state.json 31 | key: ${{ runner.os }}-swiftpm-${{ hashFiles('Package.*') }} 32 | 33 | - name: Install dependencies 34 | run: swift package resolve 35 | 36 | - name: Build 37 | if: runner.debug != '1' 38 | run: swift build 39 | - name: Build (verbose) 40 | if: runner.debug == '1' 41 | run: swift build -v 42 | 43 | #- name: Run tests 44 | # if: runner.debug != '1' 45 | # run: swift test 46 | #- name: Run tests (verbose) 47 | # if: runner.debug == '1' 48 | # run: swift test -v 49 | 50 | 51 | conclusion: 52 | 53 | runs-on: ubuntu-latest 54 | needs: build 55 | name: build conclusion 56 | steps: 57 | - run: 'true' 58 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: [ "master" ] 11 | paths: 12 | - ".github/workflows/docker.yml" 13 | - "Dockerfile" 14 | - ".dockerignore" 15 | - "Package.resolved" 16 | - "Package.swift" 17 | - "Sources/**" 18 | - "Resources/**" 19 | - "Public/**" 20 | pull_request: 21 | branches: [ "master" ] 22 | paths: 23 | - ".github/workflows/docker.yml" 24 | - "Dockerfile" 25 | - ".dockerignore" 26 | - "Package.resolved" 27 | - "Package.swift" 28 | - "Sources/**" 29 | - "Resources/**" 30 | - "Public/**" 31 | 32 | env: 33 | # Use docker.io for Docker Hub if empty 34 | REGISTRY: ghcr.io 35 | # github.repository as / 36 | IMAGE_NAME: ${{ github.repository }} 37 | 38 | 39 | jobs: 40 | build: 41 | 42 | runs-on: ubuntu-latest 43 | permissions: 44 | contents: read 45 | packages: write 46 | # This is used to complete the identity challenge 47 | # with sigstore/fulcio when running outside of PRs. 48 | id-token: write 49 | 50 | steps: 51 | - name: Checkout repository 52 | uses: actions/checkout@v4 53 | 54 | # Install the cosign tool except on PR 55 | # https://github.com/sigstore/cosign-installer 56 | - name: Install cosign 57 | uses: sigstore/cosign-installer@v3 58 | with: 59 | cosign-release: 'v2.2.3' 60 | 61 | # Workaround: https://github.com/docker/build-push-action/issues/461 62 | - name: Setup Docker buildx 63 | uses: docker/setup-buildx-action@v3 64 | 65 | # Login against a Docker registry except on PR 66 | # https://github.com/docker/login-action 67 | - name: Log into registry ${{ env.REGISTRY }} 68 | uses: docker/login-action@v3 69 | with: 70 | registry: ${{ env.REGISTRY }} 71 | username: ${{ github.actor }} 72 | password: ${{ secrets.GITHUB_TOKEN }} 73 | 74 | # Extract metadata (tags, labels) for Docker 75 | # https://github.com/docker/metadata-action 76 | - name: Extract Docker metadata 77 | id: meta 78 | uses: docker/metadata-action@v5 79 | with: 80 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 81 | tags: | 82 | type=ref,event=pr 83 | type=sha,prefix= 84 | type=raw,value=latest,enable={{is_default_branch}} 85 | 86 | # Build and push Docker image with Buildx (don't push on PR) 87 | # https://github.com/docker/build-push-action 88 | - name: Build and push Docker image 89 | id: build-and-push 90 | uses: docker/build-push-action@v6 91 | with: 92 | context: . 93 | push: true 94 | provenance: false 95 | tags: ${{ steps.meta.outputs.tags }} 96 | labels: ${{ steps.meta.outputs.labels }} 97 | cache-from: type=gha 98 | cache-to: type=gha,mode=max 99 | 100 | # Sign the resulting Docker image digest except on PRs. 101 | # This will only write to the public Rekor transparency log when the Docker 102 | # repository is public to avoid leaking data. If you would like to publish 103 | # transparency data even for private images, pass --force to cosign below. 104 | # https://github.com/sigstore/cosign 105 | - name: Sign the published Docker image 106 | env: 107 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 108 | TAGS: ${{ steps.meta.outputs.tags }} 109 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 110 | # This step uses the identity token to provision an ephemeral certificate 111 | # against the sigstore community Fulcio instance. 112 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}" 113 | 114 | 115 | conclusion: 116 | 117 | runs-on: ubuntu-latest 118 | needs: build 119 | name: build conclusion 120 | steps: 121 | - run: 'true' 122 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift CQ 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | versions: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | swift-image: ${{ steps.swift-image.outputs.version }} 17 | swift-format: ${{ steps.swift-format.outputs.version }} 18 | steps: 19 | - name: swift 20 | id: swift-image 21 | run: echo "version=6.0-noble" >> $GITHUB_OUTPUT 22 | - name: swift-format 23 | id: swift-format 24 | run: echo "version=600.0.0" >> $GITHUB_OUTPUT 25 | 26 | install-swift-format: 27 | needs: versions 28 | runs-on: ubuntu-latest 29 | container: swift:${{ needs.versions.outputs.swift-image }} 30 | 31 | steps: 32 | - name: Restore build cache 33 | uses: actions/cache@v4 34 | id: cache 35 | with: 36 | path: ./vendor/swift-format/.build 37 | key: ${{ runner.os }}-swift-format-${{ needs.versions.outputs.swift-format }} 38 | 39 | - name: Checkout apple/swift-format 40 | if: steps.cache.outputs.cache-hit != 'true' 41 | uses: actions/checkout@v4 42 | with: 43 | repository: apple/swift-format 44 | ref: ${{ needs.versions.outputs.swift-format }} 45 | path: vendor/swift-format 46 | 47 | - name: Build swift-format 48 | if: steps.cache.outputs.cache-hit != 'true' 49 | working-directory: ./vendor/swift-format 50 | run: | 51 | swift package resolve 52 | swift build -c release 53 | echo "${PWD}/.build/release" >> $GITHUB_PATH 54 | 55 | 56 | lint: 57 | needs: [versions, install-swift-format] 58 | runs-on: ubuntu-latest 59 | container: 60 | image: swift:${{ needs.versions.outputs.swift-image }} 61 | volumes: 62 | - /usr/bin/curl:/usr/bin/curl 63 | permissions: 64 | contents: read 65 | pull-requests: write 66 | 67 | steps: 68 | - uses: actions/checkout@v4 69 | 70 | - run: git config --global --add safe.directory "${{ github.workspace }}" 71 | 72 | - name: Restore build cache 73 | uses: actions/cache@v4 74 | with: 75 | path: ./vendor/swift-format/.build 76 | key: ${{ runner.os }}-swift-format-${{ needs.versions.outputs.swift-format }} 77 | 78 | - name: Install swift-format 79 | run: | 80 | echo "${PWD}/vendor/swift-format/.build/release" >> $GITHUB_PATH 81 | 82 | - uses: reviewdog/action-setup@v1 83 | with: 84 | reviewdog_version: latest 85 | - uses: haya14busa/action-cond@v1 86 | id: reporter 87 | with: 88 | cond: ${{ github.event_name == 'pull_request' }} 89 | if_true: "github-pr-review" 90 | if_false: "github-check" 91 | - uses: haya14busa/action-cond@v1 92 | id: filter-mode 93 | with: 94 | cond: ${{ github.event_name == 'pull_request' }} 95 | if_true: "file" 96 | if_false: "nofilter" 97 | - name: Run swift-format lint with reviewdog 98 | env: 99 | REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }} 100 | run: | 101 | reviewdog -reporter=${{ steps.reporter.outputs.value }} -runners=swift-format-lint -filter-mode=${{ steps.filter-mode.outputs.value }} -fail-on-error 102 | 103 | 104 | format: 105 | needs: [versions, install-swift-format] 106 | runs-on: ubuntu-latest 107 | container: 108 | image: swift:${{ needs.versions.outputs.swift-image }} 109 | volumes: 110 | - /usr/bin/curl:/usr/bin/curl 111 | if: github.event_name == 'pull_request' 112 | permissions: 113 | contents: read 114 | pull-requests: write 115 | 116 | steps: 117 | - uses: actions/checkout@v4 118 | 119 | - run: git config --global --add safe.directory "${GITHUB_WORKSPACE}" 120 | 121 | - name: Restore build cache 122 | uses: actions/cache@v4 123 | with: 124 | path: ./vendor/swift-format/.build 125 | key: ${{ runner.os }}-swift-format-${{ needs.versions.outputs.swift-format }} 126 | 127 | - name: Install swift-format 128 | run: | 129 | echo "${PWD}/vendor/swift-format/.build/release" >> $GITHUB_PATH 130 | 131 | - name: Run format 132 | run: swift format -ipr Sources/ Package.swift 133 | 134 | - name: Reviewdog suggester / mix format 135 | uses: reviewdog/action-suggester@v1 136 | with: 137 | tool_name: swift-format 138 | filter_mode: file 139 | fail_on_error: true 140 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Swift Package Manager 2 | # 3 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 4 | # Packages/ 5 | # Package.pins 6 | # Package.resolved 7 | # *.xcodeproj 8 | # 9 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 10 | # hence it is not needed unless you have added a package configuration file to your project 11 | .swiftpm 12 | 13 | .build/ 14 | 15 | # contains only example 16 | /compose.*.yml 17 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | kPherox 2 | -------------------------------------------------------------------------------- /.reviewdog.yml: -------------------------------------------------------------------------------- 1 | runner: 2 | swift-format-lint: 3 | cmd: swift format lint -spr Sources/ Package.swift 4 | errorformat: 5 | - "%f:%l:%c: %trror: %m" 6 | - "%f:%l:%c: %tarning: %m" 7 | - "%f:%l:%c: %tote: %m" 8 | level: warning 9 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "maximumBlankLines": 1, 4 | "lineLength": 100, 5 | "spacesBeforeEndOfLineComments": 1, 6 | "tabWidth": 8, 7 | "indentation": { 8 | "spaces": 2 9 | }, 10 | "respectsExistingLineBreaks": true, 11 | "lineBreakBeforeControlFlowKeywords": false, 12 | "lineBreakBeforeEachArgument": false, 13 | "lineBreakBeforeEachGenericRequirement": false, 14 | "lineBreakBetweenDeclarationAttributes": false, 15 | "prioritizeKeepingFunctionOutputTogether": false, 16 | "indentConditionalCompilationBlocks": true, 17 | "lineBreakAroundMultilineExpressionChainComponents": true, 18 | "fileScopedDeclarationPrivacy": { 19 | "accessLevel": "private" 20 | }, 21 | "indentSwitchCaseLabels": true, 22 | "rules": { 23 | "AllPublicDeclarationsHaveDocumentation": false, 24 | "AlwaysUseLiteralForEmptyCollectionInit": false, 25 | "AlwaysUseLowerCamelCase": true, 26 | "AmbiguousTrailingClosureOverload": true, 27 | "BeginDocumentationCommentWithOneLineSummary": false, 28 | "DoNotUseSemicolons": true, 29 | "DontRepeatTypeInStaticProperties": true, 30 | "FileScopedDeclarationPrivacy": true, 31 | "FullyIndirectEnum": true, 32 | "GroupNumericLiterals": true, 33 | "IdentifiersMustBeASCII": true, 34 | "NeverForceUnwrap": false, 35 | "NeverUseForceTry": false, 36 | "NeverUseImplicitlyUnwrappedOptionals": false, 37 | "NoAccessLevelOnExtensionDeclaration": true, 38 | "NoAssignmentInExpressions": true, 39 | "NoBlockComments": true, 40 | "NoCasesWithOnlyFallthrough": true, 41 | "NoEmptyTrailingClosureParentheses": true, 42 | "NoLabelsInCasePatterns": true, 43 | "NoLeadingUnderscores": false, 44 | "NoParensAroundConditions": true, 45 | "NoPlaygroundLiterals": true, 46 | "NoVoidReturnOnFunctionSignature": true, 47 | "OmitExplicitReturns": false, 48 | "OneCasePerLine": true, 49 | "OneVariableDeclarationPerLine": true, 50 | "OnlyOneTrailingClosureArgument": true, 51 | "OrderedImports": true, 52 | "ReplaceForEachWithForLoop": true, 53 | "ReturnVoidInsteadOfEmptyTuple": true, 54 | "TypeNamesShouldBeCapitalized": true, 55 | "UseEarlyExits": false, 56 | "UseExplicitNilCheckInConditions": true, 57 | "UseLetInEveryBoundCaseVariable": true, 58 | "UseShorthandTypeNames": true, 59 | "UseSingleLinePropertyGetter": true, 60 | "UseSynthesizedInitializer": true, 61 | "UseTripleSlashForDocumentationComments": true, 62 | "UseWhereClausesInForLoops": false, 63 | "ValidateDocumentationComments": false 64 | }, 65 | "spacesAroundRangeFormationOperators": false, 66 | "noAssignmentInExpressions": { 67 | "allowedFunctions": [ 68 | "XCTAssertNoThrow" 69 | ] 70 | }, 71 | "multiElementCollectionTrailingCommas": true, 72 | "reflowMultilineStringLiterals": "never" 73 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM swift:6.0-noble AS build 5 | 6 | # Install OS updates 7 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 8 | && apt-get -q update \ 9 | && apt-get -q dist-upgrade -y \ 10 | && apt-get install -y libjemalloc-dev \ 11 | && rm -rf /var/lib/apt/lists/* 12 | 13 | # Set up a build area 14 | WORKDIR /build 15 | 16 | # First just resolve dependencies. 17 | # This creates a cached layer that can be reused 18 | # as long as your Package.swift/Package.resolved 19 | # files do not change. 20 | COPY ./Package.* ./ 21 | RUN swift package resolve \ 22 | $([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true) 23 | 24 | # Copy entire repo into container 25 | COPY . . 26 | 27 | # Build everything, with optimizations, with static linking, and using jemalloc 28 | # N.B.: The static version of jemalloc is incompatible with the static Swift runtime. 29 | RUN swift build -c release \ 30 | --static-swift-stdlib \ 31 | -Xlinker -ljemalloc 32 | 33 | # Switch to the staging area 34 | WORKDIR /staging 35 | 36 | # Copy main executable to staging area 37 | RUN cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./ 38 | 39 | # Copy static swift backtracer binary to staging area 40 | RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ 41 | 42 | # Copy resources bundled by SPM to staging area 43 | RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; 44 | 45 | # Copy any resources from the public directory and views directory if the directories exist 46 | # Ensure that by default, neither the directory nor any of its contents are writable. 47 | RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true 48 | RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true 49 | 50 | # ================================ 51 | # Run image 52 | # ================================ 53 | FROM ubuntu:noble 54 | 55 | # Make sure all system packages are up to date, and install only essential packages. 56 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 57 | && apt-get -q update \ 58 | && apt-get -q dist-upgrade -y \ 59 | && apt-get -q install -y \ 60 | libjemalloc2 \ 61 | ca-certificates \ 62 | tzdata \ 63 | # If your app or its dependencies import FoundationNetworking, also install `libcurl4`. 64 | # libcurl4 \ 65 | # If your app or its dependencies import FoundationXML, also install `libxml2`. 66 | # libxml2 \ 67 | && rm -r /var/lib/apt/lists/* 68 | 69 | # Create a vapor user and group with /app as its home directory 70 | RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor 71 | 72 | # Switch to the new home directory 73 | WORKDIR /app 74 | 75 | # Copy built executable and any staged resources from builder 76 | COPY --from=build --chown=vapor:vapor /staging /app 77 | 78 | # Provide configuration needed by the built-in crash reporter and some sensible default behaviors. 79 | ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static 80 | 81 | # Ensure all further commands run as the vapor user 82 | USER vapor:vapor 83 | 84 | # Let Docker bind to port 8080 85 | EXPOSE 8080 86 | 87 | # Start the Vapor service when the image is run, default to listening on 8080 in production environment 88 | ENTRYPOINT ["./App"] 89 | CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 kPherox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "3a1d71602810daa509cff82a1dff245741d8f5c35a16d6a1dd7bb48ece6e3ecd", 3 | "pins" : [ 4 | { 5 | "identity" : "async-http-client", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/swift-server/async-http-client.git", 8 | "state" : { 9 | "revision" : "0a9b72369b9d87ab155ef585ef50700a34abf070", 10 | "version" : "1.23.1" 11 | } 12 | }, 13 | { 14 | "identity" : "async-kit", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/vapor/async-kit.git", 17 | "state" : { 18 | "revision" : "e048c8ee94967e8d8a1c2ec0e1156d6f7fa34d31", 19 | "version" : "1.20.0" 20 | } 21 | }, 22 | { 23 | "identity" : "console-kit", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/vapor/console-kit.git", 26 | "state" : { 27 | "revision" : "78c0dd739df8cb9ee14a8bbbf770facc4fc3402a", 28 | "version" : "4.15.0" 29 | } 30 | }, 31 | { 32 | "identity" : "fluent", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/vapor/fluent.git", 35 | "state" : { 36 | "revision" : "223b27d04ab2b51c25503c9922eecbcdf6c12f89", 37 | "version" : "4.12.0" 38 | } 39 | }, 40 | { 41 | "identity" : "fluent-kit", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/vapor/fluent-kit.git", 44 | "state" : { 45 | "revision" : "614d3ec27cdef50cfb9fc3cfd382b6a4d9578cff", 46 | "version" : "1.49.0" 47 | } 48 | }, 49 | { 50 | "identity" : "fluent-postgres-driver", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/vapor/fluent-postgres-driver.git", 53 | "state" : { 54 | "revision" : "095bc5a17ab3363167f4becb270b6f8eb790481c", 55 | "version" : "2.10.1" 56 | } 57 | }, 58 | { 59 | "identity" : "leaf", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/vapor/leaf.git", 62 | "state" : { 63 | "revision" : "bf48d2423c00292b5937c60166c7db99705cae47", 64 | "version" : "4.4.1" 65 | } 66 | }, 67 | { 68 | "identity" : "leaf-kit", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/vapor/leaf-kit.git", 71 | "state" : { 72 | "revision" : "d0ca4417166ef7868d28ad21bc77d36b8735a0fc", 73 | "version" : "1.11.1" 74 | } 75 | }, 76 | { 77 | "identity" : "multipart-kit", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/vapor/multipart-kit.git", 80 | "state" : { 81 | "revision" : "a31236f24bfd2ea2f520a74575881f6731d7ae68", 82 | "version" : "4.7.0" 83 | } 84 | }, 85 | { 86 | "identity" : "postgres-kit", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/vapor/postgres-kit.git", 89 | "state" : { 90 | "revision" : "0b72fa83b1023c4b82072e4049a3db6c29781fff", 91 | "version" : "2.13.5" 92 | } 93 | }, 94 | { 95 | "identity" : "postgres-nio", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/vapor/postgres-nio.git", 98 | "state" : { 99 | "revision" : "cd5318a01a1efcb1e0b3c82a0ce5c9fefaf1cb2d", 100 | "version" : "1.22.1" 101 | } 102 | }, 103 | { 104 | "identity" : "queues", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/vapor/queues.git", 107 | "state" : { 108 | "revision" : "acdf38bd352fc6b31e2401c92c05888faf1e86b1", 109 | "version" : "1.17.1" 110 | } 111 | }, 112 | { 113 | "identity" : "queues-redis-driver", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/vapor/queues-redis-driver.git", 116 | "state" : { 117 | "revision" : "a3dac0d311cead67917ad4221feb061e3609b145", 118 | "version" : "1.1.2" 119 | } 120 | }, 121 | { 122 | "identity" : "redis", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/vapor/redis.git", 125 | "state" : { 126 | "revision" : "383ed57e5b5acd4dd447c2967aa665dc3bbe5be9", 127 | "version" : "4.11.0" 128 | } 129 | }, 130 | { 131 | "identity" : "redistack", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/swift-server/RediStack.git", 134 | "state" : { 135 | "revision" : "622ce440f90d79b58e45f3a3efdd64c51d1dfd17", 136 | "version" : "1.6.2" 137 | } 138 | }, 139 | { 140 | "identity" : "routing-kit", 141 | "kind" : "remoteSourceControl", 142 | "location" : "https://github.com/vapor/routing-kit.git", 143 | "state" : { 144 | "revision" : "8c9a227476555c55837e569be71944e02a056b72", 145 | "version" : "4.9.1" 146 | } 147 | }, 148 | { 149 | "identity" : "sql-kit", 150 | "kind" : "remoteSourceControl", 151 | "location" : "https://github.com/vapor/sql-kit.git", 152 | "state" : { 153 | "revision" : "e0b35ff07601465dd9f3af19a1c23083acaae3bd", 154 | "version" : "3.32.0" 155 | } 156 | }, 157 | { 158 | "identity" : "swift-algorithms", 159 | "kind" : "remoteSourceControl", 160 | "location" : "https://github.com/apple/swift-algorithms.git", 161 | "state" : { 162 | "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", 163 | "version" : "1.2.0" 164 | } 165 | }, 166 | { 167 | "identity" : "swift-asn1", 168 | "kind" : "remoteSourceControl", 169 | "location" : "https://github.com/apple/swift-asn1.git", 170 | "state" : { 171 | "revision" : "7faebca1ea4f9aaf0cda1cef7c43aecd2311ddf6", 172 | "version" : "1.3.0" 173 | } 174 | }, 175 | { 176 | "identity" : "swift-async-algorithms", 177 | "kind" : "remoteSourceControl", 178 | "location" : "https://github.com/apple/swift-async-algorithms.git", 179 | "state" : { 180 | "revision" : "5c8bd186f48c16af0775972700626f0b74588278", 181 | "version" : "1.0.2" 182 | } 183 | }, 184 | { 185 | "identity" : "swift-atomics", 186 | "kind" : "remoteSourceControl", 187 | "location" : "https://github.com/apple/swift-atomics.git", 188 | "state" : { 189 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985", 190 | "version" : "1.2.0" 191 | } 192 | }, 193 | { 194 | "identity" : "swift-collections", 195 | "kind" : "remoteSourceControl", 196 | "location" : "https://github.com/apple/swift-collections.git", 197 | "state" : { 198 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 199 | "version" : "1.1.4" 200 | } 201 | }, 202 | { 203 | "identity" : "swift-crypto", 204 | "kind" : "remoteSourceControl", 205 | "location" : "https://github.com/apple/swift-crypto.git", 206 | "state" : { 207 | "revision" : "21f7878f2b39d46fd8ba2b06459ccb431cdf876c", 208 | "version" : "3.8.1" 209 | } 210 | }, 211 | { 212 | "identity" : "swift-http-types", 213 | "kind" : "remoteSourceControl", 214 | "location" : "https://github.com/apple/swift-http-types", 215 | "state" : { 216 | "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", 217 | "version" : "1.3.0" 218 | } 219 | }, 220 | { 221 | "identity" : "swift-log", 222 | "kind" : "remoteSourceControl", 223 | "location" : "https://github.com/apple/swift-log.git", 224 | "state" : { 225 | "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", 226 | "version" : "1.6.1" 227 | } 228 | }, 229 | { 230 | "identity" : "swift-metrics", 231 | "kind" : "remoteSourceControl", 232 | "location" : "https://github.com/apple/swift-metrics.git", 233 | "state" : { 234 | "revision" : "e0165b53d49b413dd987526b641e05e246782685", 235 | "version" : "2.5.0" 236 | } 237 | }, 238 | { 239 | "identity" : "swift-nio", 240 | "kind" : "remoteSourceControl", 241 | "location" : "https://github.com/apple/swift-nio.git", 242 | "state" : { 243 | "revision" : "f7dc3f527576c398709b017584392fb58592e7f5", 244 | "version" : "2.75.0" 245 | } 246 | }, 247 | { 248 | "identity" : "swift-nio-extras", 249 | "kind" : "remoteSourceControl", 250 | "location" : "https://github.com/apple/swift-nio-extras.git", 251 | "state" : { 252 | "revision" : "2e9746cfc57554f70b650b021b6ae4738abef3e6", 253 | "version" : "1.24.1" 254 | } 255 | }, 256 | { 257 | "identity" : "swift-nio-http2", 258 | "kind" : "remoteSourceControl", 259 | "location" : "https://github.com/apple/swift-nio-http2.git", 260 | "state" : { 261 | "revision" : "eaa71bb6ae082eee5a07407b1ad0cbd8f48f9dca", 262 | "version" : "1.34.1" 263 | } 264 | }, 265 | { 266 | "identity" : "swift-nio-ssl", 267 | "kind" : "remoteSourceControl", 268 | "location" : "https://github.com/apple/swift-nio-ssl.git", 269 | "state" : { 270 | "revision" : "d7ceaf0e4d8001cd35cdc12e42cdd281e9e564e8", 271 | "version" : "2.28.0" 272 | } 273 | }, 274 | { 275 | "identity" : "swift-nio-transport-services", 276 | "kind" : "remoteSourceControl", 277 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 278 | "state" : { 279 | "revision" : "dbace16f126fdcd80d58dc54526c561ca17327d7", 280 | "version" : "1.22.0" 281 | } 282 | }, 283 | { 284 | "identity" : "swift-numerics", 285 | "kind" : "remoteSourceControl", 286 | "location" : "https://github.com/apple/swift-numerics", 287 | "state" : { 288 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 289 | "version" : "1.0.2" 290 | } 291 | }, 292 | { 293 | "identity" : "swift-service-lifecycle", 294 | "kind" : "remoteSourceControl", 295 | "location" : "https://github.com/swift-server/swift-service-lifecycle.git", 296 | "state" : { 297 | "revision" : "24c800fb494fbee6e42bc156dc94232dc08971af", 298 | "version" : "2.6.1" 299 | } 300 | }, 301 | { 302 | "identity" : "swift-system", 303 | "kind" : "remoteSourceControl", 304 | "location" : "https://github.com/apple/swift-system.git", 305 | "state" : { 306 | "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", 307 | "version" : "1.4.0" 308 | } 309 | }, 310 | { 311 | "identity" : "vapor", 312 | "kind" : "remoteSourceControl", 313 | "location" : "https://github.com/vapor/vapor.git", 314 | "state" : { 315 | "revision" : "fb619ab6485a88787ef9c78ba70e7415f8ebf981", 316 | "version" : "4.106.4" 317 | } 318 | }, 319 | { 320 | "identity" : "websocket-kit", 321 | "kind" : "remoteSourceControl", 322 | "location" : "https://github.com/vapor/websocket-kit.git", 323 | "state" : { 324 | "revision" : "4232d34efa49f633ba61afde365d3896fc7f8740", 325 | "version" : "2.15.0" 326 | } 327 | } 328 | ], 329 | "version" : 3 330 | } 331 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "plc-handle-tracker", 8 | platforms: [ 9 | .macOS(.v13) 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), 13 | .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"), 14 | .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"), 15 | .package(url: "https://github.com/vapor/queues.git", from: "1.15.0"), 16 | .package(url: "https://github.com/vapor/queues-redis-driver.git", from: "1.0.0"), 17 | .package(url: "https://github.com/vapor/leaf.git", from: "4.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package, defining a module or a test suite. 21 | // Targets can depend on other targets in this package and products from dependencies. 22 | .executableTarget( 23 | name: "App", 24 | dependencies: [ 25 | .product(name: "Fluent", package: "fluent"), 26 | .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), 27 | .product(name: "QueuesRedisDriver", package: "queues-redis-driver"), 28 | .product(name: "Leaf", package: "leaf"), 29 | .product(name: "Vapor", package: "vapor"), 30 | ], 31 | path: "Sources", 32 | swiftSettings: [ 33 | // Enable better optimizations when building in Release configuration. Despite the use of 34 | // the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release 35 | // builds. See for details. 36 | .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) 37 | ] 38 | ) 39 | ] 40 | ) 41 | -------------------------------------------------------------------------------- /Public/scripts/profile-view.js: -------------------------------------------------------------------------------- 1 | function getBlobLink(did, blob, baseUrl = 'https://bsky.social') { 2 | if (blob == null) return null 3 | 4 | const src = new URL('/xrpc/com.atproto.sync.getBlob', baseUrl); 5 | src.searchParams.set('did', did); 6 | 7 | if (blob.$type === 'blob') { 8 | src.searchParams.set('cid', blob.ref.$link); 9 | } else { 10 | src.searchParams.set('cid', blob.cid); 11 | } 12 | 13 | return { 14 | src, 15 | mimeType: blob.mimeType, 16 | } 17 | }; 18 | 19 | export async function getRecord(did, baseUrl) { 20 | const url = new URL('/xrpc/com.atproto.repo.getRecord', baseUrl); 21 | url.searchParams.set('repo', did); 22 | url.searchParams.set('collection', 'app.bsky.actor.profile'); 23 | url.searchParams.set('rkey', 'self'); 24 | 25 | const body = await fetch(url).then(res => { 26 | const json = res.json(); 27 | if (res.status >= 300) { 28 | throw json 29 | } 30 | return json 31 | }); 32 | 33 | const { 34 | displayName, 35 | description, 36 | avatar, 37 | banner, 38 | } = body.value; 39 | 40 | return { 41 | displayName, 42 | description, 43 | avatar: getBlobLink(did, avatar, baseUrl), 44 | banner: getBlobLink(did, banner, baseUrl), 45 | } 46 | }; 47 | 48 | export async function describeRepo(repo, baseUrl) { 49 | const url = new URL('/xrpc/com.atproto.repo.describeRepo', baseUrl); 50 | url.searchParams.set('repo', repo); 51 | 52 | const body = await fetch(url).then(res => { 53 | const json = res.json(); 54 | if (res.status >= 300) { 55 | throw json 56 | } 57 | return json 58 | }); 59 | 60 | const { 61 | handle, 62 | did, 63 | didDoc, 64 | collections, 65 | handleIsCorrect, 66 | } = body; 67 | 68 | return { 69 | did: did, 70 | handle: handle, 71 | baseUrl: didDoc.service.find(x => x.type === 'AtprotoPersonalDataServer')?.serviceEndpoint ?? baseUrl, 72 | hasProfile: collections.includes('app.bsky.actor.profile'), 73 | handleIsCorrect, 74 | } 75 | } 76 | 77 | export class ProfileModal extends HTMLElement { 78 | constructor() { 79 | super(); 80 | 81 | const shadowRoot = this.attachShadow({mode: 'open', slotAssignment: 'manual'}); 82 | 83 | const style = shadowRoot.appendChild(document.createElement('style')); 84 | 85 | style.textContent = ` 86 | :host { 87 | display: none; 88 | } 89 | :host([open]) { 90 | display: block; 91 | position: fixed; 92 | z-index: 10000; 93 | inset: 0; 94 | background: rgb(222 222 222 / .8); 95 | } 96 | :host > div { 97 | margin: 10vh 10vw; 98 | padding: 1rem; 99 | display: grid; 100 | grid-template-columns: 6rem calc(80vw - 9rem); 101 | gap: 1rem; 102 | background: #fff; 103 | } 104 | @media (prefers-color-scheme: dark) { 105 | :host([open]) { 106 | background: rgb(32 32 32 / .8); 107 | } 108 | :host > div { 109 | background: #333; 110 | } 111 | } 112 | slot[name=banner]::slotted(img) { 113 | grid-column: span 2; 114 | width: 100%; 115 | max-height: 24rem; 116 | object-fit: cover; 117 | } 118 | slot[name=avatar]::slotted(img) { 119 | grid-column: 1; 120 | max-width: 6rem; 121 | max-height: 6rem; 122 | object-fit: scale-down; 123 | } 124 | dl { 125 | grid-column: 2; 126 | display: grid; 127 | grid-template-columns: 4rem 1fr; 128 | gap: .5rem 1rem; 129 | } 130 | dl > dt { 131 | font-weight: bold; 132 | text-align: right; 133 | grid-column-start: 1; 134 | } 135 | dl > dd { 136 | margin: 0; 137 | grid-column-start: 2; 138 | } 139 | `; 140 | 141 | const div = shadowRoot.appendChild(document.createElement('div')); 142 | this.bannerSlot = div.appendChild(document.createElement('slot')); 143 | this.bannerSlot.name = 'banner' 144 | this.avatarSlot = div.appendChild(document.createElement('slot')); 145 | this.avatarSlot.name = 'avatar' 146 | const dl = div.appendChild(document.createElement('dl')); 147 | const name = dl.appendChild(document.createElement('dt')); 148 | name.textContent = 'Name'; 149 | this.displayNameSlot = dl.appendChild(document.createElement('slot')); 150 | const bio = dl.appendChild(document.createElement('dt')); 151 | bio.textContent = 'Bio'; 152 | this.descriptionSlot = dl.appendChild(document.createElement('slot')); 153 | 154 | this.displayName = document.createElement('dd'); 155 | this.description = document.createElement('dd'); 156 | this.avatar = new Image(); 157 | this.avatar.alt = 'avatar'; 158 | this.banner = new Image(); 159 | this.banner.alt = 'banner'; 160 | } 161 | 162 | connectedCallback() { 163 | this.append(this.displayName, this.description, this.avatar, this.banner); 164 | this.addEventListener('click', () => this.close()); 165 | } 166 | 167 | open({displayName, description, avatar, banner}) { 168 | if (displayName == null) { 169 | this.displayNameSlot.assign(); 170 | } else { 171 | this.displayName.textContent = displayName; 172 | this.displayNameSlot.assign(this.displayName); 173 | } 174 | if (description == null) { 175 | this.descriptionSlot.assign(); 176 | } else { 177 | this.description.textContent = description; 178 | this.descriptionSlot.assign(this.description); 179 | } 180 | if (avatar == null) { 181 | this.avatarSlot.assign(); 182 | } else { 183 | this.avatar.src = avatar.src; 184 | this.avatarSlot.assign(this.avatar); 185 | } 186 | if (banner == null) { 187 | this.bannerSlot.assign(); 188 | } else { 189 | this.banner.src = banner.src; 190 | this.bannerSlot.assign(this.banner); 191 | } 192 | this.setAttribute('open', ''); 193 | } 194 | 195 | close() { 196 | this.removeAttribute('open'); 197 | } 198 | } 199 | window.customElements.define('profile-modal', ProfileModal); 200 | -------------------------------------------------------------------------------- /Public/styles/base.css: -------------------------------------------------------------------------------- 1 | /* layout */ 2 | body { 3 | display: flex; 4 | flex-direction: column; 5 | min-height: 100vh; 6 | margin: 0 auto; 7 | width: max-content; 8 | max-width: 100vw; 9 | } 10 | body > header { 11 | padding: 16px; 12 | } 13 | body > header > #app-title { 14 | margin: 0 48px; 15 | } 16 | body > header > nav { 17 | margin: 10px 0 0; 18 | } 19 | body > header > nav > p { 20 | margin: 0 18px; 21 | } 22 | body > header > nav > ul { 23 | display: flex; 24 | justify-content: center; 25 | gap: 1rem; 26 | list-style: none; 27 | padding: 0; 28 | margin: 0 auto; 29 | width: max-content; 30 | max-width: 320px; 31 | } 32 | 33 | main { 34 | flex: auto; 35 | padding: 8px; 36 | } 37 | main > #page-title { 38 | margin-top: 0; 39 | } 40 | 41 | body > footer { 42 | padding: 16px; 43 | } 44 | #license { 45 | margin: 0; 46 | } 47 | 48 | /* color */ 49 | :root { 50 | background: #fff; 51 | color: #242424; 52 | } 53 | 54 | a { 55 | text-decoration: none; 56 | color: #0068da; 57 | } 58 | a:visited { 59 | color: #ad52de; 60 | } 61 | a:active { 62 | color: #ee0000; 63 | } 64 | 65 | body > header > #app-title > a { 66 | color: currentColor; 67 | } 68 | 69 | @media (prefers-color-scheme: dark) { 70 | :root { 71 | background: #000; 72 | color: #dbdbdb; 73 | } 74 | 75 | a { 76 | color: #ff9724; 77 | } 78 | a:visited { 79 | color: #4fad20; 80 | } 81 | a:active { 82 | color: #11ffff; 83 | } 84 | } 85 | 86 | /* typography */ 87 | :root { 88 | font-family: sans-serif; 89 | } 90 | 91 | body > header > #app-title { 92 | font-size: 1.5rem; 93 | font-weight: normal; 94 | text-align: center; 95 | white-space: nowrap; 96 | } 97 | body > header > nav > p { 98 | text-align: center; 99 | } 100 | 101 | #page-title { 102 | font-size: 1.6rem; 103 | text-align: center; 104 | white-space: nowrap; 105 | } 106 | 107 | #license { 108 | text-align: center; 109 | } 110 | 111 | .handle, .did-plc { 112 | font-family: monospace, serif; 113 | } 114 | 115 | /* github-corner */ 116 | #source-code { 117 | margin: 0; 118 | width: 0; 119 | height: 0; 120 | overflow: hidden; 121 | } 122 | 123 | .github-corner svg { 124 | fill: #151513; 125 | color: #fff; 126 | position: absolute; 127 | top: 0; 128 | border: 0; 129 | left: 0; 130 | transform: scale(-1, 1); 131 | clip-path: polygon(0 0, 100% 0, 100% 100%); 132 | } 133 | 134 | @media (prefers-color-scheme: dark) { 135 | .github-corner svg { 136 | fill: #fff; 137 | color: #151513; 138 | } 139 | } 140 | 141 | .github-corner .octo-arm { 142 | transform-origin: 130px 106px; 143 | } 144 | 145 | .github-corner:hover .octo-arm { 146 | animation: octocat-wave 560ms ease-in-out; 147 | } 148 | 149 | @keyframes octocat-wave { 150 | 0%, 100% { 151 | transform: rotate(0); 152 | } 153 | 154 | 20%, 60% { 155 | transform: rotate(-25deg); 156 | } 157 | 158 | 40%, 80% { 159 | transform: rotate(10deg); 160 | } 161 | } 162 | 163 | @media (max-width: 500px) { 164 | .github-corner:hover .octo-arm { 165 | animation: none; 166 | } 167 | 168 | .github-corner .octo-arm { 169 | animation: octocat-wave 560ms ease-in-out; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Public/styles/status.css: -------------------------------------------------------------------------------- 1 | h2 { 2 | margin-top: 3rem; 3 | margin-bottom: 1rem; 4 | font-size: 1.3rem; 5 | text-align: center; 6 | } 7 | 8 | dl, table { 9 | margin: 0 auto; 10 | width: max-content; 11 | max-width: calc(100vw - 16px); 12 | } 13 | 14 | dl { 15 | display: grid; 16 | grid-template-columns: 5rem 1fr; 17 | gap: .5rem 1rem; 18 | } 19 | dl > dt { 20 | font-weight: bold; 21 | text-align: right; 22 | grid-column-start: 1; 23 | } 24 | dl > dd { 25 | margin: 0; 26 | white-space: nowrap; 27 | overflow: hidden; 28 | text-overflow: ellipsis; 29 | grid-column-start: 2; 30 | } 31 | p { 32 | text-align: center; 33 | } 34 | 35 | table { 36 | display: block; 37 | overflow-x: auto; 38 | } 39 | table > caption { 40 | max-width: calc(100vw - 16px); 41 | position: sticky; 42 | top: 0; 43 | left: 0; 44 | } 45 | th, td { 46 | padding: 5px; 47 | } 48 | 49 | tbody { 50 | white-space: nowrap; 51 | } 52 | 53 | tbody th { 54 | text-align: left; 55 | } 56 | 57 | tbody td { 58 | min-width: 200px; 59 | text-align: center; 60 | line-height: 1.2rem; 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # plc-handle-tracker 2 | did:plc & atproto handle tracker 3 | -------------------------------------------------------------------------------- /Resources/Views/did/index.leaf: -------------------------------------------------------------------------------- 1 | #extend("templates/search"): 2 | #export("kind"): DIDs #endexport 3 | 4 | #export("form"): 5 |
6 | 15 | 16 |
17 | #endexport 18 | #endextend 19 | -------------------------------------------------------------------------------- /Resources/Views/did/show.leaf: -------------------------------------------------------------------------------- 1 | #extend("templates/status"): 2 | #export("current_info"): 3 | #if(current): 4 |
5 |
Server
6 |
#externalLink(current.pds)
7 |
Handle
8 |
@#(current.handle)
9 |
10 | #else:

Tombstone

11 | #endif 12 | #endexport 13 | 14 | #export("history_heading"): Handle History #endexport 15 | 16 | #export("history_table_head"): 17 | 18 | Handle 19 | Used since 20 | 21 | #endexport 22 | 23 | #export("history_table_body"): 24 | #for(operation in operations): 25 | 26 | 27 | #if(operation.handle): @#(operation.handle) 28 | #else: tombstone 29 | #endif 30 | 31 | #date(operation.createdAt, "yyyy-MM-dd HH:mm:ss z") 32 | 33 | #endfor 34 | #endexport 35 | #endextend 36 | -------------------------------------------------------------------------------- /Resources/Views/error/default.leaf: -------------------------------------------------------------------------------- 1 | #extend("templates/error"): 2 | #export("info"): 3 |

4 | #if(reason): #(reason) 5 | #else: Error code: Is 4xx, check URL. Is 5xx, try again later. 6 | #endif 7 |

8 | #endexport 9 | #endextend 10 | -------------------------------------------------------------------------------- /Resources/Views/handle/index.leaf: -------------------------------------------------------------------------------- 1 | #extend("templates/search"): 2 | #export("kind"): handles #endexport 3 | 4 | #export("form"): 5 |
6 | 15 | 16 |
17 | #endexport 18 | 19 | #export("result"): 20 | #if(count(result) != 0): 21 |
    22 | #for(handle in result): 23 |
  • @#(handle)
  • 24 | #endfor 25 |
26 | #endif 27 | #endexport 28 | #endextend 29 | -------------------------------------------------------------------------------- /Resources/Views/handle/show.leaf: -------------------------------------------------------------------------------- 1 | #extend("templates/status"): 2 | #export("current_info"): 3 | #if(count(current) != 0): 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | #for(op in current): 13 | 14 | 15 | 17 | #endfor 18 | 19 |
Personal Data ServerDID
#externalLink(op.pds)#(op.did) 16 |
20 | 41 | 128 | #else:

Currently, not used

129 | #endif 130 | #endexport 131 | 132 | #export("history_heading"): DID History #endexport 133 | 134 | #export("history_table_head"): 135 | 136 | DID 137 | Used since 138 | Used until 139 | 140 | #endexport 141 | 142 | #export("history_table_body"): 143 | #for(operation in operations): 144 | 145 | #(operation.did) 146 | #date(operation.createdAt, "yyyy-MM-dd HH:mm:ss z") 147 | 148 | #if(operation.updatedAt): #date(operation.updatedAt, "yyyy-MM-dd HH:mm:ss z") 149 | #else: - 150 | #endif 151 | 152 | 153 | #endfor 154 | #endexport 155 | #endextend 156 | -------------------------------------------------------------------------------- /Resources/Views/index.leaf: -------------------------------------------------------------------------------- 1 | #extend("templates/base"): 2 | #export("head"): 3 | 24 | #endexport 25 | 26 | #export("body"): 27 | #if(latestPolling): 28 |
29 |
Latest imported
30 |
31 | #if(latestPolling.createdAt): #date(latestPolling.createdAt, "yyyy-MM-dd HH:mm:ss z") 32 | #else: - 33 | #endif 34 |
35 |
Last import log
36 |
#date(latestPolling.insertedAt, "yyyy-MM-dd HH:mm:ss z")
37 |
38 | #endif 39 |

Example did:plc page: GET /did/did:plc:ragtjsm2j2vknwkz3zp4oxrd

40 |

Example @handle page: GET /handle/paul.bsky.social

41 | #endexport 42 | #endextend 43 | -------------------------------------------------------------------------------- /Resources/Views/templates/base.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | #if(title): #(title) | @handle and did:plc Tracker 9 | #else: @handle and did:plc Tracker 10 | #endif 11 | 12 | 13 | 14 | 15 | #import("head") 16 | 17 | 18 | 19 |
20 | #if(title): 21 |

#navLink("/", route): 22 | @handle and did:plc Tracker 23 | #endnavLink

24 | #else: 25 |

#navLink("/", route): 26 | @handle and did:plc Tracker 27 | #endnavLink

28 | #endif 29 | 36 |
37 |
38 | #if(title):

#(title)

#endif 39 | #import("body") 40 |
41 |
42 |

43 | #externalLink("https://github.com/kphrx/plc-handle-tracker", "View source on GitHub"): 44 | 49 | #endexternalLink 50 |

51 | 52 |

53 | (c) 2023 kphrx. 54 | #externalLink("https://github.com/kphrx/plc-handle-tracker/blob/master/LICENSE", "MIT licensed") 55 |

56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /Resources/Views/templates/error.leaf: -------------------------------------------------------------------------------- 1 | #extend("templates/base"): 2 | #export("head"): 3 | 11 | #endexport 12 | 13 | #export("body"): 14 |
15 | #import("info") 16 |
17 | #endexport 18 | #endextend 19 | -------------------------------------------------------------------------------- /Resources/Views/templates/search.leaf: -------------------------------------------------------------------------------- 1 | #extend("templates/base"): 2 | #export("head"): 3 | 69 | #endexport 70 | 71 | #export("body"): 72 |
73 |
74 |
Known #import("kind") count
75 |
#(count)
76 |
77 |
78 | 79 | 84 | #endexport 85 | #endextend 86 | -------------------------------------------------------------------------------- /Resources/Views/templates/status.leaf: -------------------------------------------------------------------------------- 1 | #extend("templates/base"): 2 | #export("head"): 3 | 4 | #endexport 5 | 6 | #export("body"): 7 |
8 |

Currently Info

9 | #import("current_info") 10 |
11 | 12 |
13 |

#import("history_heading")

14 | 15 | 16 | 17 | #import("history_table_head") 18 | 19 | 20 | #import("history_table_body") 21 | 22 |
History table
23 | 24 | #endexport 25 | #endextend 26 | -------------------------------------------------------------------------------- /Sources/Commands/CleanupCacheCommand.swift: -------------------------------------------------------------------------------- 1 | import RediStack 2 | import Vapor 3 | 4 | struct CleanupCacheCommand: AsyncCommand { 5 | struct Signature: CommandSignature {} 6 | 7 | var help: String { 8 | "Cleanup redis caches" 9 | } 10 | 11 | func run(using context: CommandContext, signature: Signature) async throws { 12 | let app = context.application 13 | context.console.print("Clear count") 14 | _ = try await ( 15 | app.cache.delete(DidRepository.countCacheKey), 16 | app.cache.delete(HandleRepository.countCacheKey) 17 | ) 18 | context.console.print("Build count cache") 19 | let (didCount, handleCount) = try await ( 20 | app.didRepository.count(), app.handleRepository.count() 21 | ) 22 | context.console.print("Count cache: did:\(didCount), handle:\(handleCount)") 23 | context.console.print("Clear exists check cache") 24 | _ = try await ( 25 | app.cache.delete(DidRepository.notFoundCacheKey), 26 | app.cache.delete(HandleRepository.notFoundCacheKey) 27 | ) 28 | context.console.print("Clear expired search cache") 29 | let searchCacheKey = RedisKey(HandleRepository.searchCacheKey) 30 | let searched = try await app.redis.smembers(of: searchCacheKey, as: String.self) 31 | .compactMap { value in 32 | if let value { 33 | (value, "\(HandleRepository.searchCacheKey):\(value)") 34 | } else { 35 | nil 36 | } 37 | } 38 | for (key, cacheKey) in searched { 39 | if try await app.redis.exists(.init(cacheKey)) > 0 { 40 | try await app.cache.delete(cacheKey) 41 | _ = try await app.redis.srem(key, from: searchCacheKey) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Commands/ImportDidCommand.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct ImportDidCommand: AsyncCommand { 4 | struct Signature: CommandSignature { 5 | @Argument(name: "did") 6 | var did: String 7 | } 8 | 9 | var help: String { 10 | "Import from https://plc.directory/:did/log/audit" 11 | } 12 | 13 | func run(using context: CommandContext, signature: Signature) async throws { 14 | guard Did.validate(did: signature.did) else { 15 | throw "Invalid DID Placeholder" 16 | } 17 | let app = context.application 18 | let res = try await app.client.send(.HEAD, to: "https://plc.directory/\(signature.did)") 19 | if 299 >= res.status.code { 20 | try await app.queues.queue.dispatch(ImportAuditableLogJob.self, signature.did) 21 | context.console.print("Queued fetching auditable log: \(signature.did)") 22 | } else { 23 | context.console.print("Not found DID: \(signature.did), resCode: \(res.status.code)") 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Commands/ImportExportedLogCommand.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct ImportExportedLogCommand: AsyncCommand { 4 | struct Signature: CommandSignature { 5 | @Option(name: "count", short: nil) 6 | var count: UInt? 7 | } 8 | 9 | var help: String { 10 | "Import from https://plc.directory/export" 11 | } 12 | 13 | func run(using context: CommandContext, signature: Signature) async throws { 14 | let app = context.application 15 | let after = try await PollingPlcServerExportJob.lastPolledDateWithoutFailure(on: app.db) 16 | let history = PollingHistory() 17 | try await history.create(on: app.db) 18 | try await app.queues.queue(.polling) 19 | .dispatch( 20 | PollingPlcServerExportJob.self, 21 | .init(after: after, count: signature.count ?? 1000, history: history)) 22 | if let after { 23 | context.console.print("Queued fetching export log, after \(after)") 24 | } else { 25 | context.console.print("Queued fetching export log") 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Controllers/DidController.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | struct DidIndexQuery: Content { 5 | let did: String? 6 | let specificId: String? 7 | 8 | private enum CodingKeys: String, CodingKey { 9 | case did 10 | case specificId = "specific_id" 11 | } 12 | } 13 | 14 | enum DidSearchResult { 15 | case notFound(_: String) 16 | case invalidFormat(_: String) 17 | case redirect(_: String) 18 | case none 19 | 20 | var message: String? { 21 | switch self { 22 | case .notFound(let did): "Not found: \(did)" 23 | case .invalidFormat(let did): "Invalid DID format: \(did)" 24 | default: nil 25 | } 26 | } 27 | 28 | var status: HTTPResponseStatus { 29 | switch self { 30 | case .notFound: .notFound 31 | case .invalidFormat: .badRequest 32 | case .redirect: .movedPermanently 33 | case .none: .ok 34 | } 35 | } 36 | } 37 | 38 | struct DidIndexContext: SearchContext { 39 | let title: String? 40 | let route: String 41 | let count: Int 42 | let currentValue: String? 43 | let message: String? 44 | } 45 | 46 | struct DidShowContext: BaseContext { 47 | struct UpdateHandleOp: Content { 48 | let handle: String? 49 | let createdAt: Date 50 | 51 | init(op operation: Operation, on db: Database) async throws { 52 | let handle = try await operation.$handle.get(on: db) 53 | self.handle = handle?.handle 54 | self.createdAt = operation.createdAt 55 | } 56 | } 57 | 58 | struct Current: Content { 59 | let handle: String 60 | let pds: String 61 | 62 | init?(op operation: Operation?, on db: Database) async throws { 63 | guard let operation else { 64 | return nil 65 | } 66 | let (handle, pds) = try await (operation.$handle.get(on: db), operation.$pds.get(on: db)) 67 | guard let handleName = handle?.handle, let pdsEndpoint = pds?.endpoint else { 68 | return nil 69 | } 70 | self.handle = handleName 71 | self.pds = pdsEndpoint 72 | } 73 | } 74 | 75 | let title: String? 76 | let route: String 77 | let current: Current? 78 | let operations: [UpdateHandleOp] 79 | } 80 | 81 | struct DidController: RouteCollection { 82 | func boot(routes: RoutesBuilder) throws { 83 | let dids = routes.grouped("did") 84 | dids.get(use: index) 85 | dids.group(":did") { $0.get(use: show) } 86 | } 87 | 88 | func index(req: Request) async throws -> ViewOrRedirect { 89 | let query = try req.query.decode(DidIndexQuery.self) 90 | let (specificId, did) = 91 | query.specificId.map({ ($0, "did:plc:" + $0) }) ?? query.did.map({ 92 | (String($0.trimmingPrefix("did:plc:")), $0) 93 | }) ?? (nil, nil) 94 | let result: DidSearchResult = 95 | if let did { 96 | try await self.search(did: did, req: req) 97 | } else { 98 | .none 99 | } 100 | if case .redirect(let did) = result { 101 | return .redirect(to: "/did/\(did)", redirectType: .permanent) 102 | } 103 | let count = try await req.didRepository.count() 104 | return .view( 105 | try await req.view.render( 106 | "did/index", 107 | DidIndexContext( 108 | title: "DID Placeholders", route: req.route?.description ?? "", count: count, 109 | currentValue: specificId, message: result.message)), status: result.status) 110 | } 111 | 112 | private func search(did: String, req: Request) async throws -> DidSearchResult { 113 | if !Did.validate(did: did) { 114 | .invalidFormat(did) 115 | } else if try await req.didRepository.search(did: did) { 116 | .redirect(did) 117 | } else { 118 | .notFound(did) 119 | } 120 | } 121 | 122 | func show(req: Request) async throws -> View { 123 | guard let did = req.parameters.get("did") else { 124 | throw Abort(.internalServerError) 125 | } 126 | guard Did.validate(did: did) else { 127 | throw Abort(.badRequest, reason: "Invalid DID format") 128 | } 129 | guard let didPlc = try await req.didRepository.findOrFetch(did) else { 130 | throw Abort(.notFound) 131 | } 132 | if didPlc.banned { 133 | throw Abort(.notFound, reason: didPlc.reason?.rawValue) 134 | } 135 | if didPlc.nonNullifiedOperations.isEmpty { 136 | throw Abort(.notFound, reason: "Operation not stored") 137 | } 138 | guard let operations = try didPlc.nonNullifiedOperations.treeSort().first else { 139 | throw Abort(.internalServerError, reason: "Broken operation tree") 140 | } 141 | let updateHandleOps = try await withThrowingTaskGroup( 142 | of: (idx: Int, op: DidShowContext.UpdateHandleOp).self 143 | ) { 144 | let updateHandleOps = try operations.onlyUpdateHandle() 145 | for (i, op) in updateHandleOps.enumerated() { 146 | $0.addTask { try await (i, .init(op: op, on: req.db)) } 147 | } 148 | return 149 | try await $0.reduce(into: Array(repeating: nil, count: updateHandleOps.count)) { 150 | $0[$1.idx] = $1.op 151 | } 152 | .compactMap { $0 } 153 | } 154 | return try await req.view.render( 155 | "did/show", 156 | DidShowContext( 157 | title: didPlc.requireID(), route: req.route?.description ?? "", 158 | current: .init(op: operations.last, on: req.db), operations: updateHandleOps)) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Sources/Controllers/HandleController.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentPostgresDriver 3 | import Vapor 4 | 5 | struct HandleIndexQuery: Content { 6 | let name: String? 7 | } 8 | 9 | enum HandleSearchResult { 10 | case invalid(_: String) 11 | case list(_: String, result: [String]) 12 | case none 13 | 14 | var list: [String] { 15 | switch self { 16 | case .list(_, let result): result 17 | default: [] 18 | } 19 | } 20 | 21 | var message: String? { 22 | switch self { 23 | case .invalid(let handle): "Invalid pattern: @\(handle)" 24 | case .list(let handle, let result) where result.isEmpty: "Not found: @\(handle)*" 25 | case .list(let handle, result: _): "Search: @\(handle)*" 26 | default: nil 27 | } 28 | } 29 | 30 | var status: HTTPResponseStatus { 31 | switch self { 32 | case .invalid: .badRequest 33 | case .list(_, let result) where result.isEmpty: .notFound 34 | case .list, .none: .ok 35 | } 36 | } 37 | } 38 | 39 | struct HandleIndexContext: SearchContext { 40 | let title: String? 41 | let route: String 42 | let count: Int 43 | let currentValue: String? 44 | let message: String? 45 | let result: [String] 46 | } 47 | 48 | struct HandleShowContext: BaseContext { 49 | struct HandleUsagePeriod: Content { 50 | let did: String 51 | let createdAt: Date 52 | let updatedAt: Date? 53 | } 54 | 55 | struct Current: Content { 56 | let did: String 57 | let pds: String 58 | } 59 | 60 | let title: String? 61 | let route: String 62 | let current: [Current] 63 | let operations: [HandleUsagePeriod] 64 | } 65 | 66 | struct HandleController: RouteCollection { 67 | func boot(routes: RoutesBuilder) throws { 68 | let handles = routes.grouped("handle") 69 | handles.get(use: index) 70 | handles.group(":handle") { $0.get(use: show) } 71 | } 72 | 73 | func index(req: Request) async throws -> ViewOrRedirect { 74 | let query = try req.query.decode(HandleIndexQuery.self) 75 | if let handle = query.name, try await req.handleRepository.exists(handle) { 76 | return .redirect(to: "/handle/\(handle)", redirectType: .permanent) 77 | } 78 | async let count = req.handleRepository.count() 79 | async let result = self.search(handle: query.name, repo: req.handleRepository) 80 | return try await .view( 81 | req.view.render( 82 | "handle/index", 83 | HandleIndexContext( 84 | title: "Handles", route: req.route?.description ?? "", count: count, 85 | currentValue: query.name, message: result.message, result: result.list)), 86 | status: result.status) 87 | } 88 | 89 | private func search(handle: String?, repo: HandleRepository) async throws -> HandleSearchResult { 90 | guard let handle else { 91 | return .none 92 | } 93 | return switch try await repo.search(prefix: handle) { 94 | case .some(let result): .list(handle, result: result) 95 | case .none: .invalid(handle) 96 | } 97 | } 98 | 99 | func show(req: Request) async throws -> View { 100 | guard let handleName = req.parameters.get("handle") else { 101 | throw Abort(.internalServerError) 102 | } 103 | guard let handle = try await req.handleRepository.findWithOperations(handleName: handleName) 104 | else { 105 | throw Abort(.notFound) 106 | } 107 | let (handleUsagePeriod, currents): 108 | (period: [HandleShowContext.HandleUsagePeriod], current: [HandleShowContext.Current]) = 109 | try await withThrowingTaskGroup( 110 | of: ( 111 | idx: Int, period: HandleShowContext.HandleUsagePeriod, 112 | current: HandleShowContext.Current? 113 | ) 114 | .self 115 | ) { group in 116 | let usagePeriod = try handle.nonNullifiedOperations.treeSort() 117 | for (idx, ops) in usagePeriod.enumerated() { 118 | guard let firstOp = ops.first, let lastOp = ops.last else { 119 | continue 120 | } 121 | let did = firstOp.$id.$did.id 122 | group.addTask { () in 123 | guard let untilOp = try await lastOp.$nexts.get(on: req.db).first else { 124 | return try await ( 125 | idx, .init(did: did, createdAt: firstOp.createdAt, updatedAt: nil), 126 | .init(did: did, pds: lastOp.$pds.get(on: req.db)!.endpoint) 127 | ) 128 | } 129 | return ( 130 | idx, .init(did: did, createdAt: firstOp.createdAt, updatedAt: untilOp.createdAt), 131 | nil 132 | ) 133 | } 134 | } 135 | return 136 | try await group.reduce(into: Array(repeating: nil, count: usagePeriod.count)) { 137 | $0[$1.idx] = (period: $1.period, current: $1.current) 138 | } 139 | .compactMap { $0 } 140 | .reduce(into: ([], [])) { acc, result in 141 | let (period, current) = result 142 | acc.period.append(period) 143 | if let current { acc.current.append(current) } 144 | } 145 | } 146 | return try await req.view.render( 147 | "handle/show", 148 | HandleShowContext( 149 | title: "@\(handle.handle)", route: req.route?.description ?? "", 150 | current: currents, operations: handleUsagePeriod)) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Sources/Controllers/ViewOrRedirect.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | enum ViewOrRedirect: AsyncResponseEncodable { 4 | case view(_: View, status: HTTPResponseStatus = .ok) 5 | case redirect(to: String, redirectType: Redirect = .normal) 6 | 7 | public func encodeResponse(for req: Request) async throws -> Response { 8 | switch self { 9 | case .view(let view, let status): try await view.encodeResponse(status: status, for: req) 10 | case .redirect(let to, let redirectType): req.redirect(to: to, redirectType: redirectType) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Extensions/Command+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | protocol AsyncCommand: Command { 4 | func run(using context: CommandContext, signature: Signature) async throws 5 | } 6 | 7 | extension Command where Self: AsyncCommand { 8 | func run(using context: CommandContext, signature: Signature) throws { 9 | let promise = context.application.eventLoopGroup.next().makePromise(of: Void.self) 10 | promise.completeWithTask { () in 11 | try await self.run(using: context, signature: signature) 12 | } 13 | try promise.futureResult.wait() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Extensions/Environment++.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Environment { 4 | static func get(_ key: String, _ defaultValue: String) -> String { 5 | Self.get(key) ?? defaultValue 6 | } 7 | 8 | static func getInt(_ key: String) -> Int? { 9 | Self.get(key).flatMap(Int.init(_:)) 10 | } 11 | 12 | static func getInt(_ key: String, _ defaultValue: Int) -> Int { 13 | Self.getInt(key) ?? defaultValue 14 | } 15 | 16 | static func getUInt(_ key: String) -> UInt? { 17 | Self.get(key).flatMap(UInt.init(_:)) 18 | } 19 | 20 | static func getUInt(_ key: String, _ defaultValue: UInt) -> UInt { 21 | Self.getUInt(key) ?? defaultValue 22 | } 23 | 24 | static func getBool(_ key: String, _ defaultValue: Bool = false) -> Bool { 25 | Self.get(key) 26 | .flatMap { value in 27 | switch value.lowercased() { 28 | case "true", "t", "yes", "y": true 29 | case "false", "f", "no", "n", "": false 30 | default: if let int = Int(value) { int != 0 } else { nil } 31 | } 32 | } ?? defaultValue 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Extensions/LeafTags+innerText.swift: -------------------------------------------------------------------------------- 1 | import Leaf 2 | 3 | extension UnsafeUnescapedLeafTag { 4 | func innerText(_ body: [Syntax]) -> String { 5 | body.compactMap { syntax in 6 | if case .raw(var byteBuffer) = syntax { 7 | byteBuffer.readString(length: byteBuffer.readableBytes) 8 | } else { 9 | nil 10 | } 11 | } 12 | .joined(separator: "") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Extensions/QueueName+polling.swift: -------------------------------------------------------------------------------- 1 | import Queues 2 | 3 | extension QueueName { 4 | static let polling = QueueName(string: "polling") 5 | } 6 | -------------------------------------------------------------------------------- /Sources/Extensions/Queues+scheduleEvery.swift: -------------------------------------------------------------------------------- 1 | import Queues 2 | import Vapor 3 | 4 | extension Application.Queues { 5 | func scheduleEvery(_ job: ScheduledJob, stride strideStep: Int.Stride, from: Int = 0) { 6 | let start = from % 60 7 | let strideStart = 8 | if strideStep <= start { 9 | start - strideStep 10 | } else { 11 | start 12 | } 13 | for minuteOffset in stride(from: strideStart, to: 60, by: strideStep) { 14 | self.schedule(job).hourly().at(.init(integerLiteral: minuteOffset)) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Extensions/RedisClient+Concurrency.swift: -------------------------------------------------------------------------------- 1 | import NIOCore 2 | import RediStack 3 | 4 | extension RedisClient { 5 | func increment(_ key: RedisKey) async throws -> Int { 6 | try await self.increment(key).get() 7 | } 8 | 9 | func exists(_ keys: [RedisKey]) async throws -> Int { 10 | try await self.exists(keys).get() 11 | } 12 | 13 | func exists(_ keys: RedisKey...) async throws -> Int { 14 | try await self.exists(keys) 15 | } 16 | 17 | func expire(_ key: RedisKey, after timeout: TimeAmount) async throws -> Bool { 18 | try await self.expire(key, after: timeout).get() 19 | } 20 | 21 | func sadd(_ elements: [Value], to key: RedisKey) async throws -> Int 22 | { 23 | try await self.sadd(elements, to: key).get() 24 | } 25 | 26 | func sadd(_ elements: Value..., to key: RedisKey) async throws -> Int 27 | { 28 | try await self.sadd(elements, to: key) 29 | } 30 | 31 | func sismember(_ element: Value, of key: RedisKey) async throws 32 | -> Bool 33 | { 34 | try await self.sismember(element, of: key).get() 35 | } 36 | 37 | func smembers(of key: RedisKey, as type: Value.Type) async throws 38 | -> [Value?] 39 | { 40 | try await self.smembers(of: key, as: type).get() 41 | } 42 | 43 | func srem(_ elements: [Value], from key: RedisKey) async throws 44 | -> Int 45 | { 46 | try await self.srem(elements, from: key).get() 47 | } 48 | 49 | func srem(_ elements: Value..., from key: RedisKey) async throws 50 | -> Int 51 | { 52 | try await self.srem(elements, from: key) 53 | } 54 | 55 | func zadd( 56 | _ elements: [(element: Value, score: Double)], to key: RedisKey 57 | ) async throws -> Int { 58 | try await self.zadd(elements, to: key).get() 59 | } 60 | 61 | func zadd( 62 | _ elements: (element: Value, score: Double)..., to key: RedisKey 63 | ) async throws -> Int { 64 | try await self.zadd(elements, to: key) 65 | } 66 | 67 | func zadd( 68 | _ elements: [Value], defaultRank rank: Double = 0, to key: RedisKey 69 | ) async throws -> Int { 70 | try await self.zadd(elements.map { ($0, rank) }, to: key).get() 71 | } 72 | 73 | func zadd( 74 | _ elements: Value..., defaultRank rank: Double = 0, to key: RedisKey 75 | ) async throws -> Int { 76 | try await self.zadd(elements, defaultRank: rank, to: key) 77 | } 78 | 79 | func zrange(from key: RedisKey, fromIndex index: Int) async throws -> [RESPValue] { 80 | try await self.zrange(from: key, fromIndex: index).get() 81 | } 82 | 83 | func zrange( 84 | from key: RedisKey, fromIndex index: Int, as type: Value.Type 85 | ) async throws -> [Value?] { 86 | try await self.zrange(from: key, fromIndex: index).map(type.init(fromRESP:)) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/Jobs/Dispatched/FetchDidJob.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Foundation 3 | import Queues 4 | import Vapor 5 | 6 | struct FetchDidJob: AsyncJob { 7 | typealias Payload = String 8 | 9 | func dequeue(_ context: QueueContext, _ payload: Payload) async throws { 10 | guard Did.validate(did: payload) else { 11 | throw "Invalid DID Placeholder" 12 | } 13 | let app = context.application 14 | let res = try await app.client.send(.HEAD, to: "https://plc.directory/\(payload)") 15 | if 299 >= res.status.code { 16 | try await app.queues.queue.dispatch(ImportAuditableLogJob.self, payload) 17 | } else { 18 | app.logger.debug("Not found DID: \(payload), resCode: \(res.status.code)") 19 | } 20 | } 21 | 22 | func error(_ context: QueueContext, _ error: Error, _ payload: Payload) async throws { 23 | context.application.logger.report(error: error) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Jobs/Dispatched/ImportAuditableLogJob.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Foundation 3 | import Queues 4 | import Vapor 5 | 6 | struct ImportAuditableLogJob: AsyncJob { 7 | typealias Payload = String 8 | 9 | func dequeue(_ context: QueueContext, _ payload: Payload) async throws { 10 | guard Did.validate(did: payload) else { 11 | throw "Invalid DID Placeholder" 12 | } 13 | let app = context.application 14 | let response = try await app.client.get("https://plc.directory/\(payload)/log/audit") 15 | let ops = try response.content.decode([ExportedOperation].self) 16 | try await ops.insert(app: app) 17 | do { 18 | try await app.didRepository.unban(payload) 19 | } catch { 20 | app.logger.report(error: error) 21 | } 22 | do { 23 | try await PollingJobStatus.query(on: app.db).set(\.$status, to: .success) 24 | .filter(\.$did == payload).update() 25 | } catch { 26 | app.logger.report(error: error) 27 | } 28 | } 29 | 30 | func error(_ context: QueueContext, _ error: Error, _ payload: Payload) async throws { 31 | let app = context.application 32 | app.logger.report(error: error) 33 | guard let err = error as? OpParseError else { 34 | return 35 | } 36 | do { 37 | try await app.didRepository.ban(payload, error: err) 38 | } catch { 39 | app.logger.report(error: error) 40 | } 41 | do { 42 | try await PollingJobStatus.query(on: app.db).set(\.$status, to: .banned) 43 | .filter(\.$status != .banned).filter(\.$did == payload).update() 44 | } catch { 45 | app.logger.report(error: error) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Jobs/Dispatched/ImportExportedLogJob.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Foundation 3 | import Queues 4 | import Vapor 5 | 6 | struct ImportExportedLogJob: AsyncJob { 7 | struct Payload: Content { 8 | let ops: [ExportedOperation] 9 | let historyId: UUID 10 | } 11 | 12 | func dequeue(_ context: QueueContext, _ payload: Payload) async throws { 13 | let app = context.application 14 | if payload.ops.isEmpty { 15 | throw "Empty export" 16 | } 17 | try await payload.ops.insert(app: app) 18 | } 19 | 20 | func error(_ context: QueueContext, _ error: Error, _ payload: Payload) async throws { 21 | let app = context.application 22 | app.logger.report(error: error) 23 | guard let err = error as? OpParseError, let op = payload.ops.first else { 24 | return 25 | } 26 | do { 27 | try await app.didRepository.ban(op.did, error: err) 28 | } catch { 29 | app.logger.report(error: error) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Jobs/Dispatched/PollingPlcServerExportJob.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Foundation 3 | import Queues 4 | import Vapor 5 | 6 | struct PollingPlcServerExportJob: AsyncJob { 7 | static func lastPolledDateWithoutFailure(on database: Database) async throws -> Date? { 8 | guard let last = try await PollingHistory.getLatestWithoutErrors(on: database) else { 9 | return nil 10 | } 11 | if try await last.running(on: database) { 12 | throw "latest polling job not completed" 13 | } 14 | return last.createdAt 15 | } 16 | 17 | struct Payload: Content { 18 | let after: Date? 19 | let count: UInt 20 | let historyId: UUID 21 | 22 | init(after: Date?, count: UInt = 1000, history: PollingHistory) throws { 23 | self.after = after 24 | self.count = count 25 | self.historyId = try history.requireID() 26 | } 27 | } 28 | 29 | func dequeue(_ context: QueueContext, _ payload: Payload) async throws { 30 | let app = context.application 31 | let exportedLog = try await getExportedLog(app, after: payload.after, count: payload.count) 32 | for tree in try exportedLog.treeSort() { 33 | try await app.queues.queue.dispatch( 34 | ImportExportedLogJob.self, .init(ops: tree, historyId: payload.historyId)) 35 | } 36 | try await self.log(to: payload.historyId, lastOp: exportedLog.last, on: app.db) 37 | } 38 | 39 | private func getExportedLog(_ app: Application, after: Date?, count: UInt) async throws 40 | -> [ExportedOperation] 41 | { 42 | let jsonLines = try await self.fetchExportedLog(app.client, after: after, count: count) 43 | let jsonDecoder = try ContentConfiguration.global.requireDecoder(for: .json) 44 | var bannedDids: [String] = [] 45 | let ops = try jsonLines.compactMap { json in 46 | do { 47 | return try jsonDecoder.decode( 48 | ExportedOperation.self, from: .init(string: String(json)), headers: [:]) 49 | } catch OpParseError.notUsedInAtproto(let did, _) { 50 | bannedDids.append(did) 51 | return nil 52 | } 53 | } 54 | try await app.didRepository.ban(dids: bannedDids) 55 | return ops 56 | } 57 | 58 | private func fetchExportedLog(_ client: Client, after: Date?, count: UInt) async throws 59 | -> [String.SubSequence] 60 | { 61 | var url: URI = "https://plc.directory/export" 62 | url.query = 63 | if let after { 64 | "count=\(count)&after=\(after.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true)))" 65 | } else { 66 | "count=\(count)" 67 | } 68 | let response = try await client.get(url) 69 | let textDecoder = try ContentConfiguration.global.requireDecoder(for: .plainText) 70 | let jsonLines = try response.content.decode(String.self, using: textDecoder) 71 | .split(separator: "\n") 72 | if count <= 1000 || jsonLines.count < 1000 { 73 | return jsonLines 74 | } 75 | let jsonDecoder = try ContentConfiguration.global.requireDecoder(for: .json) 76 | var nextAfter: Date? 77 | do { 78 | let lastOp = try jsonDecoder.decode( 79 | ExportedOperation.self, from: .init(string: String(jsonLines.last!)), headers: [:]) 80 | nextAfter = lastOp.createdAt 81 | } catch OpParseError.notUsedInAtproto(_, let createdAt) { 82 | nextAfter = createdAt 83 | } 84 | let nextJsonLines = try await self.fetchExportedLog( 85 | client, after: nextAfter, count: count - 1000) 86 | return jsonLines + nextJsonLines 87 | } 88 | 89 | private func log(to historyId: UUID, lastOp: ExportedOperation?, on database: Database) 90 | async throws 91 | { 92 | guard let lastOp, let history = try await PollingHistory.find(historyId, on: database) else { 93 | throw "Empty export" 94 | } 95 | history.cid = lastOp.cid 96 | history.createdAt = lastOp.createdAt 97 | return try await history.update(on: database) 98 | } 99 | 100 | func error(_ context: QueueContext, _ error: Error, _ payload: Payload) async throws { 101 | let app = context.application 102 | app.logger.report(error: error) 103 | guard let history = try await PollingHistory.find(payload.historyId, on: app.db) else { 104 | return 105 | } 106 | history.failed = true 107 | try await history.update(on: app.db) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/Jobs/PollingJobNotificationHook.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Foundation 3 | import Queues 4 | 5 | struct PollingJobNotificationHook: AsyncJobEventDelegate { 6 | typealias Job = ImportExportedLogJob 7 | 8 | let database: Database 9 | 10 | init(on database: Database) { 11 | self.database = database 12 | } 13 | 14 | func dispatched(job: JobEventData) async throws { 15 | guard let jobId = UUID(uuidString: job.id), job.jobName == String(describing: Job.self) else { 16 | return 17 | } 18 | let payload = try Job.parsePayload(job.payload) 19 | try await PollingJobStatus( 20 | id: jobId, historyId: payload.historyId, did: payload.ops.first.map { $0.did }, 21 | dispatchTimestamp: job.queuedAt 22 | ) 23 | .create(on: self.database) 24 | } 25 | 26 | func didDequeue(jobId: String) async throws { 27 | guard let jobId = UUID(uuidString: jobId), 28 | let jobStatus = try await PollingJobStatus.find(jobId, on: self.database) 29 | else { 30 | return 31 | } 32 | jobStatus.dequeuedAt = Date() 33 | jobStatus.status = .running 34 | try await jobStatus.update(on: self.database) 35 | } 36 | 37 | func success(jobId: String) async throws { 38 | guard let jobId = UUID(uuidString: jobId), 39 | let jobStatus = try await PollingJobStatus.find(jobId, on: self.database) 40 | else { 41 | return 42 | } 43 | jobStatus.completedAt = Date() 44 | jobStatus.status = .success 45 | try await jobStatus.update(on: self.database) 46 | } 47 | 48 | func error(jobId: String, error: Error) async throws { 49 | guard let jobId = UUID(uuidString: jobId), 50 | let jobStatus = try await PollingJobStatus.find(jobId, on: self.database) 51 | else { 52 | return 53 | } 54 | jobStatus.completedAt = Date() 55 | jobStatus.status = 56 | if error is OpParseError { 57 | .banned 58 | } else { 59 | .error 60 | } 61 | try await jobStatus.update(on: self.database) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Jobs/Scheduled/ScheduledPollingHistoryCleanupJob.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Queues 3 | 4 | struct ScheduledPollingHistoryCleanupJob: AsyncScheduledJob { 5 | func run(context: QueueContext) async throws { 6 | let app = context.application 7 | do { 8 | try await PollingJobStatus.query(on: app.db).filter(\.$status ~~ [.success, .banned]).delete() 9 | let errorOrRunnings = try await PollingJobStatus.query(on: app.db).all(\.$history.$id) 10 | guard 11 | let insertedAt = try await PollingHistory.queryCompleted(on: app.db, errorOrRunnings) 12 | .sort(\.$insertedAt, .descending).range(5...).limit(1).all(\.$insertedAt).first 13 | else { 14 | return 15 | } 16 | try await PollingHistory.queryCompleted(on: app.db, errorOrRunnings) 17 | .filter(\.$insertedAt < insertedAt).delete() 18 | } catch { 19 | app.logger.report(error: error) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Jobs/Scheduled/ScheduledPollingJob.swift: -------------------------------------------------------------------------------- 1 | import Queues 2 | import Vapor 3 | 4 | struct ScheduledPollingJob: AsyncScheduledJob { 5 | func run(context: QueueContext) async throws { 6 | let app = context.application 7 | do { 8 | let after = try await PollingPlcServerExportJob.lastPolledDateWithoutFailure(on: app.db) 9 | let history = PollingHistory() 10 | try await history.create(on: app.db) 11 | try await app.queues.queue(.polling) 12 | .dispatch( 13 | PollingPlcServerExportJob.self, 14 | .init( 15 | after: after, count: Environment.getUInt("POLLING_MAX_COUNT", 1000), history: history)) 16 | } catch { 17 | app.logger.report(error: error) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Jobs/Scheduled/ScheduledPollingRecoveryJob.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Queues 3 | 4 | struct ScheduledPollingRecoveryJob: AsyncScheduledJob { 5 | func run(context: QueueContext) async throws { 6 | let app = context.application 7 | do { 8 | let notSuccessful = try await PollingJobStatus.query(on: app.db).filter(\.$did != .null) 9 | .filter(\.$status !~ [.success, .banned]).unique().all(\.$did) 10 | for did in notSuccessful { 11 | try await app.queues.queue.dispatch(ImportAuditableLogJob.self, did!) 12 | } 13 | } catch { 14 | app.logger.report(error: error) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Middleware/ErrorMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct ErrorContext: BaseContext { 4 | let title: String? 5 | let route: String 6 | let reason: String? 7 | } 8 | 9 | struct ErrorMiddleware: AsyncMiddleware { 10 | let environment: Environment 11 | 12 | func respond(to req: Request, chainingTo next: AsyncResponder) async -> Response { 13 | do { 14 | return try await next.respond(to: req) 15 | } catch { 16 | req.logger.report(error: error) 17 | let (status, reason) = self.handle(error: error) 18 | let context = ErrorContext( 19 | title: "\(status.code) \(status.reasonPhrase)", route: req.route?.description ?? "", 20 | reason: reason) 21 | return await self.response(status: status, context: context, reason: reason, for: req) 22 | } 23 | } 24 | 25 | private func handle(error abort: AbortError) -> (HTTPStatus, String?) { 26 | if abort.reason != abort.status.reasonPhrase { 27 | (abort.status, abort.reason) 28 | } else { 29 | (abort.status, nil) 30 | } 31 | } 32 | 33 | private func handle(error: Error) -> (HTTPStatus, String?) { 34 | if self.environment.isRelease { 35 | (.internalServerError, "Something went wrong.") 36 | } else { 37 | (.internalServerError, String(describing: error)) 38 | } 39 | } 40 | 41 | private func response( 42 | status: HTTPStatus, context: ErrorContext, reason: String?, for req: Request 43 | ) async -> Response { 44 | do { 45 | return try await self.render(status: status, context: context, for: req) 46 | } catch { 47 | return .init( 48 | status: status, 49 | headers: [HTTPHeaders.Name.contentType.description: "text/plain; charset=utf-8"], 50 | body: .init( 51 | string: "Oops: \(reason ?? "Something went wrong.")", 52 | byteBufferAllocator: req.byteBufferAllocator)) 53 | } 54 | } 55 | 56 | private func render(status: HTTPStatus, context: ErrorContext, for req: Request) async throws 57 | -> Response 58 | { 59 | do { 60 | return try await req.view.render("error/\(status.code)", context) 61 | .encodeResponse(status: status, for: req) 62 | } catch { 63 | return try await req.view.render("error/default", context) 64 | .encodeResponse(status: status, for: req) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Middleware/RouteLoggingMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct RouteLoggingMiddleware: AsyncMiddleware { 4 | let logLevel: Logger.Level = .info 5 | 6 | func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response { 7 | let query = 8 | if let query = request.url.query { 9 | "?\(query.removingPercentEncoding ?? query)" 10 | } else { 11 | "" 12 | } 13 | request.logger.log( 14 | level: self.logLevel, 15 | "\(request.method) \(request.url.path.removingPercentEncoding ?? request.url.path)\(query)") 16 | return try await next.respond(to: request) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Migrations/AddCompletedColumnToPollingHistoryTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentSQL 3 | 4 | struct AddCompletedColumnToPollingHistoryTable: AsyncMigration { 5 | func prepare(on database: Database) async throws { 6 | try await database.schema("polling_history") 7 | .field("completed", .bool, .required, .custom("DEFAULT false")) 8 | .update() 9 | try await database.transaction { transaction in 10 | guard let sql = transaction as? SQLDatabase else { 11 | throw "not supported currently database" 12 | } 13 | try await sql.update("polling_history") 14 | .set("completed", to: SQLLiteral.boolean(true)) 15 | .where("operation", .isNot, SQLLiteral.null) 16 | .run() 17 | } 18 | try await database.schema("polling_history") 19 | .deleteField("operation") 20 | .update() 21 | } 22 | 23 | func revert(on database: Database) async throws { 24 | try await database.schema("polling_history") 25 | .field("operation", .uuid, .references("operations", "id")) 26 | .update() 27 | if let sql = database as? SQLDatabase { 28 | try await sql.raw( 29 | """ 30 | UPDATE polling_history AS h 31 | SET operation = o.id 32 | FROM operations AS o 33 | WHERE 34 | h.completed = true 35 | AND h.cid = o.cid 36 | """ 37 | ) 38 | .run() 39 | } else { 40 | throw "not supported currently database" 41 | } 42 | try await database.schema("polling_history") 43 | .deleteField("completed") 44 | .update() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Migrations/AddDidColumnToPollingJobStatusesTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct AddDidColumnToPollingJobStatusesTable: AsyncMigration { 4 | func prepare(on database: Database) async throws { 5 | try await database.schema("polling_job_statuses") 6 | .field("did", .string) 7 | .update() 8 | } 9 | 10 | func revert(on database: Database) async throws { 11 | try await database.schema("polling_job_statuses") 12 | .deleteField("did") 13 | .update() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Migrations/AddFailedColumnToPollingHistoryTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct AddFailedColumnToPollingHistoryTable: AsyncMigration { 4 | func prepare(on database: Database) async throws { 5 | try await database.schema("polling_history") 6 | .field("failed", .bool, .required, .custom("DEFAULT false")) 7 | .update() 8 | } 9 | 10 | func revert(on database: Database) async throws { 11 | try await database.schema("polling_history") 12 | .deleteField("failed") 13 | .update() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Migrations/AddPrevDidColumnForPrevForeignKey.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentSQL 3 | 4 | struct AddPrevDidColumnForPrevForeignKey: AsyncMigration { 5 | func prepare(on database: Database) async throws { 6 | try await database.transaction { transaction in 7 | try await transaction.schema("operations") 8 | .deleteConstraint(name: "operations_prev_fkey") 9 | .field("prev_did", .string) 10 | .update() 11 | if let sql = transaction as? SQLDatabase { 12 | try await sql.raw("ALTER TABLE operations RENAME COLUMN prev TO prev_cid").run() 13 | try await sql.raw( 14 | """ 15 | UPDATE operations 16 | SET prev_did = did 17 | WHERE prev_cid IS NOT NULL 18 | """ 19 | ) 20 | .run() 21 | } else { 22 | throw "not supported currently database" 23 | } 24 | let checkBothNull = SQLTableConstraintAlgorithm.check( 25 | SQLBinaryExpression( 26 | SQLBinaryExpression( 27 | SQLBinaryExpression(SQLIdentifier("prev_cid"), .is, SQLLiteral.null), 28 | .and, 29 | SQLBinaryExpression(SQLIdentifier("prev_did"), .is, SQLLiteral.null)), 30 | .or, 31 | SQLBinaryExpression( 32 | SQLBinaryExpression(SQLIdentifier("prev_cid"), .isNot, SQLLiteral.null), 33 | .and, 34 | SQLBinaryExpression(SQLIdentifier("prev_did"), .isNot, SQLLiteral.null)))) 35 | try await transaction.schema("operations") 36 | .foreignKey( 37 | ["prev_cid", "prev_did"], references: "operations", ["cid", "did"], 38 | name: "operations_prev_fkey" 39 | ) 40 | .constraint(.constraint(.sql(checkBothNull), name: "operations_prev_fkey_check")) 41 | .update() 42 | } 43 | } 44 | 45 | func revert(on database: Database) async throws { 46 | try await database.transaction { transaction in 47 | try await transaction.schema("operations") 48 | .deleteConstraint(name: "operations_prev_fkey") 49 | .deleteConstraint(name: "operations_prev_fkey_check") 50 | .update() 51 | try await transaction.schema("operations") 52 | .deleteField("prev_did") 53 | .update() 54 | if let sql = transaction as? SQLDatabase { 55 | try await sql.raw("ALTER TABLE operations RENAME COLUMN prev_cid TO prev").run() 56 | } else { 57 | throw "not supported currently database" 58 | } 59 | try await transaction.schema("operations") 60 | .foreignKey( 61 | ["prev", "did"], references: "operations", ["cid", "did"], name: "operations_prev_fkey" 62 | ) 63 | .update() 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/Migrations/AddReasonColumnToBannedDidsTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct AddReasonColumnToBannedDidsTable: AsyncMigration { 4 | func prepare(on database: Database) async throws { 5 | let banReason = try await database.enum("ban_reason") 6 | .case("incompatible_atproto") 7 | .case("invalid_handle") 8 | .case("missing_history") 9 | .create() 10 | try await database.schema("banned_dids") 11 | .field("reason", banReason, .required, .custom("DEFAULT 'incompatible_atproto'")) 12 | .update() 13 | } 14 | 15 | func revert(on database: Database) async throws { 16 | try await database.schema("banned_dids") 17 | .deleteField("reason") 18 | .update() 19 | try await database.enum("ban_reason").delete() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Migrations/ChangePrimaryKeyToCompositeDidAndCid.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentSQL 3 | 4 | struct ChangePrimaryKeyToCompositeDidAndCid: AsyncMigration { 5 | func prepare(on database: Database) async throws { 6 | try await database.transaction { transaction in 7 | try await transaction.schema("operations") 8 | .deleteConstraint(name: "operations_prev_fkey") 9 | .deleteConstraint(name: "operations_pkey") 10 | .deleteUnique(on: "cid") 11 | .update() 12 | try await transaction.schema("operations") 13 | .constraint( 14 | .constraint(.compositeIdentifier([.key("cid"), .key("did")]), name: "operations_pkey") 15 | ) 16 | .update() 17 | if let sql = transaction as? SQLDatabase { 18 | let deletedOpRows = try await sql.delete(from: "operations") 19 | .where("prev", .isNot, SQLLiteral.null) 20 | .where( 21 | SQLFunction( 22 | "NOT EXISTS", 23 | args: sql.select().from("operations", as: "o") 24 | .where(SQLColumn("did", table: "operations"), .equal, SQLColumn("did", table: "o")) 25 | .where(SQLColumn("prev", table: "operations"), .equal, SQLColumn("cid", table: "o")) 26 | .query) 27 | ) 28 | .returning("did") 29 | .all() 30 | let deletedDids = Array( 31 | Set(try deletedOpRows.map { try $0.decode(column: "did", as: String.self) })) 32 | if !deletedDids.isEmpty { 33 | try await sql.delete(from: "operations").where("did", .in, deletedDids).run() 34 | try await Did.query(on: transaction).set(\.$banned, to: true) 35 | .set(\.$reason, to: .missingHistory).filter(\.$id ~~ deletedDids).update() 36 | } 37 | } else { 38 | throw "not supported currently database" 39 | } 40 | try await transaction.schema("operations") 41 | .foreignKey( 42 | ["prev", "did"], references: "operations", ["cid", "did"], name: "operations_prev_fkey" 43 | ) 44 | .update() 45 | } 46 | } 47 | 48 | func revert(on database: Database) async throws { 49 | try await database.transaction { transaction in 50 | try await transaction.schema("operations") 51 | .deleteConstraint(name: "operations_prev_fkey") 52 | .deleteConstraint(name: "operations_pkey") 53 | .update() 54 | try await transaction.schema("operations") 55 | .unique(on: "cid") 56 | .constraint(.constraint(.compositeIdentifier([.key("cid")]), name: "operations_pkey")) 57 | .foreignKey("prev", references: "operations", "cid", name: "operations_prev_fkey") 58 | .update() 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/Migrations/ChangePrimaryKeyToNaturalKeyOfDidAndCid.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentSQL 3 | 4 | struct ChangePrimaryKeyToNaturalKeyOfDidAndCid: AsyncMigration { 5 | func prepare(on database: Database) async throws { 6 | try await database.transaction { transaction in 7 | try await transaction.schema("operations") 8 | .deleteConstraint(name: "operations_did_fkey") 9 | .updateField("did", .string) 10 | .update() 11 | if let sql = transaction as? SQLDatabase { 12 | try await sql.raw( 13 | """ 14 | UPDATE operations AS o 15 | SET did = d.did 16 | FROM dids AS d 17 | WHERE o.did = d.id::text 18 | """ 19 | ) 20 | .run() 21 | } else { 22 | throw "not supported currently database" 23 | } 24 | try await transaction.schema("dids") 25 | .deleteField("id") 26 | .constraint(.constraint(.compositeIdentifier([.key("did")]), name: "dids_pkey")) 27 | .update() 28 | try await transaction.schema("operations") 29 | .foreignKey("did", references: "dids", "did", name: "operations_did_fkey") 30 | .update() 31 | } 32 | 33 | try await database.transaction { transaction in 34 | try await transaction.schema("operations") 35 | .deleteConstraint(name: "operations_prev_fkey") 36 | .updateField("prev", .string) 37 | .update() 38 | if let sql = transaction as? SQLDatabase { 39 | try await sql.raw( 40 | """ 41 | UPDATE operations AS o1 42 | SET prev = o2.cid 43 | FROM operations AS o2 44 | WHERE o1.prev = o2.id::text 45 | """ 46 | ) 47 | .run() 48 | } else { 49 | throw "not supported currently database" 50 | } 51 | try await transaction.schema("operations") 52 | .deleteField("id") 53 | .constraint(.constraint(.compositeIdentifier([.key("cid")]), name: "operations_pkey")) 54 | .update() 55 | try await transaction.schema("operations") 56 | .foreignKey("prev", references: "operations", "cid", name: "operations_prev_fkey") 57 | .update() 58 | } 59 | } 60 | 61 | func revert(on database: Database) async throws { 62 | throw "Cannot revert from this point" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/Migrations/ChangeToNullableCidAndCreatedAtColumn.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentSQL 3 | 4 | struct ChangeToNullableCidAndCreatedAtColumn: AsyncMigration { 5 | func prepare(on database: Database) async throws { 6 | try await database.transaction { transaction in 7 | if let sql = transaction as? SQLDatabase { 8 | try await sql.raw("ALTER TABLE polling_history ALTER COLUMN cid DROP NOT NULL").run() 9 | try await sql.raw("ALTER TABLE polling_history ALTER COLUMN created_at DROP NOT NULL").run() 10 | } else { 11 | throw "not supported currently database" 12 | } 13 | } 14 | } 15 | 16 | func revert(on database: Database) async throws { 17 | try await database.transaction { transaction in 18 | if let sql = transaction as? SQLDatabase { 19 | try await sql.raw("ALTER TABLE polling_history ALTER COLUMN cid SET NOT NULL").run() 20 | try await sql.raw("ALTER TABLE polling_history ALTER COLUMN created_at SET NOT NULL").run() 21 | } else { 22 | throw "not supported currently database" 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Migrations/CreateBannedDidsTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateBannedDidsTable: AsyncMigration { 4 | func prepare(on database: Database) async throws { 5 | try await database.schema("banned_dids") 6 | .field("did", .string, .identifier(auto: false)) 7 | .create() 8 | } 9 | 10 | func revert(on database: Database) async throws { 11 | try await database.schema("banned_dids").delete() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Migrations/CreateDidsTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateDidsTable: AsyncMigration { 4 | func prepare(on database: Database) async throws { 5 | try await database.schema("dids") 6 | .id() 7 | .field("did", .string, .required) 8 | .unique(on: "did") 9 | .create() 10 | } 11 | 12 | func revert(on database: Database) async throws { 13 | try await database.schema("dids").delete() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Migrations/CreateHandlesTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateHandlesTable: AsyncMigration { 4 | func prepare(on database: Database) async throws { 5 | try await database.schema("handles") 6 | .id() 7 | .field("handle", .string, .required) 8 | .unique(on: "handle") 9 | .create() 10 | } 11 | 12 | func revert(on database: Database) async throws { 13 | try await database.schema("handles").delete() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Migrations/CreateIndexForForeignKeyOfOperationsTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentSQL 3 | 4 | struct CreateIndexForForeignKeyOfOperationsTable: AsyncMigration { 5 | func prepare(on database: Database) async throws { 6 | try await database.transaction { transaction in 7 | if let sql = transaction as? SQLDatabase { 8 | try await sql.create(index: "operations_did_fkey_index") 9 | .on("operations") 10 | .column("did") 11 | .run() 12 | try await sql.create(index: "operations_prev_fkey_index") 13 | .on("operations") 14 | .column("prev_cid") 15 | .column("prev_did") 16 | .run() 17 | try await sql.create(index: "operations_handle_fkey_index") 18 | .on("operations") 19 | .column("handle") 20 | .run() 21 | try await sql.create(index: "operations_pds_fkey_index") 22 | .on("operations") 23 | .column("pds") 24 | .run() 25 | } else { 26 | throw "not supported currently database" 27 | } 28 | } 29 | } 30 | 31 | func revert(on database: Database) async throws { 32 | try await database.transaction { transaction in 33 | if let sql = transaction as? SQLDatabase { 34 | try await sql.drop(index: "operations_pds_fkey_index").run() 35 | try await sql.drop(index: "operations_handle_fkey_index").run() 36 | try await sql.drop(index: "operations_prev_fkey_index").run() 37 | try await sql.drop(index: "operations_did_fkey_index").run() 38 | } else { 39 | throw "not supported currently database" 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Migrations/CreateOperationsTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateOperationsTable: AsyncMigration { 4 | func prepare(on database: Database) async throws { 5 | try await database.schema("operations") 6 | .id() 7 | .field("cid", .string, .required) 8 | .field("did", .uuid, .required, .references("dids", "id")) 9 | .field("nullified", .bool, .required) 10 | .field("created_at", .datetime, .required) 11 | .field("prev", .uuid, .references("operations", "id")) 12 | .field("handle", .uuid, .references("handles", "id")) 13 | .field("pds", .uuid, .references("personal_data_servers", "id")) 14 | .unique(on: "cid") 15 | .create() 16 | } 17 | 18 | func revert(on database: Database) async throws { 19 | try await database.schema("operations").delete() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Migrations/CreatePersonalDataServersTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreatePersonalDataServersTable: AsyncMigration { 4 | func prepare(on database: Database) async throws { 5 | try await database.schema("personal_data_servers") 6 | .id() 7 | .field("endpoint", .string, .required) 8 | .unique(on: "endpoint") 9 | .create() 10 | } 11 | 12 | func revert(on database: Database) async throws { 13 | try await database.schema("personal_data_servers").delete() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Migrations/CreatePollingHistoryTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreatePollingHistoryTable: AsyncMigration { 4 | func prepare(on database: Database) async throws { 5 | try await database.schema("polling_history") 6 | .id() 7 | .field("operation", .uuid, .references("operations", "id")) 8 | .field("cid", .string, .required) 9 | .field("created_at", .datetime, .required) 10 | .field("inserted_at", .datetime, .required) 11 | .create() 12 | } 13 | 14 | func revert(on database: Database) async throws { 15 | try await database.schema("polling_history").delete() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Migrations/CreatePollingJobStatusesTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreatePollingJobStatusesTable: AsyncMigration { 4 | func prepare(on database: Database) async throws { 5 | try await database.schema("polling_job_statuses") 6 | .id() 7 | .field("history_id", .uuid, .required, .references("polling_history", "id")) 8 | .field("status", .int16, .required) 9 | .field("queued_at", .datetime, .required) 10 | .field("dequeued_at", .datetime) 11 | .field("completed_at", .datetime) 12 | .field("created_at", .datetime, .required) 13 | .field("updated_at", .datetime, .required) 14 | .create() 15 | } 16 | 17 | func revert(on database: Database) async throws { 18 | try await database.schema("polling_job_statuses").delete() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Migrations/MergeBannedDidsTableToDidsTable.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentSQL 3 | 4 | struct MergeBannedDidsTableToDidsTable: AsyncMigration { 5 | func prepare(on database: Database) async throws { 6 | try await database.transaction { transaction in 7 | let banReason = try await transaction.enum("ban_reason").read() 8 | try await transaction.schema("dids") 9 | .field("banned", .bool, .required, .custom("DEFAULT false")) 10 | .field("reason", banReason) 11 | .update() 12 | guard let sql = transaction as? SQLDatabase else { 13 | throw "not supported currently database" 14 | } 15 | for bannedDidRow in try await sql.select().columns("*").from("banned_dids").all() { 16 | let did = try bannedDidRow.decode(column: "did", as: String.self) 17 | let reason = try bannedDidRow.decode(column: "reason", as: BanReason.self) 18 | if let did = try await Did.find(did, on: transaction) { 19 | did.banned = true 20 | did.reason = reason 21 | } else { 22 | try await Did(did, banned: true, reason: reason).create(on: transaction) 23 | } 24 | } 25 | } 26 | try await database.schema("banned_dids").delete() 27 | } 28 | 29 | func revert(on database: Database) async throws { 30 | try await database.transaction { transaction in 31 | let banReason = try await transaction.enum("ban_reason").read() 32 | try await transaction.schema("banned_dids") 33 | .field("did", .string, .identifier(auto: false)) 34 | .field("reason", banReason, .required, .custom("DEFAULT 'incompatible_atproto'")) 35 | .create() 36 | for bannedDids in try await Did.query(on: transaction).filter(\.$banned == true).all() { 37 | guard let sql = transaction as? SQLDatabase else { 38 | throw "not supported currently transaction" 39 | } 40 | try await sql.update("banned_dids") 41 | .set("did", to: SQLLiteral.string(try bannedDids.requireID())) 42 | .set( 43 | "reason", to: SQLLiteral.string((bannedDids.reason ?? .incompatibleAtproto).rawValue) 44 | ) 45 | .run() 46 | } 47 | try await transaction.schema("dids") 48 | .deleteField("banned") 49 | .deleteField("reason") 50 | .update() 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/Models/Did.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | enum BanReason: String, Codable { 5 | case incompatibleAtproto = "incompatible_atproto" 6 | case invalidHandle = "invalid_handle" 7 | case missingHistory = "missing_history" 8 | } 9 | 10 | final class Did: Model, Content, @unchecked Sendable { 11 | static let schema = "dids" 12 | 13 | static func findWithOperations(_ id: Did.IDValue?, on db: Database) async throws -> Did? { 14 | guard let id, let did = try await Did.query(on: db).filter(\.$id == id).first() else { 15 | return nil 16 | } 17 | try await did.loadNonNullifiedOps(on: db) 18 | return did 19 | } 20 | 21 | @ID(custom: "did", generatedBy: .user) 22 | var id: String? 23 | 24 | @Field(key: "banned") 25 | var banned: Bool 26 | 27 | @OptionalEnum(key: "reason") 28 | var reason: BanReason? 29 | 30 | @Children(for: \.$id.$did) 31 | var operations: [Operation] 32 | 33 | private var operationsCache: [Operation]? 34 | 35 | var nonNullifiedOperations: [Operation] { 36 | guard let ops = self.operationsCache else { 37 | fatalError("not eager loaded: nonNullifiedOperations") 38 | } 39 | return ops 40 | } 41 | 42 | init() {} 43 | 44 | init(_ did: String, banned: Bool = false, reason: BanReason? = nil) { 45 | self.id = did 46 | self.banned = banned 47 | if banned { 48 | self.reason = reason ?? .incompatibleAtproto 49 | } 50 | } 51 | 52 | func loadNonNullifiedOps(on db: Database) async throws { 53 | self.operationsCache = try await Operation.query(on: db) 54 | .filter(\.$id.$did.$id == self.requireID()).filter(\.$nullified == false).all() 55 | } 56 | } 57 | 58 | extension Did { 59 | static func validate(did: String) -> Bool { 60 | guard did.hasPrefix("did:plc:") else { 61 | return false 62 | } 63 | let specificId = did.replacingOccurrences(of: "did:plc:", with: "") 64 | return 65 | if specificId.rangeOfCharacter( 66 | from: .init(charactersIn: "abcdefghijklmnopqrstuvwxyz234567").inverted) != nil 67 | { 68 | false 69 | } else { 70 | specificId.count >= 24 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Models/Handle.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | enum HandleNameError: Error { 5 | case invalidCharacter 6 | } 7 | 8 | final class Handle: Model, Content, @unchecked Sendable { 9 | static let schema = "handles" 10 | 11 | static func findBy(handleName: String, withOp: Bool = false, on db: Database) async throws 12 | -> Handle? 13 | { 14 | guard let handle = try await Handle.query(on: db).filter(\.$handle == handleName).first() else { 15 | return nil 16 | } 17 | if withOp { 18 | try await handle.loadNonNullifiedOps(on: db) 19 | } 20 | return handle 21 | } 22 | 23 | static let invalidDomainNameCharacters = CharacterSet(charactersIn: "a"..."z") 24 | .union(.init(charactersIn: "0"..."9")).union(.init(charactersIn: ".-")).inverted 25 | 26 | static func validate(_ handle: String) -> Bool { 27 | return handle.count > 3 28 | && handle.rangeOfCharacter(from: Self.invalidDomainNameCharacters) == nil 29 | } 30 | 31 | @ID(key: .id) 32 | var id: UUID? 33 | 34 | @Field(key: "handle") 35 | var handle: String 36 | 37 | @Children(for: \.$handle) 38 | var operations: [Operation] 39 | 40 | private var operationsCache: [Operation]? 41 | 42 | var nonNullifiedOperations: [Operation] { 43 | guard let ops = self.operationsCache else { 44 | fatalError("not eager loaded: nonNullifiedOperations") 45 | } 46 | return ops 47 | } 48 | 49 | init() {} 50 | 51 | init(id: UUID? = nil, _ handle: String) throws { 52 | self.id = id 53 | guard Self.validate(handle) else { 54 | throw HandleNameError.invalidCharacter 55 | } 56 | self.handle = handle 57 | } 58 | 59 | func loadNonNullifiedOps(on db: Database) async throws { 60 | self.operationsCache = try await Operation.query(on: db) 61 | .filter(\.$handle.$id == self.requireID()).filter(\.$nullified == false).all() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Models/Middleware/DidMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Redis 3 | import Vapor 4 | 5 | struct DidMiddleware: AsyncModelMiddleware { 6 | typealias Model = Did 7 | 8 | let app: Application 9 | 10 | var redis: RedisClient { 11 | self.app.redis 12 | } 13 | 14 | var logger: Logger { 15 | self.app.logger 16 | } 17 | 18 | func create(model: Model, on db: Database, next: AnyAsyncModelResponder) async throws { 19 | try await next.create(model, on: db) 20 | let countCacheKey = RedisKey(DidRepository.countCacheKey) 21 | do { 22 | if try await self.redis.exists(countCacheKey) > 0 { 23 | _ = try await self.redis.increment(countCacheKey) 24 | } 25 | _ = try await self.redis.srem( 26 | String(model.requireID().trimmingPrefix("did:plc:")), 27 | from: RedisKey(DidRepository.notFoundCacheKey)) 28 | } catch { 29 | self.logger.report(error: error) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Models/Middleware/HandleMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Redis 3 | import Vapor 4 | 5 | struct HandleMiddleware: AsyncModelMiddleware { 6 | typealias Model = Handle 7 | 8 | let app: Application 9 | 10 | var redis: RedisClient { 11 | self.app.redis 12 | } 13 | 14 | var logger: Logger { 15 | self.app.logger 16 | } 17 | 18 | func create(model: Model, on db: Database, next: AnyAsyncModelResponder) async throws { 19 | try await next.create(model, on: db) 20 | let countCacheKey = RedisKey(HandleRepository.countCacheKey) 21 | do { 22 | if try await self.redis.exists(countCacheKey) > 0 { 23 | _ = try await self.redis.increment(countCacheKey) 24 | } 25 | _ = try await self.redis.srem(model.handle, from: RedisKey(HandleRepository.notFoundCacheKey)) 26 | } catch { 27 | self.logger.report(error: error) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Models/Operation.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentPostgresDriver 3 | import Vapor 4 | 5 | final class Operation: Model, Content, @unchecked Sendable { 6 | static let schema = "operations" 7 | 8 | final class IDValue: Fields, Hashable, @unchecked Sendable { 9 | @Field(key: "cid") 10 | var cid: String 11 | 12 | @Parent(key: "did") 13 | var did: Did 14 | 15 | init() {} 16 | 17 | init(cid: String, did: Did.IDValue) { 18 | self.cid = cid 19 | self.$did.id = did 20 | } 21 | 22 | static func == (lhs: IDValue, rhs: IDValue) -> Bool { 23 | lhs.cid == rhs.cid && lhs.$did.id == rhs.$did.id 24 | } 25 | 26 | func hash(into hasher: inout Hasher) { 27 | hasher.combine(self.cid) 28 | hasher.combine(self.$did.id) 29 | } 30 | } 31 | @CompositeID var id: IDValue? 32 | 33 | var did: Did { 34 | self.id!.did 35 | } 36 | 37 | @Field(key: "nullified") 38 | var nullified: Bool 39 | 40 | @Timestamp(key: "created_at", on: .none) 41 | var createdAt: Date! 42 | 43 | @CompositeOptionalParent(prefix: "prev") 44 | var prev: Operation? 45 | 46 | @CompositeChildren(for: \.$prev) 47 | var nexts: [Operation] 48 | 49 | @OptionalParent(key: "handle") 50 | var handle: Handle? 51 | 52 | @OptionalParent(key: "pds") 53 | var pds: PersonalDataServer? 54 | 55 | init() {} 56 | 57 | init( 58 | cid: String, did: String, nullified: Bool, createdAt: Date, prev: Operation? = nil, 59 | handle: Handle? = nil, pds: PersonalDataServer? = nil 60 | ) throws { 61 | self.id = .init(cid: cid, did: did) 62 | self.nullified = nullified 63 | self.createdAt = createdAt 64 | if let prevId = try prev?.requireID() { 65 | self.$prev.id = .init(cid: prevId.cid, did: prevId.$did.id) 66 | } 67 | self.$handle.id = try handle?.requireID() 68 | self.$pds.id = try pds?.requireID() 69 | } 70 | 71 | init(cid: String, did: String, nullified: Bool, createdAt: Date) throws { 72 | self.id = .init(cid: cid, did: did) 73 | self.nullified = nullified 74 | self.createdAt = createdAt 75 | } 76 | } 77 | 78 | extension Operation: MergeSort { 79 | typealias CompareValue = Date 80 | func compareValue() -> CompareValue { 81 | self.createdAt 82 | } 83 | } 84 | 85 | extension Operation: TreeSort { 86 | typealias KeyType = Operation.IDValue 87 | func cursor() throws -> KeyType { 88 | try self.requireID() 89 | } 90 | func previousCursor() -> KeyType? { 91 | self.$prev.id 92 | } 93 | } 94 | 95 | extension Array where Element == Operation { 96 | func onlyUpdateHandle() throws -> Self { 97 | var result: Self = [] 98 | var previous: UUID? 99 | for operation in self { 100 | let handleId = operation.$handle.id 101 | if handleId != previous || previous == nil { 102 | result.append(operation) 103 | } 104 | previous = handleId 105 | } 106 | return result 107 | } 108 | } 109 | 110 | extension Operation { 111 | convenience init(exportedOp: ExportedOperation, prevOp: Operation? = nil, app: Application) 112 | async throws 113 | { 114 | try self.init( 115 | cid: exportedOp.cid, did: exportedOp.did, nullified: exportedOp.nullified, 116 | createdAt: exportedOp.createdAt) 117 | switch exportedOp.operation { 118 | case .create(let createOp): 119 | _ = try await ( 120 | self.resolveDid(on: app.didRepository), 121 | self.resolve(handle: createOp.handle, on: app.handleRepository), 122 | self.resolve(serviceEndpoint: createOp.service, on: app.db) 123 | ) 124 | case .plcOperation(let plcOp): 125 | guard 126 | let handleString = plcOp.alsoKnownAs.first(where: { $0.hasPrefix("at://") })? 127 | .replacingOccurrences(of: "at://", with: "") 128 | else { 129 | throw OpParseError.notFoundAtprotoHandle 130 | } 131 | _ = try await ( 132 | self.resolve(handle: handleString, on: app.handleRepository), 133 | self.resolve(serviceEndpoint: plcOp.services.atprotoPds.endpoint, on: app.db), 134 | self.resolve(prevOp: prevOp, prevCid: plcOp.prev, app: app) 135 | ) 136 | case .plcTombstone(let tombstoneOp): 137 | if let prevOp { 138 | try self.resolve(prev: prevOp) 139 | } else { 140 | try await self.resolve(prev: tombstoneOp.prev, on: app.db) 141 | } 142 | } 143 | } 144 | 145 | private func resolveDid(on repository: DidRepository) async throws { 146 | guard let did = self.id?.$did.id else { 147 | throw "not expected unset did" 148 | } 149 | try await repository.createIfNoxExists(did) 150 | } 151 | 152 | private func resolve(prevOp: Operation? = nil, prevCid: String? = nil, app: Application) 153 | async throws 154 | { 155 | if let prevOp { 156 | try self.resolve(prev: prevOp) 157 | return 158 | } 159 | if let prevCid { 160 | try await self.resolve(prev: prevCid, on: app.db) 161 | return 162 | } 163 | try await self.resolveDid(on: app.didRepository) 164 | } 165 | 166 | private func resolve(prev prevCid: String, on database: Database) async throws { 167 | guard let did = self.id?.$did.id, 168 | let prevOp = try await Operation.find(.init(cid: prevCid, did: did), on: database) 169 | else { 170 | throw OpParseError.unknownPreviousOp 171 | } 172 | try self.resolve(prev: prevOp) 173 | } 174 | 175 | private func resolve(prev prevOp: Operation) throws { 176 | let prevId = try prevOp.requireID() 177 | self.$prev.id = .init(cid: prevId.cid, did: prevId.$did.id) 178 | } 179 | 180 | private func resolve(handle handleName: String, on repository: HandleRepository) async throws { 181 | do { 182 | let handle = try await repository.createIfNoxExists(handleName) 183 | self.$handle.id = try handle.requireID() 184 | } catch HandleNameError.invalidCharacter { 185 | throw OpParseError.invalidHandle 186 | } 187 | } 188 | 189 | private func resolve(serviceEndpoint endpoint: String, on database: Database) async throws { 190 | if let pds = try await PersonalDataServer.query(on: database).filter(\.$endpoint == endpoint) 191 | .first() 192 | { 193 | self.$pds.id = try pds.requireID() 194 | return 195 | } 196 | let pds = PersonalDataServer(endpoint: endpoint) 197 | do { 198 | try await pds.create(on: database) 199 | } catch let error as PostgresError where error.code == .uniqueViolation { 200 | try await self.resolve(serviceEndpoint: endpoint, on: database) 201 | return 202 | } 203 | self.$pds.id = try pds.requireID() 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /Sources/Models/PersonalDataServer.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class PersonalDataServer: Model, Content, @unchecked Sendable { 5 | static let schema = "personal_data_servers" 6 | 7 | @ID(key: .id) 8 | var id: UUID? 9 | 10 | @Field(key: "endpoint") 11 | var endpoint: String 12 | 13 | @Children(for: \.$pds) 14 | var operations: [Operation] 15 | 16 | init() {} 17 | 18 | init(id: UUID? = nil, endpoint: String) { 19 | self.id = id 20 | self.endpoint = endpoint 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Models/PollingHistory.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class PollingHistory: Model, Content, @unchecked Sendable { 5 | static func getLatestWithoutErrors(on database: Database) async throws -> PollingHistory? { 6 | let errors = try await PollingJobStatus.query(on: database).filter(\.$status == .error) 7 | .all(\.$history.$id) 8 | return try await PollingHistory.query(on: database).filter(\.$failed == false) 9 | .filter(\.$id !~ errors).sort(\.$insertedAt, .descending).first() 10 | } 11 | 12 | static func getLatestCompleted(on database: Database) async throws -> PollingHistory? { 13 | try await PollingHistory.queryCompleted(on: database).sort(\.$insertedAt, .descending).first() 14 | } 15 | 16 | static func queryCompleted(on database: Database) async throws -> QueryBuilder { 17 | let errorOrRunnings = try await PollingJobStatus.query(on: database) 18 | .filter(\.$status !~ [.success, .banned]).all(\.$history.$id) 19 | return PollingHistory.queryCompleted(on: database, errorOrRunnings) 20 | } 21 | 22 | static func queryCompleted(on database: Database, _ errorOrRunnings: [UUID]) -> QueryBuilder< 23 | PollingHistory 24 | > { 25 | PollingHistory.query(on: database).filter(\.$failed == false).filter(\.$cid != .null) 26 | .filter(\.$createdAt != .null) 27 | .group(.or) { $0.filter(\.$completed == true).filter(\.$id !~ errorOrRunnings) } 28 | } 29 | 30 | static let schema = "polling_history" 31 | 32 | @ID(key: .id) 33 | var id: UUID? 34 | 35 | @OptionalField(key: "cid") 36 | var cid: String? 37 | 38 | @Children(for: \.$history) 39 | var statuses: [PollingJobStatus] 40 | 41 | @Field(key: "completed") 42 | var completed: Bool 43 | 44 | @Field(key: "failed") 45 | var failed: Bool 46 | 47 | @Timestamp(key: "created_at", on: .none) 48 | var createdAt: Date? 49 | 50 | @Timestamp(key: "inserted_at", on: .create) 51 | var insertedAt: Date! 52 | 53 | init() { 54 | self.completed = false 55 | self.failed = false 56 | } 57 | 58 | init(id: UUID? = nil, cid: String, createdAt: Date) { 59 | self.id = id 60 | self.cid = cid 61 | self.completed = false 62 | self.failed = false 63 | self.createdAt = createdAt 64 | } 65 | 66 | func running(on database: Database) async throws -> Bool { 67 | if self.completed || self.failed { 68 | return false 69 | } 70 | if self.cid == nil || self.createdAt == nil { 71 | return true 72 | } 73 | if self.$statuses.value == nil { 74 | try await self.$statuses.load(on: database) 75 | } 76 | return self.statuses.contains(where: { $0.status == .queued || $0.status == .running }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/Models/PollingJobStatus.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class PollingJobStatus: Model, Content, @unchecked Sendable { 5 | static let schema = "polling_job_statuses" 6 | 7 | @ID(custom: .id, generatedBy: .user) 8 | var id: UUID? 9 | 10 | @Parent(key: "history_id") 11 | var history: PollingHistory 12 | 13 | @Field(key: "status") 14 | var status: Status 15 | 16 | @OptionalField(key: "did") 17 | var did: String? 18 | 19 | @Timestamp(key: "queued_at", on: .none) 20 | var queuedAt: Date! 21 | 22 | @Timestamp(key: "dequeued_at", on: .none) 23 | var dequeuedAt: Date? 24 | 25 | @Timestamp(key: "completed_at", on: .none) 26 | var completedAt: Date? 27 | 28 | @Timestamp(key: "created_at", on: .create) 29 | var createdAt: Date! 30 | 31 | @Timestamp(key: "updated_at", on: .update) 32 | var updatedAt: Date! 33 | 34 | enum Status: Int16, CaseIterable, Codable { 35 | case queued, running, success, error, banned 36 | } 37 | 38 | init() {} 39 | 40 | init(id uuid: UUID, historyId: UUID, did: String?, dispatchTimestamp queuedAt: Date) { 41 | self.id = uuid 42 | self.$history.id = historyId 43 | self.did = did 44 | self.status = .queued 45 | self.queuedAt = queuedAt 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Repositories/DidRepository.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentPostgresDriver 3 | import Queues 4 | import Redis 5 | import Vapor 6 | 7 | struct DidRepository { 8 | static let countCacheKey = "count:did:plc" 9 | static let notFoundCacheKey = "not-found:did:plc" 10 | 11 | let logger: Logger 12 | let queue: Queue 13 | let cache: Cache 14 | let redis: RedisClient 15 | let db: Database 16 | 17 | init(app: Application) { 18 | self.logger = app.logger 19 | self.queue = app.queues.queue 20 | self.cache = app.cache 21 | self.redis = app.redis 22 | self.db = app.db 23 | } 24 | 25 | init(req: Request) { 26 | self.logger = req.logger 27 | self.queue = req.queue 28 | self.cache = req.cache 29 | self.redis = req.redis 30 | self.db = req.db 31 | } 32 | 33 | func count() async throws -> Int { 34 | if let cachedCount = try? await self.cache.get(Self.countCacheKey, as: Int.self) { 35 | return cachedCount 36 | } 37 | let count = try await Did.query(on: self.db).count() 38 | do { 39 | try await self.cache.set(Self.countCacheKey, to: count) 40 | } catch { 41 | self.logger.report(error: error) 42 | } 43 | return count 44 | } 45 | 46 | func search(did: String) async throws -> Bool { 47 | let didSpecificId = String(did.trimmingPrefix("did:plc:")) 48 | let cacheKey = RedisKey(Self.notFoundCacheKey) 49 | do { 50 | if try await self.redis.sismember(didSpecificId, of: cacheKey) { 51 | return false 52 | } 53 | } catch { 54 | self.logger.report(error: error) 55 | } 56 | if try await Did.find(did, on: self.db) != nil { 57 | return true 58 | } 59 | do { 60 | _ = try await self.redis.sadd(didSpecificId, to: cacheKey) 61 | } catch { 62 | self.logger.report(error: error) 63 | } 64 | return false 65 | } 66 | 67 | func ban(_ dids: String..., reason: BanReason = .incompatibleAtproto) async throws { 68 | try await self.ban(dids: dids, reason: reason) 69 | } 70 | 71 | func ban(dids: [String], reason: BanReason = .incompatibleAtproto) async throws { 72 | for did in dids { 73 | guard let did = try await Did.find(did, on: self.db) else { 74 | try await Did(did, banned: true, reason: reason).create(on: self.db) 75 | return 76 | } 77 | did.banned = true 78 | did.reason = reason 79 | try await did.update(on: self.db) 80 | } 81 | } 82 | 83 | func unban(_ did: String) async throws { 84 | guard let did = try await Did.find(did, on: self.db) else { 85 | try await Did(did).create(on: self.db) 86 | return 87 | } 88 | did.banned = false 89 | did.reason = nil 90 | try await did.update(on: self.db) 91 | } 92 | 93 | func createIfNoxExists(_ did: String) async throws { 94 | guard Did.validate(did: did) else { 95 | throw "Invalid DID Placeholder" 96 | } 97 | if try await Did.find(did, on: self.db) != nil { 98 | return 99 | } 100 | do { 101 | try await Did(did).create(on: self.db) 102 | } catch let error as PostgresError where error.code == .uniqueViolation { 103 | return 104 | } 105 | } 106 | 107 | func findOrFetch(_ did: String) async throws -> Did? { 108 | let cacheKey = RedisKey(Self.notFoundCacheKey) 109 | let didSpecificId = String(did.trimmingPrefix("did:plc:")) 110 | do { 111 | if try await self.redis.sismember(didSpecificId, of: cacheKey) { 112 | await self.dispatchFetchJob(did) 113 | return nil 114 | } 115 | } catch { 116 | self.logger.report(error: error) 117 | } 118 | if let didPlc = try await Did.findWithOperations(did, on: self.db) { 119 | return didPlc 120 | } 121 | do { 122 | _ = try await self.redis.sadd(didSpecificId, to: cacheKey) 123 | } catch { 124 | self.logger.report(error: error) 125 | } 126 | await self.dispatchFetchJob(did) 127 | return nil 128 | } 129 | 130 | private func dispatchFetchJob(_ did: String) async { 131 | let cacheKey = "last-fetch:\(did)" 132 | do { 133 | if let lastFetch = try await self.cache.get(cacheKey, as: Date.self) { 134 | self.logger.debug("\(did) already fetch in \(lastFetch)") 135 | return 136 | } 137 | try await self.queue.dispatch(FetchDidJob.self, did) 138 | try await self.cache.set(cacheKey, to: Date(), expiresIn: .days(1)) 139 | } catch { 140 | self.logger.report(error: error) 141 | } 142 | } 143 | } 144 | 145 | extension DidRepository { 146 | func ban(_ dids: String, error err: OpParseError) async throws { 147 | let reason: BanReason = 148 | switch err { 149 | case .invalidHandle: 150 | .invalidHandle 151 | case .unknownPreviousOp: 152 | .missingHistory 153 | default: 154 | .incompatibleAtproto 155 | } 156 | try await self.ban(dids, reason: reason) 157 | } 158 | } 159 | 160 | extension Application { 161 | var didRepository: DidRepository { 162 | .init(app: self) 163 | } 164 | } 165 | 166 | extension Request { 167 | var didRepository: DidRepository { 168 | .init(req: self) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Sources/Repositories/HandleRepository.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentPostgresDriver 3 | import Redis 4 | import Vapor 5 | 6 | struct HandleRepository { 7 | static let countCacheKey = "count:handle" 8 | static let notFoundCacheKey = "not-found:handle" 9 | static let searchCacheKey = "search:handle" 10 | 11 | let logger: Logger 12 | let cache: Cache 13 | let redis: RedisClient 14 | let db: Database 15 | 16 | init(app: Application) { 17 | self.redis = app.redis 18 | self.logger = app.logger 19 | self.cache = app.cache 20 | self.db = app.db 21 | } 22 | 23 | init(req: Request) { 24 | self.redis = req.redis 25 | self.logger = req.logger 26 | self.cache = req.cache 27 | self.db = req.db 28 | } 29 | 30 | func count() async throws -> Int { 31 | if let cachedCount = try? await self.cache.get(Self.countCacheKey, as: Int.self) { 32 | return cachedCount 33 | } 34 | let count = try await Handle.query(on: self.db).count() 35 | do { 36 | try await self.cache.set(Self.countCacheKey, to: count) 37 | } catch { 38 | self.logger.report(error: error) 39 | } 40 | return count 41 | } 42 | 43 | func exists(_ handleName: String) async throws -> Bool { 44 | guard Handle.validate(handleName) else { 45 | return false 46 | } 47 | let cacheKey = RedisKey(Self.notFoundCacheKey) 48 | do { 49 | if try await self.redis.sismember(handleName, of: cacheKey) { 50 | return false 51 | } 52 | } catch { 53 | self.logger.report(error: error) 54 | } 55 | if try await Handle.findBy(handleName: handleName, on: self.db) != nil { 56 | return true 57 | } 58 | do { 59 | _ = try await self.redis.sadd(handleName, to: cacheKey) 60 | } catch { 61 | self.logger.report(error: error) 62 | } 63 | return false 64 | } 65 | 66 | func search(prefix handlePrefix: String) async throws -> [String]? { 67 | guard Handle.validate(handlePrefix) else { 68 | return nil 69 | } 70 | let cacheKey = RedisKey("\(Self.searchCacheKey):\(handlePrefix)") 71 | do { 72 | if try await self.redis.exists(cacheKey) > 0 { 73 | return try await self.redis.zrange(from: cacheKey, fromIndex: 0, as: String.self) 74 | .compactMap { if let h = $0, h != "." { h } else { nil } } 75 | } 76 | } catch { 77 | self.logger.report(error: error) 78 | } 79 | let query = 80 | if !Environment.getBool("DISABLE_NON_C_LOCALE_POSTGRES_SEARCH_OPTIMIZE") 81 | && self.db is PostgresDatabase 82 | { 83 | Handle.query(on: self.db).filter(\.$handle >= handlePrefix) 84 | .filter( 85 | \.$handle 86 | <= .custom( 87 | SQLFunction("CONCAT", args: SQLLiteral.string(handlePrefix), SQLLiteral.string("~")) 88 | )) 89 | } else { 90 | Handle.query(on: self.db).filter(\.$handle =~ handlePrefix) 91 | } 92 | let handles = try await query.all().map { $0.handle } 93 | do { 94 | if handles.count > 0 { 95 | _ = try await self.redis.zadd(handles, to: cacheKey) 96 | } else { 97 | _ = try await self.redis.zadd(".", to: cacheKey) 98 | } 99 | _ = try await self.redis.expire(cacheKey, after: .minutes(30)) 100 | _ = try await self.redis.sadd(handlePrefix, to: RedisKey(Self.searchCacheKey)) 101 | } catch { 102 | self.logger.report(error: error) 103 | } 104 | return handles 105 | } 106 | 107 | func createIfNoxExists(_ handleName: String) async throws -> Handle { 108 | if let handle = try await Handle.findBy(handleName: handleName, on: self.db) { 109 | return handle 110 | } 111 | let handle = try Handle(handleName) 112 | do { 113 | try await handle.create(on: self.db) 114 | return handle 115 | } catch let error as PostgresError where error.code == .uniqueViolation { 116 | return try await Handle.findBy(handleName: handleName, on: self.db)! 117 | } 118 | } 119 | 120 | func findWithOperations(handleName: String) async throws -> Handle? { 121 | let cacheKey = RedisKey(Self.notFoundCacheKey) 122 | do { 123 | if try await self.redis.sismember(handleName, of: cacheKey) { 124 | return nil 125 | } 126 | } catch { 127 | self.logger.report(error: error) 128 | } 129 | if let handle = try await Handle.findBy(handleName: handleName, withOp: true, on: self.db) { 130 | return handle 131 | } 132 | do { 133 | _ = try await self.redis.sadd(handleName, to: cacheKey) 134 | } catch { 135 | self.logger.report(error: error) 136 | } 137 | return nil 138 | } 139 | } 140 | 141 | extension Application { 142 | var handleRepository: HandleRepository { 143 | .init(app: self) 144 | } 145 | } 146 | 147 | extension Request { 148 | var handleRepository: HandleRepository { 149 | .init(req: self) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Sources/Utilities/ExportedOperation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vapor 3 | 4 | enum OpParseError: Error { 5 | case notUsedInAtproto(String, Date) 6 | case notFoundAtprotoHandle 7 | case invalidHandle 8 | case unknownPreviousOp 9 | } 10 | 11 | enum CompatibleOperationOrTombstone: Encodable { 12 | case create(CreateOperation) 13 | case plcOperation(PlcOperation) 14 | case plcTombstone(PlcTombstone) 15 | 16 | func encode(to encoder: Encoder) throws { 17 | switch self { 18 | case .create(let createOp): try createOp.encode(to: encoder) 19 | case .plcOperation(let plcOp): try plcOp.encode(to: encoder) 20 | case .plcTombstone(let tombstoneOp): try tombstoneOp.encode(to: encoder) 21 | } 22 | } 23 | } 24 | 25 | enum OpType: String, Content { 26 | case create 27 | case plcOperation = "plc_operation" 28 | case plcTombstone = "plc_tombstone" 29 | } 30 | 31 | struct PlcOperation: Encodable { 32 | let sig: String 33 | var type: OpType { .plcOperation } 34 | let prev: String? 35 | 36 | let services: Services 37 | 38 | let alsoKnownAs: [String] 39 | let rotationKeys: [String] 40 | 41 | struct VerificationMethods: Content { 42 | let atproto: String 43 | } 44 | let verificationMethods: VerificationMethods 45 | 46 | private enum CodingKeys: String, CodingKey { 47 | case sig, type, prev, services, alsoKnownAs, rotationKeys, verificationMethods 48 | } 49 | 50 | func encode(to encoder: Encoder) throws { 51 | var container = encoder.container(keyedBy: CodingKeys.self) 52 | try container.encode(self.sig, forKey: .sig) 53 | try container.encode(self.type, forKey: .type) 54 | try container.encode(self.prev, forKey: .prev) 55 | try container.encode(self.services, forKey: .services) 56 | try container.encode(self.alsoKnownAs, forKey: .alsoKnownAs) 57 | try container.encode(self.rotationKeys, forKey: .rotationKeys) 58 | try container.encode(self.verificationMethods, forKey: .verificationMethods) 59 | } 60 | } 61 | struct Services: Content { 62 | struct Service: Content { 63 | let type: String // AtprotoPersonalDataServer 64 | let endpoint: String 65 | } 66 | let atprotoPds: Service 67 | 68 | private enum CodingKeys: String, CodingKey { 69 | case atprotoPds = "atproto_pds" 70 | } 71 | } 72 | 73 | struct CreateOperation: Encodable { 74 | let sig: String 75 | var type: OpType { .create } 76 | var prev: String? { nil } 77 | 78 | let handle: String 79 | let service: String 80 | let signingKey: String 81 | let recoveryKey: String 82 | 83 | private enum CodingKeys: String, CodingKey { 84 | case sig, type, prev, handle, service, signingKey, recoveryKey 85 | } 86 | 87 | func encode(to encoder: Encoder) throws { 88 | var container = encoder.container(keyedBy: CodingKeys.self) 89 | try container.encode(self.sig, forKey: .sig) 90 | try container.encode(self.type, forKey: .type) 91 | try container.encode(self.prev, forKey: .prev) 92 | try container.encode(self.handle, forKey: .handle) 93 | try container.encode(self.service, forKey: .service) 94 | try container.encode(self.signingKey, forKey: .signingKey) 95 | try container.encode(self.recoveryKey, forKey: .recoveryKey) 96 | } 97 | } 98 | 99 | struct PlcTombstone: Encodable { 100 | let sig: String 101 | var type: OpType { .plcTombstone } 102 | let prev: String 103 | 104 | private enum CodingKeys: String, CodingKey { 105 | case sig, type, prev 106 | } 107 | 108 | func encode(to encoder: Encoder) throws { 109 | var container = encoder.container(keyedBy: CodingKeys.self) 110 | try container.encode(self.sig, forKey: .sig) 111 | try container.encode(self.type, forKey: .type) 112 | try container.encode(self.prev, forKey: .prev) 113 | } 114 | } 115 | 116 | struct ExportedOperation: Content { 117 | var did: String 118 | var operation: CompatibleOperationOrTombstone 119 | var cid: String 120 | var nullified: Bool 121 | var createdAt: Date 122 | 123 | private struct GenericOperaion: Decodable { 124 | let sig: String 125 | let type: OpType 126 | let prev: String? 127 | 128 | let handle: String? 129 | let service: String? 130 | let signingKey: String? 131 | let recoveryKey: String? 132 | 133 | let services: [String: Services.Service]? 134 | let alsoKnownAs: [String]? 135 | let rotationKeys: [String]? 136 | let verificationMethods: [String: String]? 137 | } 138 | 139 | private enum CodingKeys: String, CodingKey { 140 | case did 141 | case operation 142 | case cid 143 | case nullified 144 | case createdAt 145 | } 146 | 147 | public init(from decoder: Decoder) throws { 148 | let container = try decoder.container(keyedBy: CodingKeys.self) 149 | self.did = try container.decode(String.self, forKey: .did) 150 | self.cid = try container.decode(String.self, forKey: .cid) 151 | self.nullified = try container.decode(Bool.self, forKey: .nullified) 152 | self.createdAt = try container.decode(Date.self, forKey: .createdAt) 153 | let operation = try container.decode(GenericOperaion.self, forKey: .operation) 154 | switch operation.type { 155 | case .create: 156 | self.operation = .create( 157 | .init( 158 | sig: operation.sig, handle: operation.handle!, service: operation.service!, 159 | signingKey: operation.signingKey!, recoveryKey: operation.recoveryKey!)) 160 | case .plcOperation: 161 | guard let signingKey = operation.verificationMethods?["atproto"], 162 | let atprotoPds = operation.services?["atproto_pds"], 163 | atprotoPds.type == "AtprotoPersonalDataServer" 164 | else { 165 | throw OpParseError.notUsedInAtproto(self.did, self.createdAt) 166 | } 167 | self.operation = .plcOperation( 168 | .init( 169 | sig: operation.sig, prev: operation.prev, services: Services(atprotoPds: atprotoPds), 170 | alsoKnownAs: operation.alsoKnownAs!, rotationKeys: operation.rotationKeys!, 171 | verificationMethods: PlcOperation.VerificationMethods(atproto: signingKey))) 172 | case .plcTombstone: 173 | self.operation = .plcTombstone(.init(sig: operation.sig, prev: operation.prev!)) 174 | } 175 | } 176 | } 177 | 178 | extension ExportedOperation: TreeSort { 179 | typealias KeyType = String 180 | func cursor() -> KeyType { 181 | self.cid 182 | } 183 | func previousCursor() -> KeyType? { 184 | switch self.operation { 185 | case .create: nil 186 | case .plcOperation(let plcOp): plcOp.prev 187 | case .plcTombstone(let tombstoneOp): tombstoneOp.prev 188 | } 189 | } 190 | } 191 | 192 | extension Array where Element == ExportedOperation { 193 | func insert(app: Application) async throws { 194 | let (updateOp, createOp) = try await self.toChangeOperation(app: app) 195 | try await app.db.transaction { transaction in 196 | for op in updateOp { 197 | try await op.update(on: transaction) 198 | } 199 | for op in createOp { 200 | try await op.create(on: transaction) 201 | } 202 | } 203 | } 204 | 205 | private func toChangeOperation(app: Application) async throws -> ( 206 | nullify: [Operation], newOps: [Operation] 207 | ) { 208 | var nullifyOps: [Operation] = [] 209 | var newOps: [Operation] = [] 210 | var existOps: [Operation.IDValue: Operation] = [:] 211 | for exportedOp in self { 212 | if let operation = try await Operation.find( 213 | .init(cid: exportedOp.cid, did: exportedOp.did), on: app.db) 214 | { 215 | existOps[try operation.requireID()] = operation 216 | if operation.nullified != exportedOp.nullified { 217 | operation.nullified = exportedOp.nullified 218 | nullifyOps.append(operation) 219 | } 220 | continue 221 | } 222 | let prevOp: Operation? = 223 | switch exportedOp.operation { 224 | case .plcOperation(let op): 225 | if let prev = op.prev { existOps[.init(cid: prev, did: exportedOp.did)] } else { nil } 226 | case .plcTombstone(let op): existOps[.init(cid: op.prev, did: exportedOp.did)] 227 | default: nil 228 | } 229 | let operation = try await Operation(exportedOp: exportedOp, prevOp: prevOp, app: app) 230 | existOps[try operation.requireID()] = operation 231 | newOps.append(operation) 232 | } 233 | return (nullifyOps, newOps) 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /Sources/Utilities/MergeSort.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol MergeSort { 4 | associatedtype CompareValue: Comparable 5 | func compareValue() -> CompareValue 6 | } 7 | 8 | extension Array where Element: MergeSort { 9 | func mergeSort() -> Self { 10 | guard self.count > 1 else { return self } 11 | 12 | let middleIndex = self.count / 2 13 | let leftArray = Array(self[.. Self { 20 | var leftIndex = 0 21 | var rightIndex = 0 22 | var mergedArray = Self() 23 | 24 | while leftIndex < left.count && rightIndex < right.count { 25 | if left[leftIndex].compareValue() < right[rightIndex].compareValue() { 26 | mergedArray.append(left[leftIndex]) 27 | leftIndex += 1 28 | } else { 29 | mergedArray.append(right[rightIndex]) 30 | rightIndex += 1 31 | } 32 | } 33 | 34 | mergedArray += Array(left[leftIndex...]) 35 | mergedArray += Array(right[rightIndex...]) 36 | 37 | return mergedArray 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Utilities/TreeSort.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol TreeSort { 4 | associatedtype KeyType: Hashable 5 | func cursor() throws -> KeyType 6 | func previousCursor() -> KeyType? 7 | } 8 | 9 | extension Array where Element: TreeSort { 10 | func treeSort() throws -> [Self] { 11 | let keys: [Element.KeyType] = try self.map { try $0.cursor() } 12 | var dict: [Element.KeyType: Element] = [:] 13 | var roots: Self = [] 14 | for item in self { 15 | guard let prev = item.previousCursor(), keys.contains(prev), dict[prev] == nil else { 16 | roots.append(item) 17 | continue 18 | } 19 | dict[prev] = item 20 | } 21 | if roots.isEmpty { 22 | throw "Invalid item tree" 23 | } 24 | return try roots.map { 25 | var tree = [$0] 26 | var currentId = try $0.cursor() 27 | while let next = dict[currentId] { 28 | tree.append(next) 29 | currentId = try next.cursor() 30 | } 31 | return tree 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Views/BaseContext.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | protocol BaseContext: Content { 4 | var title: String? { get } 5 | var route: String { get } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Views/ExternalLinkTag.swift: -------------------------------------------------------------------------------- 1 | import Leaf 2 | 3 | enum ExternalLinkTagError: Error { 4 | case missingHRefParameter 5 | } 6 | 7 | struct ExternalLinkTag: UnsafeUnescapedLeafTag { 8 | func render(_ ctx: LeafContext) throws -> LeafData { 9 | let (href, text) = try self.parameters(ctx.parameters) 10 | return LeafData.string(self.outerText(ctx.body, href: href, text: text)) 11 | } 12 | 13 | private func outerText(_ body: [Syntax]?, href: String, text: String) -> String { 14 | if let body { 15 | "\(text) \(self.innerText(body))" 16 | } else { 17 | "\(text)" 18 | } 19 | } 20 | 21 | private func parameters(_ parameters: [LeafData]) throws -> (String, String) { 22 | switch parameters.count { 23 | case 0: 24 | throw ExternalLinkTagError.missingHRefParameter 25 | case 1: 26 | guard let href = parameters[0].string else { 27 | throw ExternalLinkTagError.missingHRefParameter 28 | } 29 | return (href, href) 30 | default: 31 | guard let href = parameters[0].string else { 32 | throw ExternalLinkTagError.missingHRefParameter 33 | } 34 | return (href, parameters[1].string ?? href) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Views/NavLinkTag.swift: -------------------------------------------------------------------------------- 1 | import Leaf 2 | 3 | enum NavLinkTagError: Error { 4 | case missingHRefParameter 5 | case missingRouteParameter 6 | case missingBodyParameter 7 | } 8 | 9 | struct NavLinkTag: UnsafeUnescapedLeafTag { 10 | func render(_ ctx: LeafContext) throws -> LeafData { 11 | let (href, isCurrent) = try self.parameters(ctx.parameters) 12 | guard let body = ctx.body else { 13 | throw NavLinkTagError.missingBodyParameter 14 | } 15 | return LeafData.string(self.outerText(self.innerText(body), href: href, isWrap: isCurrent)) 16 | } 17 | 18 | private func outerText(_ innerText: String, href: String, isWrap: Bool) -> String { 19 | if isWrap { 20 | innerText 21 | } else { 22 | "\(innerText)" 23 | } 24 | } 25 | 26 | private func parameters(_ parameters: [LeafData]) throws -> (String, Bool) { 27 | switch parameters.count { 28 | case 0: 29 | throw NavLinkTagError.missingHRefParameter 30 | case 1: 31 | throw NavLinkTagError.missingRouteParameter 32 | default: 33 | guard let href = parameters[0].string else { 34 | throw NavLinkTagError.missingHRefParameter 35 | } 36 | guard let route = parameters[1].string else { 37 | throw NavLinkTagError.missingRouteParameter 38 | } 39 | return (href, route == "GET \(href)") 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Views/SearchContext.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | protocol SearchContext: BaseContext { 4 | var count: Int { get } 5 | var currentValue: String? { get } 6 | var message: String? { get } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/configure.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentPostgresDriver 3 | import QueuesRedisDriver 4 | import Vapor 5 | 6 | func registerCustomCoder() { 7 | // milliseconds RFC 3339 FormatStyle 8 | let formatStyle = Date.ISO8601FormatStyle(includingFractionalSeconds: true) 9 | 10 | let encoder = JSONEncoder.custom( 11 | dates: .custom({ (date, encoder) in 12 | var container = encoder.singleValueContainer() 13 | try container.encode(date.formatted(formatStyle)) 14 | })) 15 | ContentConfiguration.global.use(encoder: encoder, for: .json) 16 | ContentConfiguration.global.use(encoder: encoder, for: .jsonAPI) 17 | 18 | let decoder = JSONDecoder.custom( 19 | dates: .custom({ decoder in 20 | let container = try decoder.singleValueContainer() 21 | let string = try container.decode(String.self) 22 | do { 23 | return try formatStyle.parse(string) 24 | } catch { 25 | throw DecodingError.dataCorrupted( 26 | DecodingError.Context( 27 | codingPath: decoder.codingPath, 28 | debugDescription: "Expected date string to be ISO8601-formatted.")) 29 | } 30 | })) 31 | ContentConfiguration.global.use(decoder: decoder, for: .json) 32 | ContentConfiguration.global.use(decoder: decoder, for: .jsonAPI) 33 | } 34 | 35 | func connectDatabase(_ app: Application) { 36 | app.databases.use( 37 | .postgres( 38 | configuration: .init( 39 | hostname: Environment.get("DATABASE_HOST", "localhost"), 40 | port: Environment.getInt("DATABASE_PORT", SQLPostgresConfiguration.ianaPortNumber), 41 | username: Environment.get("DATABASE_USERNAME", "vapor_username"), 42 | password: Environment.get("DATABASE_PASSWORD", "vapor_password"), 43 | database: Environment.get("DATABASE_NAME", "vapor_database"), tls: .disable), 44 | connectionPoolTimeout: .seconds(60)), as: .psql) 45 | } 46 | 47 | func connectRedis(_ app: Application) throws { 48 | app.redis.configuration = try .init( 49 | url: Environment.get("REDIS_URL", "redis://localhost:6379"), 50 | pool: .init(connectionRetryTimeout: .seconds(60))) 51 | } 52 | 53 | func startJobQueuing(_ app: Application) throws { 54 | app.queues.use(.redis(app.redis.configuration!)) 55 | 56 | if Environment.getBool("INPROCESS_JOB") { 57 | try app.queues.startInProcessJobs(on: .default) 58 | try app.queues.startInProcessJobs(on: .polling) 59 | try app.queues.startScheduledJobs() 60 | } 61 | } 62 | 63 | // configures your application 64 | public func configure(_ app: Application) async throws { 65 | connectDatabase(app) 66 | try connectRedis(app) 67 | 68 | app.caches.use(.redis) 69 | 70 | registerCustomCoder() 71 | registerMigrations(app) 72 | registerJobs(app) 73 | registerCommands(app) 74 | registerViews(app) 75 | registerMiddleware(app) 76 | 77 | try registerRoutes(app) 78 | 79 | try startJobQueuing(app) 80 | } 81 | -------------------------------------------------------------------------------- /Sources/entrypoint.swift: -------------------------------------------------------------------------------- 1 | import Logging 2 | import NIOCore 3 | import NIOPosix 4 | import Vapor 5 | 6 | @main 7 | enum Entrypoint { 8 | static func main() async throws { 9 | var env = try Environment.detect() 10 | try LoggingSystem.bootstrap(from: &env) 11 | 12 | let app = try await Application.make(env) 13 | 14 | // This attempts to install NIO as the Swift Concurrency global executor. 15 | // You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency. 16 | // Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down. 17 | // If enabled, you should be careful about calling async functions before this point as it can cause assertion failures. 18 | let executorTakeoverSuccess = 19 | NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() 20 | app.logger.debug( 21 | "Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", 22 | metadata: ["success": .stringConvertible(executorTakeoverSuccess)]) 23 | 24 | do { 25 | try await configure(app) 26 | } catch { 27 | app.logger.report(error: error) 28 | try? await app.asyncShutdown() 29 | throw error 30 | } 31 | 32 | try await app.execute() 33 | try await app.asyncShutdown() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/registerCommands.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | func registerCommands(_ app: Application) { 4 | app.commands.use(ImportDidCommand(), as: "import") 5 | app.commands.use(ImportExportedLogCommand(), as: "import-exported-log") 6 | app.commands.use(CleanupCacheCommand(), as: "cleanup-cache") 7 | } 8 | -------------------------------------------------------------------------------- /Sources/registerJobs.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | func registerJobs(_ app: Application) { 4 | app.queues.add(ImportAuditableLogJob()) 5 | app.queues.add(FetchDidJob()) 6 | app.queues.add(ImportExportedLogJob()) 7 | app.queues.add(PollingJobNotificationHook(on: app.db)) 8 | app.queues.add(PollingPlcServerExportJob()) 9 | 10 | let pollingInterval = Environment.getInt("POLLING_INTERVAL", 30) 11 | let pollingStart = Environment.getInt("POLLING_START_AT_MINUTES", 20) 12 | let afterRecovery = Environment.getInt("AFTER_POLLING_RECOVERLY_MINUTES", 15) 13 | app.queues.scheduleEvery(ScheduledPollingJob(), stride: pollingInterval, from: pollingStart) 14 | app.queues.scheduleEvery( 15 | ScheduledPollingRecoveryJob(), stride: pollingInterval, from: pollingStart + afterRecovery) 16 | 17 | if Environment.getBool("DISABLE_POLLING_HISTORY_CLEANUP") { 18 | return 19 | } 20 | let cleanupInterval = Environment.getInt("POLLING_HISTORY_CLEANUP_INTERVAL", 15) 21 | let cleanupStart = Environment.getInt("POLLING_HISTORY_CLEANUP_START_AT_MINUTES", 10) 22 | app.queues.scheduleEvery( 23 | ScheduledPollingHistoryCleanupJob(), stride: cleanupInterval, from: cleanupStart) 24 | } 25 | -------------------------------------------------------------------------------- /Sources/registerMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | func registerMiddleware(_ app: Application) { 4 | app.middleware = .init() 5 | app.middleware.use(RouteLoggingMiddleware()) 6 | app.middleware.use(ErrorMiddleware(environment: app.environment)) 7 | // serve files from /Public folder 8 | app.middleware.use( 9 | FileMiddleware(publicDirectory: app.directory.publicDirectory, directoryAction: .redirect)) 10 | 11 | // database middleware 12 | app.databases.middleware.use(DidMiddleware(app: app), on: .psql) 13 | app.databases.middleware.use(HandleMiddleware(app: app), on: .psql) 14 | } 15 | -------------------------------------------------------------------------------- /Sources/registerMigrations.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | func registerMigrations(_ app: Application) { 5 | app.migrations.add(CreateDidsTable()) 6 | app.migrations.add(CreateHandlesTable()) 7 | app.migrations.add(CreatePersonalDataServersTable()) 8 | app.migrations.add(CreateOperationsTable()) 9 | app.migrations.add(CreatePollingHistoryTable()) 10 | app.migrations.add(AddFailedColumnToPollingHistoryTable()) 11 | app.migrations.add(AddCompletedColumnToPollingHistoryTable()) 12 | app.migrations.add(ChangePrimaryKeyToNaturalKeyOfDidAndCid()) 13 | app.migrations.add(CreatePollingJobStatusesTable()) 14 | app.migrations.add(ChangeToNullableCidAndCreatedAtColumn()) 15 | app.migrations.add(AddDidColumnToPollingJobStatusesTable()) 16 | app.migrations.add(CreateBannedDidsTable()) 17 | app.migrations.add(AddReasonColumnToBannedDidsTable()) 18 | app.migrations.add(MergeBannedDidsTableToDidsTable()) 19 | app.migrations.add(ChangePrimaryKeyToCompositeDidAndCid()) 20 | app.migrations.add(AddPrevDidColumnForPrevForeignKey()) 21 | app.migrations.add(CreateIndexForForeignKeyOfOperationsTable()) 22 | } 23 | -------------------------------------------------------------------------------- /Sources/registerRoutes.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | struct IndexContext: BaseContext { 5 | private(set) var title: String? 6 | let route: String 7 | let latestPolling: PollingHistory? 8 | } 9 | 10 | func registerRoutes(_ app: Application) throws { 11 | app.get { req -> View in 12 | try await req.view.render( 13 | "index", 14 | IndexContext( 15 | route: req.route?.description ?? "", 16 | latestPolling: try await PollingHistory.getLatestCompleted(on: req.db))) 17 | } 18 | 19 | try app.register(collection: DidController()) 20 | try app.register(collection: HandleController()) 21 | } 22 | -------------------------------------------------------------------------------- /Sources/registerViews.swift: -------------------------------------------------------------------------------- 1 | import Leaf 2 | import Vapor 3 | 4 | func registerViews(_ app: Application) { 5 | app.views.use(.leaf) 6 | app.leaf.tags["externalLink"] = ExternalLinkTag() 7 | app.leaf.tags["navLink"] = NavLinkTag() 8 | } 9 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | x-shared_environment: &shared_environment 2 | LOG_LEVEL: info 3 | REDIS_URL: redis://redis:6379 4 | DATABASE_HOST: db 5 | DATABASE_NAME: vapor_database 6 | DATABASE_USERNAME: vapor_username 7 | DATABASE_PASSWORD: vapor_password 8 | 9 | services: 10 | 11 | db: 12 | image: postgres:17-alpine 13 | restart: always 14 | healthcheck: 15 | test: ["CMD", "pg_isready", "-U", "vapor_username", "-d", "vapor_database"] 16 | timeout: 60s 17 | volumes: 18 | - "db_data:/var/lib/postgresql/data/pgdata" 19 | environment: 20 | PGDATA: /var/lib/postgresql/data/pgdata 21 | POSTGRES_USER: vapor_username 22 | POSTGRES_PASSWORD: vapor_password 23 | POSTGRES_DB: vapor_database 24 | POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" 25 | 26 | redis: 27 | image: redis:8-alpine 28 | restart: always 29 | healthcheck: 30 | test: ["CMD", "redis-cli", "--raw", "incr", "ping"] 31 | timeout: 60s 32 | volumes: 33 | - "redis_data:/data" 34 | 35 | app: 36 | image: ghcr.io/kphrx/plc-handle-tracker:latest 37 | build: 38 | context: . 39 | restart: always 40 | healthcheck: 41 | test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/8080' || exit 1"] 42 | timeout: 15s 43 | environment: 44 | <<: *shared_environment 45 | depends_on: 46 | - db 47 | - redis 48 | # ports: 49 | # - '8080:8080' 50 | command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 51 | 52 | migrate: 53 | image: ghcr.io/kphrx/plc-handle-tracker:latest 54 | build: 55 | context: . 56 | environment: 57 | <<: *shared_environment 58 | depends_on: 59 | - db 60 | command: ["migrate", "--yes"] 61 | deploy: 62 | replicas: 0 63 | revert: 64 | image: ghcr.io/kphrx/plc-handle-tracker:latest 65 | build: 66 | context: . 67 | environment: 68 | <<: *shared_environment 69 | depends_on: 70 | - db 71 | command: ["migrate", "--revert", "--yes"] 72 | deploy: 73 | replicas: 0 74 | 75 | default_worker: 76 | image: ghcr.io/kphrx/plc-handle-tracker:latest 77 | build: 78 | context: . 79 | restart: always 80 | environment: 81 | <<: *shared_environment 82 | depends_on: 83 | - db 84 | - redis 85 | command: ["queues"] 86 | # deploy: 87 | # replicas: 2 # scaling 88 | 89 | polling_worker: 90 | image: ghcr.io/kphrx/plc-handle-tracker:latest 91 | build: 92 | context: . 93 | restart: always 94 | environment: 95 | <<: *shared_environment 96 | depends_on: 97 | - db 98 | - redis 99 | command: ["queues", "--queue", "polling"] 100 | # deploy: 101 | # replicas: 2 # scaling 102 | 103 | schedule_worker: 104 | image: ghcr.io/kphrx/plc-handle-tracker:latest 105 | build: 106 | context: . 107 | restart: always 108 | environment: 109 | <<: *shared_environment 110 | depends_on: 111 | - db 112 | - redis 113 | command: ["queues", "--scheduled"] 114 | 115 | volumes: 116 | db_data: 117 | redis_data: 118 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "enabledManagers": ["docker-compose"] 7 | } 8 | --------------------------------------------------------------------------------