├── .dockerignore ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .swift-format ├── .vscode └── hummingbird.code-snippets ├── Dockerfile ├── HttpRequests ├── CreateStatus.http ├── MastodonAPI.http └── swiftodon.http ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── App │ ├── ActivityPubData │ │ ├── Link.swift │ │ ├── Object.swift │ │ ├── ObjectOrLinkDecoder.swift │ │ └── ObjectProtocols.swift │ ├── App.swift │ ├── Application+build.swift │ ├── Controllers │ │ ├── HTMLController.swift │ │ ├── PersonController.swift │ │ ├── StatusController.swift │ │ ├── TimelineController.swift │ │ ├── WebAuthnController.swift │ │ └── WellKnownController.swift │ ├── Extensions │ │ ├── Fluent+Mustache.swift │ │ └── html.swift │ ├── Middleware │ │ ├── JWTAuth.swift │ │ ├── RequestLogger.swift │ │ └── WebAuthnSession.swift │ ├── Repository │ │ ├── Fluent │ │ │ ├── FluentPersonStorage.swift │ │ │ ├── FluentStatusStorage.swift │ │ │ └── FluentWebAuthNStorage.swift │ │ ├── Person.swift │ │ ├── Status.swift │ │ └── WebAuthNModel.swift │ ├── RequestContext.swift │ ├── Resources │ │ └── Templates │ │ │ └── home.mustache │ ├── Shared │ │ └── DateTime.swift │ └── UserToken │ │ └── UserToken.swift ├── KeyStorage │ ├── InMemoryKeyStorage │ │ └── InMemoryKeyStorage.swift │ ├── Key.swift │ ├── KeyStorage.swift │ └── sqliteStorage │ │ └── sqliteKeyStorage.swift ├── MastodonData │ ├── Account.swift │ ├── Link.swift │ ├── Status.swift │ └── WebFinger.swift ├── SignatureMiddleware │ ├── PublicKeySignature.swift │ └── SigningBehaviour.md └── Storage │ └── Storage.swift ├── Tests ├── AppTests │ ├── AppTests.swift │ └── SharedTests.swift ├── MastodonData │ └── MastodonDataTests.swift └── SignatureMiddleware │ └── SignatureMiddlewareTests.swift ├── public ├── js │ └── webauthn.js ├── login.html └── register.html └── status.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .build 2 | .git -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - '**.swift' 9 | - '**.yml' 10 | pull_request: 11 | workflow_dispatch: 12 | 13 | jobs: 14 | linux: 15 | runs-on: ubuntu-latest 16 | env: 17 | JWT_SECRET: 'some test value' 18 | strategy: 19 | matrix: 20 | image: 21 | - 'swift:6.0' 22 | container: 23 | image: ${{ matrix.image }} 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | - name: Test 28 | run: swift test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /.devContainer 5 | /Packages 6 | /*.xcodeproj 7 | xcuserdata/ 8 | /.vscode/* 9 | !/.vscode/hummingbird.code-snippets 10 | db.sqlite 11 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "lineLength": 120, 4 | "indentation": { 5 | "spaces": 4 6 | }, 7 | "lineBreakBeforeEachArgument": true, 8 | "indentConditionalCompilationBlocks": false, 9 | "prioritizeKeepingFunctionOutputTogether": true, 10 | "rules": { 11 | "AlwaysUseLowerCamelCase": false, 12 | "AmbiguousTrailingClosureOverload": false, 13 | "NoBlockComments": false, 14 | "OrderedImports": true, 15 | "UseLetInEveryBoundCaseVariable": false, 16 | "UseSynthesizedInitializer": false 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/hummingbird.code-snippets: -------------------------------------------------------------------------------- 1 | {{#HB_VSCODE_SNIPPETS}} 2 | { 3 | "Endpoint": { 4 | "prefix": "endpoint", 5 | "body": [ 6 | "@Sendable func ${1:functionName}(request: Request, context: some RequestContext) async throws -> ${2:returnValue} {", 7 | "\t${3:statements}", 8 | "}" 9 | ], 10 | "description": "Hummingbird: Endpoint function" 11 | }, 12 | "RouterMiddleware": { 13 | "prefix": "middleware", 14 | "body": [ 15 | "struct ${1:Name}Middleware: RouterMiddleware {", 16 | "\tfunc handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {", 17 | "\t\t${2:try await next(request, context)}", 18 | "\t}", 19 | "}" 20 | ], 21 | "description": "Hummingbird: RouterMiddleware" 22 | }, 23 | "try context.parameters.require": { 24 | "prefix": "parameter", 25 | "body": [ 26 | "try context.parameters.require(\"${2:parameterName}\")" 27 | ], 28 | "description": "Hummingbird: Extract parameter from request path" 29 | }, 30 | "try await request.decode": { 31 | "prefix": "decode", 32 | "body": [ 33 | "try await request.decode(as: ${1:Type}.self, context: context)" 34 | ], 35 | "description": "Hummingbird: Decode request" 36 | }, 37 | "throw HTTPError": { 38 | "prefix": "httperror", 39 | "body": [ 40 | "throw HTTPError(${code})" 41 | ], 42 | "description": "Hummingbird: Decode request" 43 | } 44 | } 45 | {{/HB_VSCODE_SNIPPETS}} 46 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM swift:6.0-jammy 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 | 23 | # Copy entire repo into container 24 | COPY . . 25 | 26 | # Build everything, with optimizations, with static linking, and using jemalloc 27 | RUN swift build -c release \ 28 | --static-swift-stdlib \ 29 | -Xlinker -ljemalloc 30 | 31 | # Switch to the staging area 32 | WORKDIR /staging 33 | 34 | # Copy main executable to staging area 35 | RUN cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./ 36 | 37 | # Copy static swift backtracer binary to staging area 38 | RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ 39 | 40 | # Copy resources bundled by SPM to staging area 41 | RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; 42 | 43 | # Copy any resouces from the public directory and views directory if the directories exist 44 | # Ensure that by default, neither the directory nor any of its contents are writable. 45 | RUN [ -d /build/public ] && { mv /build/public ./public && chmod -R a-w ./public; } || true 46 | 47 | # ================================ 48 | # Run image 49 | # ================================ 50 | FROM ubuntu:jammy 51 | 52 | # Make sure all system packages are up to date, and install only essential packages. 53 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 54 | && apt-get -q update \ 55 | && apt-get -q dist-upgrade -y \ 56 | && apt-get -q install -y \ 57 | libjemalloc2 \ 58 | ca-certificates \ 59 | tzdata \ 60 | # If your app or its dependencies import FoundationNetworking, also install `libcurl4`. 61 | # libcurl4 \ 62 | # If your app or its dependencies import FoundationXML, also install `libxml2`. 63 | # libxml2 \ 64 | && rm -r /var/lib/apt/lists/* 65 | 66 | # Create a hummingbird user and group with /app as its home directory 67 | RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app hummingbird 68 | 69 | # Switch to the new home directory 70 | WORKDIR /app 71 | 72 | # Copy built executable and any staged resources from builder 73 | COPY --from=build --chown=hummingbird:hummingbird /staging /app 74 | 75 | # Provide configuration needed by the built-in crash reporter and some sensible default behaviors. 76 | ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static 77 | 78 | # Ensure all further commands run as the hummingbird user 79 | USER hummingbird:hummingbird 80 | 81 | # Let Docker bind to port 8080 82 | EXPOSE 8080 83 | 84 | # Start the Hummingbird service when the image is run, default to listening on 8080 in production environment 85 | ENTRYPOINT ["./App"] 86 | CMD ["--hostname", "0.0.0.0", "--port", "8080"] 87 | -------------------------------------------------------------------------------- /HttpRequests/CreateStatus.http: -------------------------------------------------------------------------------- 1 | @contentType = application/json 2 | @baseUrl = http://localhost:8080 3 | 4 | 5 | ### POST - create a new status 6 | # @prompt authToken 7 | POST {{baseUrl}}/api/v1/statuses 8 | Content-Type: {{contentType}} 9 | Authorization: Bearer {{authToken}} 10 | 11 | { 12 | "id": "101010", 13 | "created_at": "2019-12-08T03:48:33.901Z", 14 | "in_reply_to_id": null, 15 | "in_reply_to_account_id": null, 16 | "sensitive": false, 17 | "spoiler_text": "", 18 | "visibility": "public", 19 | "language": "en", 20 | "uri": "https://mastodon.social/users/Gargron/statuses/103270115826048975", 21 | "url": "https://mastodon.social/@Gargron/103270115826048975", 22 | "replies_count": 5, 23 | "reblogs_count": 6, 24 | "favourites_count": 11, 25 | "favourited": false, 26 | "reblogged": false, 27 | "muted": false, 28 | "bookmarked": false, 29 | "content": "

"I lost my inheritance with one wrong digit on my sort code"

https://www.theguardian.com/money/2019/dec/07/i-lost-my-193000-inheritance-with-one-wrong-digit-on-my-sort-code", 30 | "reblog": null, 31 | "application": { 32 | "name": "Web", 33 | "website": null 34 | }, 35 | "account": { 36 | "id": "1", 37 | "username": "Gargron", 38 | "acct": "Gargron", 39 | "display_name": "Eugen", 40 | "locked": false, 41 | "bot": false, 42 | "discoverable": true, 43 | "group": false, 44 | "created_at": "2016-03-16T14:34:26.392Z", 45 | "note": "

Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.

", 46 | "url": "https://mastodon.social/@Gargron", 47 | "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg", 48 | "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg", 49 | "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png", 50 | "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png", 51 | "followers_count": 322930, 52 | "following_count": 459, 53 | "statuses_count": 61323, 54 | "last_status_at": "2019-12-10T08:14:44.811Z", 55 | "emojis": [], 56 | "fields": [ 57 | { 58 | "name": "Patreon", 59 | "value": "https://www.patreon.com/mastodonhttps://zeonfederated.com 2 | 3 | Swift logo 4 | 5 | 6 | # swiftodon 7 | 8 | ## ActivityPub server 9 | This is an [ActivityPub](https://www.w3.org/TR/activitypub/#social-web-working-group) server that is capable of federating with 10 | services such as [Mastodon](https://mastodon.social/about). [Documentation for Mastodon](https://docs.joinmastodon.org) 11 | 12 | It is being built using the [Hummingbird](https://hummingbird.codes) v2 web framework and Swift 6. The intention is for it to have pluggable modules for 13 | infrastructure services such as object storage. 14 | 15 | ## Feature roadmap 16 | My loose plan is to build out the features that I use so that I can run my own server. 17 | 18 | - [x] WebAuthN -> Register account 19 | - [x] JWT authenticated /api/v1 access control 20 | - [ ] home timeline 21 | - [ ] follow 22 | - [ ] boost 23 | - [ ] reply 24 | - [ ] media stored on S3 or similar 25 | - [ ] registration verification via email 26 | - [ ] follow hashtags 27 | - [ ] search users 28 | -------------------------------------------------------------------------------- /Sources/App/ActivityPubData/Link.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Link.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 21/09/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Link: ObjectOrLink, Codable { 11 | var href: String 12 | var rel: URL? 13 | 14 | /// The ActivityPub spec defines this will contain "Link" for Link types. 15 | var type: String 16 | 17 | /// mime media type e.g. "text/html" 18 | var mediaType: String? 19 | 20 | let isObject: Bool = false 21 | let isLink: Bool = true 22 | 23 | enum CodingKeys: String, CodingKey { 24 | case href 25 | case type 26 | case rel 27 | case mediaType 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/App/ActivityPubData/Object.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Object.swift 3 | // 4 | // 5 | // Created by Jonathan Pulfer on 08/09/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Object definition taken from specification at [w3c ActivityPub spec](https://www.w3.org/TR/activitystreams-vocabulary/#types). 11 | /// This will always be the parent container type holding lists that can contain a mixture of `Object` or `Link` elements. 12 | final class Object: ObjectOrLink, Codable { 13 | /// [json-LD](https://www.w3.org/TR/activitystreams-core/#jsonld) 14 | /// This special context value identifies the processing context. 15 | /// Generally, this is going to be `https://www.w3.org/ns/activitystreams` 16 | var processingContext: String = "https://www.w3.org/ns/activitystreams" 17 | 18 | var id: String 19 | var name: String 20 | 21 | /// The ActivityPub spec defines this will contain "Object" for Object types. 22 | var type: String 23 | 24 | var attachment: [ObjectOrLink] 25 | var attributedTo: [ObjectOrLink] 26 | var audience: [ObjectOrLink] 27 | 28 | var content: Object? 29 | 30 | let isObject: Bool = true 31 | let isLink: Bool = false 32 | 33 | enum CodingKeys: String, CodingKey { 34 | case processingContext = "@context" 35 | case id 36 | case type 37 | case name 38 | case attachment 39 | case attributedTo 40 | case audience 41 | case content 42 | } 43 | 44 | func encode(to _: any Encoder) throws { 45 | // TODO: implement me 46 | } 47 | 48 | init(from decoder: any Decoder) throws { 49 | let container = try decoder.container(keyedBy: CodingKeys.self) 50 | processingContext = try container.decode(String.self, forKey: .processingContext) 51 | id = try container.decode(String.self, forKey: .id) 52 | name = try container.decode(String.self, forKey: .name) 53 | type = try container.decode(String.self, forKey: .type) 54 | 55 | // attachment 56 | do { 57 | let attachmentContainer = try container.nestedUnkeyedContainer(forKey: .attachment) 58 | 59 | attachment = DecodeArrayOfObjectOrLink( 60 | unkeyedContainer: 61 | attachmentContainer 62 | ) 63 | } catch { 64 | attachment = [] 65 | } 66 | 67 | // attributedTo 68 | do { 69 | let attributedToContainer = try container.nestedUnkeyedContainer(forKey: .attributedTo) 70 | 71 | attributedTo = DecodeArrayOfObjectOrLink( 72 | unkeyedContainer: 73 | attributedToContainer 74 | ) 75 | } catch { 76 | attributedTo = [] 77 | } 78 | 79 | // audience 80 | do { 81 | let audienceContainer = try container.nestedUnkeyedContainer(forKey: .audience) 82 | 83 | audience = DecodeArrayOfObjectOrLink( 84 | unkeyedContainer: 85 | audienceContainer 86 | ) 87 | } catch { 88 | audience = [] 89 | } 90 | 91 | // content 92 | do { 93 | let contentObject = try container.decode(Object.self, forKey: .content) 94 | content = contentObject 95 | } catch { 96 | content = nil 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/App/ActivityPubData/ObjectOrLinkDecoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectOrLinkDecoder.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 21/09/2024. 6 | // 7 | 8 | func DecodeArrayOfObjectOrLink(unkeyedContainer: UnkeyedDecodingContainer) -> [ObjectOrLink] { 9 | var results: [ObjectOrLink] = [] 10 | var container: UnkeyedDecodingContainer = unkeyedContainer 11 | while !container.isAtEnd { 12 | do { 13 | let decodedObject = try container.decode(Object.self) 14 | results.append(decodedObject) 15 | continue 16 | } catch { 17 | // print(error) 18 | } 19 | do { 20 | let decodedLink = try container.decode(Link.self) 21 | results.append(decodedLink) 22 | continue 23 | } catch { 24 | // print(error) 25 | } 26 | } 27 | 28 | return results 29 | } 30 | -------------------------------------------------------------------------------- /Sources/App/ActivityPubData/ObjectProtocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectProtocols.swift 3 | // 4 | // 5 | // Created by Jonathan Pulfer on 08/09/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol AttributeTo { 11 | var attributableTo: String { get } 12 | } 13 | 14 | protocol ObjectOrLink: Codable { 15 | var isObject: Bool { get } 16 | var isLink: Bool { get } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/App/App.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Hummingbird 3 | import Logging 4 | import Foundation 5 | 6 | @main 7 | struct App: AsyncParsableCommand, AppArguments { 8 | @Flag(name: .shortAndLong) 9 | var inMemoryDatabase: Bool = false 10 | 11 | var privateKey: String { "certs/server.key" } 12 | var certificateChain: String { "certs/server.crt" } 13 | 14 | @Option(name: .shortAndLong) 15 | var hostname: String = "127.0.0.1" 16 | 17 | @Option(name: .shortAndLong) 18 | var port: Int = 8080 19 | 20 | @Option(name: .shortAndLong) 21 | var logLevel: Logger.Level? 22 | 23 | func run() async throws { 24 | let app = try await buildApplication(self) 25 | try await app.runService() 26 | } 27 | } 28 | 29 | /// Extend `Logger.Level` so it can be used as an argument 30 | #if hasFeature(RetroactiveAttribute) 31 | extension Logger.Level: @retroactive ExpressibleByArgument {} 32 | #else 33 | extension Logger.Level: ExpressibleByArgument {} 34 | #endif 35 | 36 | public func serverURL() -> String { 37 | if let envServerName = ProcessInfo.processInfo.environment["SWIFTODON_HOSTNAME"] { 38 | return "https://" + envServerName 39 | } 40 | return "http://localhost:8080" 41 | } 42 | 43 | public func serverName() -> String { 44 | if let envServerName = ProcessInfo.processInfo.environment["SWIFTODON_HOSTNAME"] { 45 | return envServerName 46 | } 47 | return "localhost" 48 | } 49 | -------------------------------------------------------------------------------- /Sources/App/Application+build.swift: -------------------------------------------------------------------------------- 1 | import FluentSQLiteDriver 2 | import Foundation 3 | import Hummingbird 4 | import HummingbirdAuth 5 | import HummingbirdFluent 6 | import HummingbirdRouter 7 | import JWTKit 8 | import Logging 9 | import Mustache 10 | 11 | /// Application arguments protocol. We use a protocol so we can call 12 | /// `buildApplication` inside Tests as well as in the App executable. 13 | /// Any variables added here also have to be added to `App` in App.swift and 14 | /// `TestArguments` in AppTest.swift 15 | public protocol AppArguments { 16 | var hostname: String { get } 17 | var port: Int { get } 18 | var logLevel: Logger.Level? { get } 19 | var inMemoryDatabase: Bool { get } 20 | var certificateChain: String { get } 21 | var privateKey: String { get } 22 | } 23 | 24 | // Request context used by application 25 | typealias AppRequestContext = BasicSessionRequestContext 26 | 27 | /// Build application 28 | /// - Parameter arguments: application arguments 29 | public func buildApplication(_ arguments: some AppArguments) async throws -> some ApplicationProtocol { 30 | let environment = Environment() 31 | let logger = { 32 | var logger = Logger(label: "swiftodon") 33 | logger.logLevel = 34 | arguments.logLevel ?? environment.get("LOG_LEVEL").map { Logger.Level(rawValue: $0) ?? .debug } ?? .info 35 | return logger 36 | }() 37 | 38 | /// Fluent is being used for storing relational data. 39 | /// 40 | /// To change the underlying datastore to a different RDBMS: 41 | /// - Change the dependencies in the 'Package' file 42 | /// - Change the imports to use the new driver 43 | /// - Change the database settings here 44 | let fluent = Fluent(logger: logger) 45 | if arguments.inMemoryDatabase { 46 | fluent.databases.use(.sqlite(.memory), as: .sqlite) 47 | } else { 48 | fluent.databases.use(.sqlite(.file("db.sqlite"), sqlLogLevel: .info), as: .sqlite) 49 | } 50 | let persist = await FluentPersistDriver(fluent: fluent) 51 | await AddPersonMigrations(fluent: fluent) 52 | await fluent.migrations.add(CreateFluentWebAuthnCredential()) 53 | await AddStatusMigrations(fluent: fluent) 54 | try await fluent.migrate() 55 | 56 | /// repository set up to inject the storage provider. 57 | let personRepos = FluentPersonStorage(fluent: fluent) 58 | let statusRepos = FluentStatusStorage(fluent: fluent, logger: logger) 59 | 60 | // load mustache template library 61 | let library = try await MustacheLibrary(directory: Bundle.module.resourcePath!) 62 | 63 | /// Authenticator storing the user 64 | let webAuthnSessionAuthenticator = SessionAuthenticator( 65 | users: personRepos, 66 | context: WebAuthnRequestContext.self 67 | ) 68 | 69 | /// JWT set up 70 | let keyCollection = JWTKeyCollection() 71 | guard 72 | let jwtSecret = ProcessInfo.processInfo.environment["JWT_SECRET"] 73 | else { 74 | logger.error("JWT_SECRET is not found in environment") 75 | throw JWTError.generic(identifier: "JWTKeyCollection", reason: "JWT_SECRET missing from env") 76 | } 77 | await keyCollection.add(hmac: HMACKey.init(stringLiteral: jwtSecret), digestAlgorithm: .sha256) 78 | 79 | let router = RouterBuilder(context: WebAuthnRequestContext.self) { 80 | // logging middleware 81 | LogRequestsMiddleware(.info) 82 | // add file middleware to serve HTML files 83 | FileMiddleware(searchForIndexHtml: true, logger: logger) 84 | // session middleware 85 | SessionMiddleware(storage: persist) 86 | RequestLoggerMiddleware() 87 | 88 | HTMLController( 89 | mustacheLibrary: library, 90 | webAuthnSessionAuthenticator: webAuthnSessionAuthenticator 91 | ) 92 | 93 | RouteGroup(".well-known") { 94 | WellKnownController() 95 | } 96 | 97 | RouteGroup("accounts") { 98 | PersonController(repository: personRepos, logger: logger) 99 | } 100 | 101 | RouteGroup("api") { 102 | WebAuthnController( 103 | webauthn: .init( 104 | config: .init( 105 | relyingPartyID: serverName(), 106 | relyingPartyName: "swiftodon", 107 | relyingPartyOrigin: serverURL() 108 | ) 109 | ), 110 | fluent: fluent, 111 | webAuthnSessionAuthenticator: webAuthnSessionAuthenticator, 112 | jwtKeyCollection: keyCollection, 113 | logger: logger 114 | ) 115 | 116 | /// API tree based on the Mastodon API: https://docs.joinmastodon.org/dev/routes/#api 117 | RouteGroup("v1") { 118 | // Auth middleware to control access to all endpoints at /api/v1/ 119 | webAuthnSessionAuthenticator 120 | JWTAuth(jwtKeyCollection: keyCollection, logger: logger, fluent: fluent) 121 | RedirectMiddleware(to: "/login.html") 122 | 123 | RouteGroup("statuses") { 124 | StatusController(repository: statusRepos, logger: logger) 125 | } 126 | 127 | } 128 | } 129 | 130 | Get("/health") { _, _ -> HTTPResponse.Status in 131 | return .ok 132 | } 133 | } 134 | 135 | var app = Application( 136 | router: router, 137 | configuration: .init( 138 | address: .hostname(arguments.hostname, port: arguments.port), 139 | serverName: "swiftodon" 140 | ), 141 | logger: logger 142 | ) 143 | 144 | app.addServices(fluent) 145 | 146 | return app 147 | } 148 | -------------------------------------------------------------------------------- /Sources/App/Controllers/HTMLController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTMLController.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 27/12/2024. 6 | // 7 | 8 | import Hummingbird 9 | import HummingbirdAuth 10 | import HummingbirdRouter 11 | import Mustache 12 | 13 | /// Redirects to login page if no user has been authenticated 14 | struct RedirectMiddleware: RouterMiddleware { 15 | let to: String 16 | func handle( 17 | _ request: Request, 18 | context: Context, 19 | next: (Request, Context) async throws -> Response 20 | ) async throws -> Response { 21 | guard context.identity != nil else { 22 | // if not authenticated then redirect to login page 23 | return .redirect(to: "\(to)?from=\(request.uri)", type: .found) 24 | } 25 | return try await next(request, context) 26 | } 27 | } 28 | 29 | /// Serves HTML pages 30 | struct HTMLController: RouterController { 31 | typealias Context = WebAuthnRequestContext 32 | 33 | let homeTemplate: MustacheTemplate 34 | let webAuthnSessionAuthenticator: SessionAuthenticator 35 | 36 | init( 37 | mustacheLibrary: MustacheLibrary, 38 | webAuthnSessionAuthenticator: SessionAuthenticator 39 | ) { 40 | // get the mustache templates from the library 41 | guard let homeTemplate = mustacheLibrary.getTemplate(named: "home") 42 | else { 43 | preconditionFailure("Failed to load mustache templates") 44 | } 45 | self.homeTemplate = homeTemplate 46 | self.webAuthnSessionAuthenticator = webAuthnSessionAuthenticator 47 | } 48 | 49 | // return Route for home page 50 | var body: some RouterMiddleware { 51 | Get("/") { 52 | webAuthnSessionAuthenticator 53 | RedirectMiddleware(to: "/login.html") 54 | home 55 | } 56 | } 57 | 58 | /// Home page listing todos and with add todo UI 59 | @Sendable func home(request _: Request, context: Context) async throws -> HTML { 60 | // get user 61 | let user = try context.requireIdentity() 62 | // Render home template and return as HTML 63 | let object: [String: Any] = [ 64 | "name": user.name 65 | ] 66 | let html = homeTemplate.render(object) 67 | return HTML(html: html) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/App/Controllers/PersonController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersonController.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 28/09/2024. 6 | // 7 | import Hummingbird 8 | import HummingbirdRouter 9 | import Logging 10 | import MastodonData 11 | 12 | struct PersonController: RouterController { 13 | typealias Context = WebAuthnRequestContext 14 | 15 | let repository: any PersonStorage 16 | let logger: Logger 17 | 18 | var body: some RouterMiddleware { 19 | 20 | // Endpoints 21 | 22 | /// GET /api/v1/accounts/:id 23 | /// 24 | Get("/:id", handler: get) 25 | } 26 | 27 | @Sendable func get(request _: Request, context: some RequestContext) async throws -> MastodonAccount? { 28 | let id = try context.parameters.require("id", as: String.self) 29 | if case let personObject as Person = await repository.get( 30 | criteria: PersonCriteria(handle: id.replacingOccurrences(of: "@", with: "")) 31 | ) { 32 | return personObject.toMastodonAccount() 33 | } 34 | throw HTTPError(.notFound) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/App/Controllers/StatusController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusContoller.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 10/01/2025. 6 | // 7 | import Hummingbird 8 | import HummingbirdRouter 9 | import Logging 10 | import MastodonData 11 | 12 | struct StatusController: RouterController { 13 | typealias Context = WebAuthnRequestContext 14 | 15 | let repository: StatusStorage 16 | let logger: Logger 17 | 18 | var body: some RouterMiddleware { 19 | 20 | // Endpoints 21 | 22 | /// POST /api/v1/statuses 23 | /// Create a new status, returns the created status 24 | Post("/", handler: create) 25 | 26 | // GET /api/v1/statuses/:id 27 | // Returns an individual status 28 | 29 | } 30 | 31 | @Sendable func create(request: Request, context: some RequestContext) async throws -> MastodonStatus? { 32 | let statusReceived = try await request.decode(as: MastodonStatus.self, context: context) 33 | try await repository.create(from: Status(fromMastodonStatus: statusReceived)) 34 | return nil 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/App/Controllers/TimelineController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimelineController.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 07/02/2025. 6 | // 7 | import Foundation 8 | import Hummingbird 9 | import HummingbirdFluent 10 | import HummingbirdRouter 11 | import Logging 12 | import MastodonData 13 | 14 | struct TimelineController: RouterController { 15 | typealias Context = WebAuthnRequestContext 16 | 17 | let fluent: Fluent 18 | let logger: Logger 19 | 20 | var body: some RouterMiddleware { 21 | 22 | // Endpoints 23 | 24 | /// GET /api/v1/timelines/home 25 | /// Returns a timeline of statuses for the authenticated user. 26 | Get("/home", handler: home) 27 | 28 | /// GET /api/v1/timelines/public 29 | /// Return a timeline of statuses of all statuses received by this server. 30 | Get("/public", handler: publicStatuses) 31 | 32 | /// GET /api/v1/timelines/tag 33 | /// Return a timeline for the statuses for a given hashtag. 34 | Get("/tag", handler: tagStatuses) 35 | 36 | /// GET /api/v1/timelines/list 37 | /// Return a timeline for the statuses of a given list. 38 | Get("/list", handler: listStatuses) 39 | } 40 | 41 | @Sendable func home(request: Request, context: some RequestContext) async throws -> [MastodonStatus] { 42 | 43 | return [] 44 | } 45 | 46 | @Sendable func publicStatuses(request: Request, context: some RequestContext) async throws -> [MastodonStatus] { 47 | 48 | return [] 49 | } 50 | 51 | @Sendable func tagStatuses(request: Request, context: some RequestContext) async throws -> [MastodonStatus] { 52 | 53 | return [] 54 | } 55 | 56 | @Sendable func listStatuses(request: Request, context: some RequestContext) async throws -> [MastodonStatus] { 57 | 58 | return [] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/App/Controllers/WebAuthnController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebAuthnController.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 27/12/2024. 6 | // 7 | 8 | import FluentKit 9 | import Foundation 10 | import Hummingbird 11 | import HummingbirdAuth 12 | import HummingbirdFluent 13 | import HummingbirdRouter 14 | import JWTKit 15 | @preconcurrency import WebAuthn 16 | 17 | struct WebAuthnController: RouterController, Sendable { 18 | typealias Context = WebAuthnRequestContext 19 | 20 | let webauthn: WebAuthnManager 21 | let fluent: Fluent 22 | let webAuthnSessionAuthenticator: SessionAuthenticator 23 | let jwtKeyCollection: JWTKeyCollection 24 | let logger: Logger 25 | 26 | // return RouteGroup with user login endpoints 27 | var body: some RouterMiddleware { 28 | RouteGroup("user") { 29 | Post("signup", handler: signup) 30 | Get("login", handler: beginAuthentication) 31 | Post("login", handler: finishAuthentication) 32 | Get("logout") { 33 | webAuthnSessionAuthenticator 34 | logout 35 | } 36 | RouteGroup("register") { 37 | Post("start", handler: beginRegistration) 38 | Post("finish", handler: finishRegistration) 39 | } 40 | } 41 | RouteGroup("token") { 42 | webAuthnSessionAuthenticator 43 | JWTAuth(jwtKeyCollection: jwtKeyCollection, logger: logger, fluent: fluent) 44 | RedirectMiddleware(to: "/login.html") 45 | Get("refresh", handler: getToken) 46 | } 47 | } 48 | 49 | struct SignUpInput: Decodable { 50 | let username: String 51 | } 52 | 53 | @Sendable func signup(request: Request, context: Context) async throws -> Response { 54 | let input = try await request.decode(as: SignUpInput.self, context: context) 55 | guard 56 | try await FluentPersonModel.query(on: fluent.db()) 57 | .filter(\.$name == input.username) 58 | .first() == nil 59 | else { 60 | throw HTTPError(.conflict, message: "Username already taken.") 61 | } 62 | let user = Person(name: input.username, fullName: "") 63 | let dbModel = FluentPersonModel(fromPersonModel: user) 64 | try await dbModel.save(on: fluent.db()) 65 | try context.sessions.setSession(.signedUp(userId: user.requireID()), expiresIn: .seconds(600)) 66 | return .redirect(to: "/api/user/register/start", type: .temporary) 67 | } 68 | 69 | /// Begin registering a User 70 | @Sendable func beginRegistration( 71 | request _: Request, 72 | context: Context 73 | ) async throws -> PublicKeyCredentialCreationOptions { 74 | let registrationSession = try await context.sessions.session?.session(fluent: fluent) 75 | guard case let .signedUp(user) = registrationSession else { throw HTTPError(.unauthorized) } 76 | let options = try webauthn.beginRegistration(user: user.publicKeyCredentialUserEntity) 77 | let session = try WebAuthnSession( 78 | from: .registering( 79 | user: user, 80 | challenge: options.challenge 81 | ) 82 | ) 83 | context.sessions.setSession(session, expiresIn: .seconds(600)) 84 | return options 85 | } 86 | 87 | /// Finish registering a user 88 | @Sendable func finishRegistration(request: Request, context: Context) async throws -> HTTPResponse.Status { 89 | let registrationSession = try await context.sessions.session?.session(fluent: fluent) 90 | let input = try await request.decode(as: RegistrationCredential.self, context: context) 91 | guard case let .registering(user, challenge) = registrationSession else { throw HTTPError(.unauthorized) } 92 | do { 93 | let credential = try await webauthn.finishRegistration( 94 | challenge: challenge, 95 | credentialCreationData: input, 96 | // this is likely to be removed soon 97 | confirmCredentialIDNotRegisteredYet: { id in 98 | try await FluentWebAuthnCredential.query(on: fluent.db()).filter(\.$id == id).first() == nil 99 | } 100 | ) 101 | try await FluentWebAuthnCredential(credential: credential, userId: user.requireID()).save(on: fluent.db()) 102 | } catch { 103 | context.logger.error("\(error)") 104 | throw HTTPError(.unauthorized) 105 | } 106 | context.sessions.clearSession() 107 | context.logger.info("Registration success, id: \(input.id)") 108 | 109 | return .ok 110 | } 111 | 112 | /// Begin Authenticating a user 113 | @Sendable func beginAuthentication(_: Request, context: Context) async throws -> PublicKeyCredentialRequestOptions { 114 | let options = try webauthn.beginAuthentication(timeout: 60000) 115 | let session = try WebAuthnSession( 116 | from: .authenticating( 117 | challenge: options.challenge 118 | ) 119 | ) 120 | context.sessions.setSession(session, expiresIn: .seconds(600)) 121 | return options 122 | } 123 | 124 | /// End Authenticating a user 125 | @Sendable func finishAuthentication(request: Request, context: Context) async throws -> HTTPResponse.Status { 126 | let authenticationSession = try await context.sessions.session?.session(fluent: fluent) 127 | let input = try await request.decode(as: AuthenticationCredential.self, context: context) 128 | guard case let .authenticating(challenge) = authenticationSession else { throw HTTPError(.unauthorized) } 129 | let id = input.id.urlDecoded.asString() 130 | guard 131 | let webAuthnCredential = try await FluentWebAuthnCredential.query(on: fluent.db()) 132 | .filter(\.$id == id) 133 | .with(\.$fluentPersonModel) 134 | .first() 135 | else { 136 | throw HTTPError(.unauthorized) 137 | } 138 | guard let decodedPublicKey = webAuthnCredential.publicKey.decoded else { throw HTTPError(.internalServerError) } 139 | context.logger.info("Challenge: \(challenge)") 140 | do { 141 | _ = try webauthn.finishAuthentication( 142 | credential: input, 143 | expectedChallenge: challenge, 144 | credentialPublicKey: [UInt8](decodedPublicKey), 145 | credentialCurrentSignCount: 0 146 | ) 147 | } catch { 148 | context.logger.error("\(error)") 149 | throw HTTPError(.unauthorized) 150 | } 151 | try context.sessions.setSession( 152 | .authenticated(userId: webAuthnCredential.fluentPersonModel.requireID()), 153 | expiresIn: .seconds(24 * 60 * 60) 154 | ) 155 | 156 | return .ok 157 | } 158 | 159 | /// Test authenticated 160 | @Sendable func logout(_: Request, context: Context) async throws -> HTTPResponse.Status { 161 | context.sessions.clearSession() 162 | return .ok 163 | } 164 | 165 | @Sendable func getToken(_: Request, context: Context) async throws -> TokenResponse { 166 | let contextSession = try await context.sessions.session?.session(fluent: fluent) 167 | switch contextSession { 168 | case let .authenticated(user: user): 169 | let userPayload: UserPayload = UserPayload(from: user.id) 170 | let token = try await jwtKeyCollection.sign(userPayload) 171 | let tokenResponse = TokenResponse(token: token, expiry: userPayload.expiration.value) 172 | return tokenResponse 173 | default: 174 | throw HTTPError(.unauthorized) 175 | } 176 | } 177 | } 178 | 179 | #if hasFeature(RetroactiveAttribute) 180 | extension PublicKeyCredentialCreationOptions: @retroactive ResponseEncodable {} 181 | extension PublicKeyCredentialRequestOptions: @retroactive ResponseEncodable {} 182 | #else 183 | extension PublicKeyCredentialCreationOptions: ResponseEncodable {} 184 | extension PublicKeyCredentialRequestOptions: ResponseEncodable {} 185 | #endif 186 | -------------------------------------------------------------------------------- /Sources/App/Controllers/WellKnownController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WellKnownController.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 23/02/2025. 6 | // 7 | 8 | import Foundation 9 | import Hummingbird 10 | import HummingbirdRouter 11 | import Logging 12 | import MastodonData 13 | 14 | struct WellKnownController: RouterController { 15 | typealias Context = WebAuthnRequestContext 16 | 17 | var body: some RouterMiddleware { 18 | 19 | Get("webfinger", handler: webfinger) 20 | } 21 | 22 | @Sendable func webfinger(request: Request, context: WebAuthnRequestContext) async throws -> WebFinger? { 23 | let providedHostname = ProcessInfo.processInfo.environment["SWIFTODON_HOSTNAME"] 24 | let hostname = (providedHostname ?? "http://localhost:8080") 25 | 26 | if let resourceQueryValue = request.uri.queryParameters["resource"] { 27 | if !resourceQueryValue.contains("acct:") { 28 | return nil 29 | } 30 | return try WebFinger(acctValue: String(resourceQueryValue), hostname: hostname) 31 | } 32 | return nil 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Fluent+Mustache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fluent+Mustache.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 27/12/2024. 6 | // 7 | import FluentKit 8 | import Mustache 9 | 10 | /// Extend @propertyWrapper FieldProperty to enable mustache transform functions and add one 11 | /// to access the wrappedValue. In the mustache template you would access this with 12 | /// `{{wrappedValue(_myProperty)}}`. Note the `_` prefix on the property name. This is 13 | /// required as this is how property wrappers appear in the Mirror reflection data. 14 | public extension FieldProperty { 15 | func transform(_ name: String) -> Any? { 16 | switch name { 17 | case "wrappedValue": 18 | wrappedValue 19 | default: 20 | nil 21 | } 22 | } 23 | } 24 | 25 | /// Extend @propertyWrapper IDProperty to enable mustache transform functions and add one 26 | /// to access the wrappedValue. In the mustache template you would access this with 27 | /// `{{wrappedValue(_myID)}}`. Note the `_` prefix on the property name. This is 28 | /// required as this is how property wrappers appear in the Mirror reflection data. 29 | public extension IDProperty { 30 | func transform(_ name: String) -> Any? { 31 | switch name { 32 | case "wrappedValue": 33 | wrappedValue 34 | default: 35 | nil 36 | } 37 | } 38 | } 39 | 40 | #if hasFeature(RetroactiveAttribute) 41 | extension FieldProperty: @retroactive MustacheTransformable {} 42 | extension IDProperty: @retroactive MustacheTransformable {} 43 | #else 44 | extension FieldProperty: MustacheTransformable {} 45 | extension IDProperty: MustacheTransformable {} 46 | #endif 47 | -------------------------------------------------------------------------------- /Sources/App/Extensions/html.swift: -------------------------------------------------------------------------------- 1 | // 2 | // html.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 27/12/2024. 6 | // 7 | import Hummingbird 8 | 9 | /// Type wrapping HTML code. Will convert to HBResponse that includes the correct 10 | /// content-type header 11 | struct HTML: ResponseGenerator { 12 | let html: String 13 | 14 | public func response(from _: Request, context _: some RequestContext) throws -> Response { 15 | let buffer = ByteBuffer(string: html) 16 | return .init(status: .ok, headers: [.contentType: "text/html"], body: .init(byteBuffer: buffer)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/App/Middleware/JWTAuth.swift: -------------------------------------------------------------------------------- 1 | import FluentKit 2 | import Foundation 3 | import Hummingbird 4 | import HummingbirdAuth 5 | import HummingbirdFluent 6 | import JWTKit 7 | import Logging 8 | 9 | struct JWTAuth: AuthenticatorMiddleware { 10 | public typealias Context = WebAuthnRequestContext 11 | let jwtKeyCollection: JWTKeyCollection 12 | let logger: Logger 13 | let fluent: Fluent 14 | 15 | func authenticate(request: Request, context: WebAuthnRequestContext) async throws -> Person? { 16 | guard 17 | let bearerToken = request.headers.bearer 18 | else { 19 | return nil 20 | } 21 | let jwtToken = bearerToken.token 22 | let payload: UserPayload 23 | do { 24 | payload = try await jwtKeyCollection.verify(jwtToken, as: UserPayload.self) 25 | } catch { 26 | logger.warning("could not verify token: \(jwtToken)") 27 | return nil 28 | } 29 | context.sessions.setSession( 30 | .authenticated(userId: UUID(uuidString: payload.userID)!), 31 | expiresIn: .seconds(payload.expiration.value.timeIntervalSinceNow) 32 | ) 33 | 34 | guard 35 | let person = try await FluentPersonModel.query(on: fluent.db()) 36 | .filter(\.$id == UUID(uuidString: payload.userID)!) 37 | .first() 38 | else { 39 | return nil 40 | } 41 | 42 | return person.fluentModelToPersonModel() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/App/Middleware/RequestLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestLogger.swift 3 | // 4 | // 5 | // Created by Jonathan Pulfer on 18/04/2025. 6 | // 7 | 8 | import Foundation 9 | import Hummingbird 10 | 11 | public struct RequestLoggerMiddleware: RouterMiddleware { 12 | public func handle( 13 | _ request: Request, 14 | context: Context, 15 | next: (Request, Context) async throws -> Response 16 | ) async throws -> Response { 17 | 18 | var request = request 19 | var requestBody = try await request.collectBody(upTo: (1024 * 1024 * 4)) 20 | 21 | context.logger.info("headers: \(request.headers)") 22 | context.logger.info("path: \(request.uri.path)") 23 | context.logger.info( 24 | "requestBody: \(String(data: requestBody.readData(length: requestBody.capacity) ?? Data(), encoding: .utf8) ?? "")" 25 | ) 26 | 27 | return try await next(request, context) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/App/Middleware/WebAuthnSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebAuthnSession.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 27/12/2024. 6 | // 7 | import Foundation 8 | import Hummingbird 9 | import HummingbirdAuth 10 | import HummingbirdFluent 11 | import WebAuthn 12 | 13 | /// Authentication session state 14 | public enum AuthenticationSession: Sendable, Codable { 15 | case signedUp(user: Person) 16 | case registering(user: Person, challenge: [UInt8]) 17 | case authenticating(challenge: [UInt8]) 18 | case authenticated(user: Person) 19 | } 20 | 21 | /// Session object extracted from session state 22 | public enum WebAuthnSession: Codable, Sendable { 23 | case signedUp(userId: UUID) 24 | case registering(userId: UUID, encodedChallenge: String) 25 | case authenticating(encodedChallenge: String) 26 | case authenticated(userId: UUID) 27 | 28 | /// init session object from authentication state 29 | public init(from session: AuthenticationSession) throws { 30 | switch session { 31 | case let .authenticating(challenge): 32 | self = .authenticating(encodedChallenge: challenge.base64URLEncodedString().asString()) 33 | case let .signedUp(user): 34 | self = try .signedUp(userId: user.requireID()) 35 | case let .registering(user, challenge): 36 | self = try .registering( 37 | userId: user.requireID(), 38 | encodedChallenge: challenge.base64URLEncodedString().asString() 39 | ) 40 | case let .authenticated(user): 41 | self = try .authenticated(userId: user.requireID()) 42 | } 43 | } 44 | 45 | /// return authentication state from session object 46 | public func session(fluent: Fluent) async throws -> AuthenticationSession? { 47 | switch self { 48 | case let .authenticating(encodedChallenge): 49 | guard let challenge = URLEncodedBase64(encodedChallenge).decodedBytes else { return nil } 50 | return .authenticating(challenge: challenge) 51 | case let .signedUp(userId): 52 | guard 53 | let user = await FluentPersonStorage(fluent: fluent).get( 54 | criteria: PersonCriteria(handle: nil, id: userId.uuidString) 55 | ) 56 | else { return nil } 57 | return .signedUp(user: user) 58 | case let .registering(userId, encodedChallenge): 59 | guard 60 | let user = await FluentPersonStorage(fluent: fluent).get( 61 | criteria: PersonCriteria(handle: nil, id: userId.uuidString) 62 | ) 63 | else { return nil } 64 | guard let challenge = URLEncodedBase64(encodedChallenge).decodedBytes else { return nil } 65 | return .registering(user: user, challenge: challenge) 66 | case let .authenticated(userId): 67 | guard 68 | let user = await FluentPersonStorage(fluent: fluent).get( 69 | criteria: PersonCriteria(handle: nil, id: userId.uuidString) 70 | ) 71 | else { return nil } 72 | return .authenticated(user: user) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/App/Repository/Fluent/FluentPersonStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FluentPersonStorage.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 17/11/2024. 6 | // 7 | 8 | import FluentKit 9 | import FluentSQLiteDriver 10 | import Foundation 11 | import HummingbirdAuth 12 | import HummingbirdFluent 13 | import Storage 14 | 15 | /// A ``PersonStorage`` that uses Fluent to store the data. 16 | /// 17 | /// This is an example of a storage adapter implementation which abstracts the 18 | /// specific way Fluent stores the data by transforming between the 19 | /// ``Person`` and an internal model. 20 | public struct FluentPersonStorage: PersonStorage, UserSessionRepository { 21 | public func getUser( 22 | from session: WebAuthnSession, 23 | context _: HummingbirdAuth.UserRepositoryContext 24 | ) async throws -> Person? { 25 | guard case let .authenticated(userId) = session else { return nil } 26 | return await get(criteria: PersonCriteria(handle: nil, id: userId.uuidString)) 27 | } 28 | 29 | public func getUser(from id: String, context _: HummingbirdAuth.UserRepositoryContext) async throws -> Person? { 30 | await get(criteria: PersonCriteria(handle: nil, id: id)) 31 | } 32 | 33 | public typealias Identifier = WebAuthnSession 34 | 35 | public typealias User = Person 36 | 37 | let fluent: Fluent 38 | 39 | /// Get a ``Person`` from the data store. 40 | /// 41 | /// - Parameters: 42 | /// - criteria: A ``PersonCriteria`` that indicates what object to return 43 | /// 44 | /// 45 | public func get(criteria: PersonCriteria) async -> Person? { 46 | do { 47 | if let handleSupplied = criteria.handle { 48 | if let dbModel = try await FluentPersonModel.query(on: fluent.db()).filter( 49 | \FluentPersonModel.$name == handleSupplied 50 | ).first() { 51 | return dbModel.fluentModelToPersonModel() 52 | } 53 | } 54 | if let idSupplied = criteria.id { 55 | if let idUuid = UUID(uuidString: idSupplied) { 56 | if let dbModel = try await FluentPersonModel.query(on: fluent.db()).filter( 57 | \FluentPersonModel.$id == idUuid 58 | ).first() { 59 | return dbModel.fluentModelToPersonModel() 60 | } 61 | } 62 | } 63 | } catch { 64 | print("error running query: \(error)") 65 | } 66 | return nil 67 | } 68 | 69 | /// Create a new ``PersonModel`` in the datastore 70 | /// 71 | /// - Parameters: 72 | /// - from: ``CreatePerson`` holding the shortId to create the ``Person`` for. 73 | public func create(from: CreatePerson) throws -> Person? { 74 | let model = Person(name: from.name, fullName: from.fullName) 75 | let sqliteModel = FluentPersonModel(fromPersonModel: model) 76 | let _ = sqliteModel.save(on: fluent.db()) 77 | return sqliteModel.fluentModelToPersonModel() 78 | } 79 | 80 | public init(fluent: Fluent) { 81 | self.fluent = fluent 82 | } 83 | } 84 | 85 | extension String: Sendable {} 86 | 87 | final class FluentPersonModel: Model, @unchecked Sendable { 88 | static let schema = "person" 89 | 90 | @ID(key: .id) 91 | var id: UUID? 92 | 93 | @OptionalChild(for: \.$fluentPersonModel) 94 | var webAuthnCredential: FluentWebAuthnCredential? 95 | 96 | @Field(key: "name") 97 | var name: String 98 | 99 | @Field(key: "session_id") 100 | var sessionId: String? 101 | 102 | @Field(key: "session_created_at") 103 | var sessionCreatedAt: String? 104 | 105 | @Field(key: "full_name") 106 | var fullName: String 107 | 108 | @Field(key: "public_url") 109 | var publicURL: String 110 | 111 | @Field(key: "real_url") 112 | var realURL: String 113 | 114 | @Field(key: "created_at") 115 | var createdAt: String 116 | 117 | @Field(key: "bio") 118 | var bio: String 119 | 120 | @Field(key: "profile_picture_url") 121 | var profilePictureURL: String 122 | 123 | @Field(key: "header_picture_url") 124 | var headerPictureURL: String 125 | 126 | @Field(key: "type") 127 | var type: String 128 | 129 | @Field(key: "server_dialect") 130 | var serverDialect: String 131 | 132 | @Field(key: "following") 133 | var following: String 134 | 135 | @Field(key: "followers") 136 | var followers: String 137 | 138 | @Field(key: "inbox") 139 | var inbox: String 140 | 141 | @Field(key: "outbox") 142 | var outbox: String 143 | 144 | @Field(key: "featured") 145 | var featured: String 146 | 147 | @Field(key: "featured_tags") 148 | var featuredTags: String 149 | 150 | @Field(key: "endpoints_shared_inbox") 151 | var endpointSharedInbox: String 152 | 153 | public init() {} 154 | 155 | public init( 156 | type: String, 157 | id: String, 158 | serverDialect: String, 159 | following: String, 160 | followers: String, 161 | inbox: String, 162 | outbox: String, 163 | featured: String, 164 | featuredTags: String, 165 | endpoints: PersonEndpoints 166 | ) { 167 | self.type = type 168 | self.id = UUID(uuidString: id) 169 | self.serverDialect = serverDialect 170 | self.following = following 171 | self.followers = followers 172 | self.inbox = inbox 173 | self.outbox = outbox 174 | self.featured = featured 175 | self.featuredTags = featuredTags 176 | endpointSharedInbox = endpoints.sharedInbox 177 | } 178 | 179 | public init(fromPersonModel: Person) { 180 | type = fromPersonModel.type 181 | name = fromPersonModel.name 182 | sessionId = fromPersonModel.sessionId 183 | if let sessionCreatedAt = fromPersonModel.sessionCreatedAt { 184 | self.sessionCreatedAt = RFC3339Timestamp(fromDate: sessionCreatedAt) 185 | } 186 | fullName = fromPersonModel.fullName 187 | publicURL = fromPersonModel.publicURL 188 | realURL = fromPersonModel.realURL 189 | profilePictureURL = fromPersonModel.profilePictureURL 190 | headerPictureURL = fromPersonModel.headerPictureURL 191 | bio = fromPersonModel.bio 192 | createdAt = RFC3339Timestamp(fromDate: fromPersonModel.createdAt) 193 | id = UUID(uuidString: fromPersonModel.id) 194 | serverDialect = fromPersonModel.serverDialect.rawValue 195 | following = fromPersonModel.following 196 | followers = fromPersonModel.followers 197 | inbox = fromPersonModel.inbox 198 | outbox = fromPersonModel.outbox 199 | featured = fromPersonModel.featured 200 | featuredTags = fromPersonModel.featuredTags 201 | endpointSharedInbox = fromPersonModel.endpoints.sharedInbox 202 | } 203 | 204 | func fluentModelToPersonModel() -> Person { 205 | var toModel = Person(name: name, fullName: fullName) 206 | if let recordId = id { 207 | toModel.id = recordId.uuidString 208 | } 209 | if let sessionId { 210 | toModel.sessionId = sessionId 211 | } 212 | // if let sessionCreatedAt { 213 | // if !sessionCreatedAt.isEmpty { 214 | // toModel.sessionCreatedAt = ISO8601DateFormatter().date(from: sessionCreatedAt)! 215 | // } 216 | // } 217 | toModel.publicURL = publicURL 218 | toModel.realURL = realURL 219 | toModel.profilePictureURL = profilePictureURL 220 | toModel.headerPictureURL = headerPictureURL 221 | toModel.bio = bio 222 | toModel.createdAt = ParseRFCTimestampToUTC(fromString: createdAt) 223 | toModel.type = type 224 | toModel.serverDialect = ServerDialect(fromString: serverDialect) 225 | toModel.following = following 226 | toModel.followers = followers 227 | toModel.inbox = inbox 228 | toModel.outbox = outbox 229 | toModel.featured = featured 230 | toModel.featuredTags = featuredTags 231 | toModel.endpoints.sharedInbox = endpointSharedInbox 232 | 233 | return toModel 234 | } 235 | } 236 | 237 | public func AddPersonMigrations(fluent: Fluent) async { 238 | await fluent.migrations.add(CreateFluentPerson()) 239 | await fluent.migrations.add(CreatePersonNameIndex()) 240 | } 241 | 242 | public struct CreateFluentPerson: AsyncMigration { 243 | // Prepares the database for storing Person models. 244 | public func prepare(on database: Database) async throws { 245 | try await database.schema("person") 246 | .id() 247 | .field("name", .string, .required) 248 | .unique(on: "name") 249 | .field("session_id", .string) 250 | .field("session_created_at", .string) 251 | .field("full_name", .string) 252 | .field("public_url", .string) 253 | .field("real_url", .string) 254 | .field("created_at", .string) 255 | .field("bio", .string) 256 | .field("profile_picture_url", .string) 257 | .field("header_picture_url", .string) 258 | .field("type", .string) 259 | .field("server_dialect", .string) 260 | .field("following", .string) 261 | .field("followers", .string) 262 | .field("inbox", .string) 263 | .field("outbox", .string) 264 | .field("featured", .string) 265 | .field("featured_tags", .string) 266 | .field("endpoints_shared_inbox", .string) 267 | .create() 268 | } 269 | 270 | // Optionally reverts the changes made in the prepare method. 271 | public func revert(on database: Database) async throws { 272 | try await database.schema("person").delete() 273 | } 274 | 275 | public init() {} 276 | } 277 | 278 | public struct CreatePersonNameIndex: AsyncMigration { 279 | 280 | public func prepare(on database: Database) async throws { 281 | try await (database as! SQLDatabase) 282 | .create(index: "name_idx") 283 | .on("person") 284 | .column("name") 285 | .run() 286 | } 287 | 288 | public func revert(on database: Database) async throws { 289 | try await (database as! SQLDatabase) 290 | .drop(index: "name_idx") 291 | .run() 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /Sources/App/Repository/Fluent/FluentStatusStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FluentStatusStorage.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 01/01/2025. 6 | // 7 | 8 | import FluentKit 9 | import FluentSQLiteDriver 10 | import Foundation 11 | import HummingbirdFluent 12 | import Logging 13 | import Storage 14 | 15 | public struct FluentStatusStorage: StatusStorage { 16 | 17 | let fluent: Fluent 18 | let logger: Logger 19 | 20 | public func get(criteria: StatusCriteria) async -> Status? { 21 | 22 | return nil 23 | } 24 | 25 | public func create(from status: Status) async throws { 26 | let dbModel = FluentStatusModel(from: status) 27 | try await dbModel.save(on: fluent.db()) 28 | } 29 | 30 | } 31 | 32 | final class FluentStatusModel: Model, @unchecked Sendable { 33 | static let schema = "status" 34 | 35 | @ID(key: .id) 36 | var id: UUID? 37 | 38 | @Field(key: "uri") 39 | var uri: String 40 | 41 | @Field(key: "content") 42 | var content: String 43 | 44 | @Field(key: "created_at") 45 | var createdAt: String 46 | 47 | @Field(key: "updated_at") 48 | var updatedAt: String? 49 | 50 | @Field(key: "in_reply_to_id") 51 | var inReplyToId: String? 52 | 53 | @Field(key: "reblog_of_id") 54 | var reblogOfId: String? 55 | 56 | @Field(key: "url") 57 | var url: String 58 | 59 | @Field(key: "sensitive") 60 | var sensitive: Bool 61 | 62 | @Field(key: "visibility") 63 | var visibility: String 64 | 65 | @Field(key: "spoiler_text") 66 | var spoilerText: String? 67 | 68 | @Field(key: "reply") 69 | var reply: Bool 70 | 71 | @Field(key: "language") 72 | var language: String 73 | 74 | @Field(key: "conversation_id") 75 | var conversationId: String? 76 | 77 | @Field(key: "local") 78 | var local: Bool 79 | 80 | @Field(key: "account_id") 81 | var accountId: String? 82 | 83 | @Field(key: "application_id") 84 | var applicationId: String? 85 | 86 | @Field(key: "in_reply_to_account_id") 87 | var inReplyToAccountId: String? 88 | 89 | @Field(key: "poll_id") 90 | var pollId: String? 91 | 92 | @Field(key: "deleted_at") 93 | var deletedAt: String? 94 | 95 | @Field(key: "edited_at") 96 | var editedAt: String? 97 | 98 | @Field(key: "trendable") 99 | var trendable: Bool? 100 | 101 | @Field(key: "ordered_media_attachments") 102 | var orderedMediaAttachments: [String] 103 | 104 | public init() {} 105 | 106 | public init(from status: Status) { 107 | self.id = (status.id ?? UUID()) 108 | self.uri = status.uri 109 | self.content = status.content 110 | self.createdAt = status.createdAt 111 | self.updatedAt = status.updatedAt 112 | self.inReplyToId = status.inReplyToId 113 | self.reblogOfId = status.reblogOfId 114 | self.url = status.url 115 | self.sensitive = status.sensitive 116 | self.visibility = status.visibility 117 | self.spoilerText = status.spoilerText 118 | self.reply = status.reply 119 | self.language = status.language 120 | self.conversationId = status.conversationId 121 | self.local = status.local 122 | self.accountId = status.accountId 123 | self.applicationId = status.applicationId 124 | self.inReplyToAccountId = status.inReplyToAccountId 125 | self.pollId = status.pollId 126 | self.deletedAt = status.deletedAt 127 | self.editedAt = status.editedAt 128 | self.trendable = status.trendable 129 | self.orderedMediaAttachments = status.orderedMediaAttachments 130 | } 131 | } 132 | 133 | public struct CreateFluentStatus: AsyncMigration { 134 | 135 | public func prepare(on database: Database) async throws { 136 | try await database.schema("status") 137 | .id() 138 | .field("uri", .string) 139 | .field("content", .string) 140 | .field("created_at", .string) 141 | .field("updated_at", .string) 142 | .field("in_reply_to_id", .string) 143 | .field("reblog_of_id", .string) 144 | .field("url", .string) 145 | .field("sensitive", .bool) 146 | .field("visibility", .string) 147 | .field("spoiler_text", .string) 148 | .field("reply", .string) 149 | .field("language", .string) 150 | .field("conversation_id", .string) 151 | .field("local", .bool) 152 | .field("account_id", .string) 153 | .field("application_id", .string) 154 | .field("in_reply_to_account_id", .string) 155 | .field("poll_id", .string) 156 | .field("deleted_at", .string) 157 | .field("edited_at", .string) 158 | .field("trendable", .bool) 159 | .field("ordered_media_attachments", .array) 160 | .create() 161 | } 162 | 163 | public func revert(on database: Database) async throws { 164 | try await database.schema("status").delete() 165 | } 166 | 167 | public init() {} 168 | } 169 | 170 | public func AddStatusMigrations(fluent: Fluent) async { 171 | await fluent.migrations.add(CreateFluentStatus()) 172 | await fluent.migrations.add(CreateStatusAccountIdIndex()) 173 | await fluent.migrations.add(CreateStatusConversationIdIndex()) 174 | await fluent.migrations.add(CreateStatusInReplyToIdIndex()) 175 | } 176 | 177 | public struct CreateStatusAccountIdIndex: AsyncMigration { 178 | 179 | public func prepare(on database: Database) async throws { 180 | try await (database as! SQLDatabase) 181 | .create(index: "status_account_id_idx") 182 | .on("status") 183 | .column("account_id") 184 | .run() 185 | } 186 | 187 | public func revert(on database: Database) async throws { 188 | try await (database as! SQLDatabase) 189 | .drop(index: "status_account_id_idx") 190 | .run() 191 | } 192 | } 193 | 194 | public struct CreateStatusConversationIdIndex: AsyncMigration { 195 | 196 | public func prepare(on database: Database) async throws { 197 | try await (database as! SQLDatabase) 198 | .create(index: "conversation_id_idx") 199 | .on("status") 200 | .column("conversation_id") 201 | .run() 202 | } 203 | 204 | public func revert(on database: Database) async throws { 205 | try await (database as! SQLDatabase) 206 | .drop(index: "conversation_id_idx") 207 | .run() 208 | } 209 | } 210 | 211 | public struct CreateStatusInReplyToIdIndex: AsyncMigration { 212 | 213 | public func prepare(on database: Database) async throws { 214 | try await (database as! SQLDatabase) 215 | .create(index: "in_reply_to_id_idx") 216 | .on("status") 217 | .column("in_reply_to_id") 218 | .run() 219 | } 220 | 221 | public func revert(on database: Database) async throws { 222 | try await (database as! SQLDatabase) 223 | .drop(index: "in_reply_to_id_idx") 224 | .run() 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /Sources/App/Repository/Fluent/FluentWebAuthNStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FLuentWebAuthNStorage.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 23/11/2024. 6 | // 7 | import FluentSQLiteDriver 8 | import Foundation 9 | import HummingbirdAuth 10 | import HummingbirdFluent 11 | @preconcurrency import WebAuthn 12 | 13 | /// A ``FluentWebAuthNStorage`` that uses Fluent in a file to store the data. 14 | /// 15 | /// This is an example of a storage adapter implementation which abstracts the 16 | /// specific way the datastore stores the data by transforming between the 17 | /// ``WebAuthNModel`` and an internal model. 18 | public struct FluentWebAuthNStorage: WebAuthNStorage { 19 | let fluent: Fluent 20 | 21 | /// Initialise the Datastore connection using `Fluent` 22 | /// 23 | /// - Parameters: 24 | /// - fluent: the pre-initialised Fluent connection 25 | public init(fluent: Fluent) { 26 | self.fluent = fluent 27 | } 28 | 29 | /// Get a ``WebAuthNModel`` from the data store. 30 | /// 31 | /// - Parameters: 32 | /// - criteria: A ``WebAuthNCriteria`` that indicates what object to return 33 | /// 34 | /// 35 | public func get(criteria: WebAuthNCriteria) async -> WebAuthNModel? { 36 | do { 37 | if let criteriaUuid = UUID(uuidString: criteria.userUuid) { 38 | if let dbPersonModel = try await FluentPersonModel.query(on: fluent.db()).filter( 39 | \FluentPersonModel.$id == criteriaUuid 40 | ).with(\.$webAuthnCredential).first() { 41 | if let cred = dbPersonModel.webAuthnCredential { 42 | return cred.toWebAuthNModel() 43 | } 44 | } 45 | } 46 | } catch { 47 | print("error running query: \(error)") 48 | } 49 | return nil 50 | } 51 | 52 | /// Create a new ``WebAuthNModel`` in the datastore 53 | /// 54 | /// - Parameters: 55 | /// - from: ``CreateWebAuthN`` holding the credential and user_id to link to 56 | /// the ``PersonModel`` for. 57 | public func create(from: CreateWebAuthN) throws -> WebAuthNModel? { 58 | let dbModel = FluentWebAuthnCredential(credential: from.publicKey, userId: from.userUuid) 59 | let _ = dbModel.save(on: fluent.db()) 60 | return dbModel.toWebAuthNModel() 61 | } 62 | } 63 | 64 | final class FluentWebAuthnCredential: Model, @unchecked Sendable { 65 | static let schema = "webauthn_credential" 66 | 67 | @ID(custom: "id", generatedBy: .user) 68 | var id: String? 69 | 70 | @Field(key: "public_key") 71 | var publicKey: EncodedBase64 72 | 73 | @Parent(key: "person_id") 74 | var fluentPersonModel: FluentPersonModel 75 | 76 | init() {} 77 | 78 | private init(id: String, publicKey: EncodedBase64, userId: UUID) { 79 | self.id = id 80 | self.publicKey = publicKey 81 | $fluentPersonModel.id = userId 82 | } 83 | 84 | convenience init(credential: Credential, userId: UUID) { 85 | self.init( 86 | id: credential.id, 87 | publicKey: credential.publicKey.base64EncodedString(), 88 | userId: userId 89 | ) 90 | } 91 | 92 | public func toWebAuthNModel() -> WebAuthNModel? { 93 | if let userUuid = fluentPersonModel.id { 94 | return WebAuthNModel(userUuid: userUuid, publicKey: publicKey) 95 | } 96 | return nil 97 | } 98 | } 99 | 100 | public struct CreateFluentWebAuthnCredential: AsyncMigration { 101 | public init() {} 102 | 103 | public func prepare(on database: Database) async throws { 104 | try await database.schema("webauthn_credential") 105 | .field("id", .string, .identifier(auto: false)) 106 | .field("public_key", .string, .required) 107 | .field("person_id", .uuid, .required, .references("person", "id")) 108 | .unique(on: "id") 109 | .create() 110 | } 111 | 112 | public func revert(on database: Database) async throws { 113 | try await database.schema("webauthn_credential").delete() 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/App/Repository/Person.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Person.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 28/09/2024. 6 | // 7 | import Foundation 8 | import MastodonData 9 | import Storage 10 | @preconcurrency import WebAuthn 11 | 12 | let personBaseURL: String = serverURL() + "/accounts/" 13 | let sharedInboxURL: String = serverURL() + "/shared/inbox" 14 | let personType: String = "Person" 15 | 16 | enum PersonError: Error { 17 | case idMissing 18 | } 19 | 20 | public struct Person: Codable { 21 | public var id: String 22 | public var publicURL: String 23 | public var realURL: String 24 | public var name: String 25 | public var sessionId: String? 26 | public var sessionCreatedAt: Date? 27 | public var fullName: String 28 | public var createdAt: Date 29 | public var bio: String = "" 30 | public var profilePictureURL: String = "" 31 | public var headerPictureURL: String = "" 32 | public var type: String 33 | public var serverDialect: ServerDialect 34 | public var following: String 35 | public var followers: String 36 | public var inbox: String 37 | public var outbox: String 38 | public var featured: String 39 | public var featuredTags: String 40 | public var endpoints: PersonEndpoints 41 | 42 | public func toMastodonAccount() -> MastodonAccount { 43 | MastodonAccount( 44 | id: id, 45 | username: name, 46 | account: name, 47 | displayName: fullName, 48 | createdAt: createdAt, 49 | note: bio, 50 | url: publicURL, 51 | uri: realURL, 52 | avatar: profilePictureURL, 53 | header: headerPictureURL, 54 | lastStatusAt: Date() 55 | ) 56 | } 57 | 58 | public init(name: String, fullName: String) { 59 | let handle = "@" + name 60 | let uri = personBaseURL + handle 61 | id = UUID().uuidString 62 | type = personType 63 | self.name = name 64 | self.fullName = fullName 65 | publicURL = serverURL() + "/" + handle 66 | realURL = uri 67 | createdAt = Date() 68 | serverDialect = .mastodon 69 | following = uri + "/following" 70 | followers = uri + "/followers" 71 | inbox = uri + "/inbox" 72 | outbox = uri + "/outbox" 73 | featured = uri + "/collections/featured" 74 | featuredTags = uri + "/collections/tags" 75 | endpoints = PersonEndpoints(sharedInbox: sharedInboxURL) 76 | } 77 | 78 | public func requireID() throws -> UUID { 79 | if id.isEmpty { 80 | throw PersonError.idMissing 81 | } 82 | return (UUID(uuidString: id) ?? UUID()) 83 | } 84 | 85 | var publicKeyCredentialUserEntity: PublicKeyCredentialUserEntity { 86 | get throws { 87 | try .init(id: .init(requireID().uuidString.utf8), name: name, displayName: name) 88 | } 89 | } 90 | } 91 | 92 | extension Person: Sendable {} 93 | 94 | public struct PersonEndpoints: Codable { 95 | public var sharedInbox: String 96 | } 97 | 98 | extension PersonEndpoints: Sendable {} 99 | 100 | /// Criteria to pass to the storage get method to select particular 101 | /// records. 102 | public struct PersonCriteria: Sendable { 103 | /// This is the short name used by the person when they created the account. 104 | /// 105 | /// Example: 106 | /// - `myAccount` 107 | public var handle: String? 108 | 109 | /// This is the id of the person assigned when they created the account. 110 | /// 111 | /// Example: 112 | /// - `` 113 | public var id: String? 114 | 115 | public init(handle: String? = nil, id: String? = nil) { 116 | self.handle = handle 117 | self.id = id 118 | } 119 | } 120 | 121 | /// Request parameters to create a new ``PersonModel`` in the datastore. 122 | public struct CreatePerson: Sendable { 123 | /// This is the name the person wants to create on this server. 124 | /// 125 | /// Example: 126 | /// - `janeD` 127 | public let name: String 128 | 129 | /// This is the full display name the person wants to create on 130 | /// this server. 131 | /// 132 | /// Example: 133 | /// - `Jane Doe` 134 | public let fullName: String 135 | 136 | public init(name: String, fullName: String) { 137 | self.name = name 138 | self.fullName = fullName 139 | } 140 | } 141 | 142 | public protocol PersonStorage: Sendable { 143 | associatedtype Identifier: Codable 144 | associatedtype Person: Sendable 145 | 146 | func get(criteria: PersonCriteria) async -> Person? 147 | func create(from: CreatePerson) async throws -> Person? 148 | } 149 | 150 | func DummyPersonModels() -> [Person] { 151 | [ 152 | Person(name: "someone", fullName: "Some One") 153 | ] 154 | } 155 | -------------------------------------------------------------------------------- /Sources/App/Repository/Status.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MastodonData 3 | 4 | public struct Status: Codable { 5 | var id: UUID? 6 | var uri: String 7 | var content: String 8 | var createdAt: String 9 | var updatedAt: String? 10 | var inReplyToId: String? 11 | var reblogOfId: String? 12 | var url: String 13 | var sensitive: Bool 14 | var visibility: String 15 | var spoilerText: String? 16 | var reply: Bool 17 | var language: String 18 | var conversationId: String? 19 | var local: Bool 20 | var accountId: String? 21 | var applicationId: String? 22 | var inReplyToAccountId: String? 23 | var pollId: String? 24 | var deletedAt: String? 25 | var editedAt: String? 26 | var trendable: Bool? 27 | var orderedMediaAttachments: [String] 28 | 29 | init(fromMastodonStatus: MastodonStatus) { 30 | id = (UUID(uuidString: (fromMastodonStatus.id ?? "")) ?? UUID()) 31 | uri = fromMastodonStatus.uri 32 | content = fromMastodonStatus.content 33 | createdAt = fromMastodonStatus.createdAt 34 | updatedAt = fromMastodonStatus.updatedAt 35 | inReplyToId = fromMastodonStatus.inReplyToId 36 | reblogOfId = fromMastodonStatus.reblogOfId 37 | url = fromMastodonStatus.url 38 | sensitive = fromMastodonStatus.sensitive 39 | visibility = fromMastodonStatus.visibility 40 | spoilerText = fromMastodonStatus.spoilerText 41 | reply = fromMastodonStatus.reply ?? false 42 | language = fromMastodonStatus.language 43 | conversationId = fromMastodonStatus.conversationId 44 | local = fromMastodonStatus.local ?? false 45 | accountId = fromMastodonStatus.accountId 46 | applicationId = fromMastodonStatus.applicationId 47 | inReplyToAccountId = fromMastodonStatus.inReplyToAccountId 48 | pollId = fromMastodonStatus.pollId 49 | deletedAt = fromMastodonStatus.deletedAt 50 | editedAt = fromMastodonStatus.editedAt 51 | trendable = fromMastodonStatus.trendable 52 | orderedMediaAttachments = fromMastodonStatus.orderedMediaAttachments 53 | } 54 | } 55 | 56 | public struct StatusCriteria: Sendable { 57 | public let id: String 58 | } 59 | 60 | public protocol StatusStorage: Sendable { 61 | func get(criteria: StatusCriteria) async -> Status? 62 | func create(from status: Status) async throws 63 | } 64 | -------------------------------------------------------------------------------- /Sources/App/Repository/WebAuthNModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebAuthNModel.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 30/11/2024. 6 | // 7 | import Foundation 8 | @preconcurrency import WebAuthn 9 | 10 | public struct WebAuthNModel: Sendable { 11 | public let userUuid: UUID 12 | public let publicKey: EncodedBase64 13 | } 14 | 15 | public struct CreateWebAuthN: @unchecked Sendable { 16 | public let userUuid: UUID 17 | public let publicKey: Credential 18 | } 19 | 20 | public struct WebAuthNCriteria: Sendable { 21 | public var userUuid: String 22 | } 23 | 24 | public protocol WebAuthNStorage: Sendable { 25 | func get(criteria: WebAuthNCriteria) async -> WebAuthNModel? 26 | func create(from: CreateWebAuthN) async throws -> WebAuthNModel? 27 | } 28 | -------------------------------------------------------------------------------- /Sources/App/RequestContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestContext.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 27/12/2024. 6 | // 7 | 8 | import Foundation 9 | import Hummingbird 10 | import HummingbirdAuth 11 | import HummingbirdRouter 12 | import Logging 13 | import NIOCore 14 | 15 | public struct WebAuthnRequestContext: AuthRequestContext, RouterRequestContext, SessionRequestContext { 16 | public typealias Session = WebAuthnSession 17 | 18 | public var coreContext: CoreRequestContextStorage 19 | public var identity: Person? 20 | public var routerContext: RouterBuilderContext 21 | public let sessions: SessionContext 22 | 23 | var responseEncoder: ResponseEncoder { 24 | let encoder = JSONEncoder() 25 | encoder.outputFormatting = .withoutEscapingSlashes 26 | encoder.dateEncodingStrategy = .iso8601 27 | return encoder 28 | } 29 | 30 | public init(source: Source) { 31 | coreContext = .init(source: source) 32 | identity = nil 33 | routerContext = .init() 34 | sessions = .init() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/App/Resources/Templates/home.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Swiftodon 5 | 6 | 7 | 8 | 9 | 10 |
11 |

Swiftodon

12 |
13 |
14 |
15 |

Welcome {{name}}

16 |
17 |
18 |
19 |
20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/App/Shared/DateTime.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public func RFC3339Timestamp(fromDate: Date) -> String { 4 | let RFC3339DateFormatter = DateFormatter() 5 | RFC3339DateFormatter.locale = Locale(identifier: "en_US_POSIX") 6 | RFC3339DateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 7 | RFC3339DateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 8 | 9 | return RFC3339DateFormatter.string(from: fromDate) 10 | } 11 | 12 | public func ParseRFCTimestampToUTC(fromString: String) -> Date { 13 | let RFC3339DateFormatter = DateFormatter() 14 | RFC3339DateFormatter.locale = Locale(identifier: "en_US_POSIX") 15 | RFC3339DateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 16 | RFC3339DateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 17 | 18 | if let date = RFC3339DateFormatter.date(from: fromString) { 19 | return date 20 | } 21 | 22 | return Date() 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/UserToken/UserToken.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Hummingbird 3 | import JWTKit 4 | 5 | struct UserPayload: JWTPayload { 6 | let userID: String 7 | let expiration: ExpirationClaim 8 | //let roles: RoleClaim 9 | 10 | enum CodingKeys: String, CodingKey { 11 | case userID = "user_id" 12 | case expiration = "exp" 13 | //case roles 14 | } 15 | 16 | func verify(using key: some JWTAlgorithm) throws { 17 | try expiration.verifyNotExpired() 18 | //try roles.verifyAdmin() 19 | } 20 | 21 | init(from userId: String) { 22 | self.userID = userId 23 | self.expiration = .init(value: .init(timeIntervalSinceNow: 3600)) // Token expires in 1 hour 24 | } 25 | } 26 | 27 | struct RoleClaim: JWTClaim { 28 | var value: [String] 29 | 30 | func verifyAdmin() throws { 31 | guard value.contains("admin") else { 32 | throw JWTError.claimVerificationFailure( 33 | failedClaim: self, 34 | reason: "User is not an admin" 35 | ) 36 | } 37 | } 38 | } 39 | 40 | struct TokenResponse: ResponseEncodable { 41 | let token: String 42 | let expiry: Date 43 | } 44 | -------------------------------------------------------------------------------- /Sources/KeyStorage/InMemoryKeyStorage/InMemoryKeyStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InMemoryKeyStorage.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 05/10/2024. 6 | // 7 | import Foundation 8 | 9 | public actor InMemoryKeyStorage { 10 | let storage: InMemoryStore = .init() 11 | } 12 | 13 | final class InMemoryStore { 14 | var storageByOwnerId: [String: KeyModel] = [:] 15 | var storageByKeyId: [String: KeyModel] = [:] 16 | 17 | func store(keyModel: KeyModel) -> KeyModel { 18 | storageByOwnerId[keyModel.ownerId] = keyModel 19 | storageByKeyId[keyModel.keyId] = keyModel 20 | 21 | return keyModel 22 | } 23 | 24 | func get(ownerId: String) -> KeyModel? { 25 | storageByOwnerId[ownerId] 26 | } 27 | 28 | func get(keyId: String) -> KeyModel? { 29 | storageByKeyId[keyId] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/KeyStorage/Key.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Key.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 04/11/2024. 6 | // 7 | import Foundation 8 | import Hummingbird 9 | import Security 10 | 11 | final class Key: Model, HBResponseCodable { 12 | static let schema = "key" 13 | 14 | var id: UUID 15 | 16 | public let ownerId: String 17 | 18 | public let createdAt: Date 19 | 20 | public let key: SecKey? 21 | 22 | public init(ownerId: String) { 23 | self.ownerId = ownerId 24 | id = UUID().uuidString 25 | createdAt = Date() 26 | 27 | let tag = "swiftodon.keys." + id 28 | let attributes: [String: Any] = 29 | [ 30 | kSecAttrKeyType as String: kSecAttrKeyTypeEC, 31 | kSecAttrKeySizeInBits as String: NSNumber(value: 256), 32 | kSecPrivateKeyAttrs as String: 33 | [ 34 | kSecAttrIsPermanent as String: true, 35 | kSecAttrApplicationTag as String: tag.data(using: .utf8)!, 36 | ], 37 | ] 38 | 39 | var error: Unmanaged? 40 | guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { 41 | print("error creating key: \(error!)") 42 | key = nil 43 | return 44 | } 45 | 46 | key = privateKey 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/KeyStorage/KeyStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyStorage.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 05/10/2024. 6 | // 7 | import FluentKit 8 | import Foundation 9 | import Hummingbird 10 | import Security 11 | 12 | public struct KeyCriteria: Sendable { 13 | let ownerId: String? 14 | let keyId: String? 15 | } 16 | 17 | public struct KeyCreateDetails: Sendable { 18 | let ownerId: String 19 | } 20 | 21 | public protocol KeyStorage: Sendable { 22 | func get(keyCriteria: KeyCriteria) async throws -> Key? 23 | func create(from: KeyCreateDetails) async throws -> Key? 24 | } 25 | -------------------------------------------------------------------------------- /Sources/KeyStorage/sqliteStorage/sqliteKeyStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // sqliteKeyStorage.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 04/11/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | actor sqliteKeyStorage: KeyStorage {} 11 | -------------------------------------------------------------------------------- /Sources/MastodonData/Account.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Person.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 21/09/2024. 6 | // 7 | import Foundation 8 | import Hummingbird 9 | 10 | /// Account from a Mastodon instance. 11 | public struct MastodonAccount: ResponseEncodable, Codable, Equatable { 12 | var id: String 13 | var username: String 14 | var account: String 15 | var displayName: String 16 | var locked: Bool 17 | var bot: Bool 18 | var discoverable: Bool 19 | var indexable: Bool? 20 | var group: Bool 21 | var createdAt: String 22 | var note: String 23 | var url: String? 24 | var uri: String? 25 | var avatar: String 26 | var avatarStatic: String 27 | var header: String 28 | var headerStatic: String 29 | var followersCount: UInt64 30 | var followingCount: UInt64 31 | var statusesCount: UInt64 32 | var lastStatusAt: String 33 | var hideCollections: Bool? 34 | var noindex: Bool? 35 | var fields: [AccountField] 36 | 37 | public enum CodingKeys: String, CodingKey { 38 | case id = "id" 39 | case username = "username" 40 | case account = "acct" 41 | case displayName = "display_name" 42 | case locked = "locked" 43 | case bot = "bot" 44 | case discoverable = "discoverable" 45 | case indexable = "indexable" 46 | case group = "group" 47 | case createdAt = "created_at" 48 | case note = "note" 49 | case url = "url" 50 | case uri = "uri" 51 | case avatar = "avatar" 52 | case avatarStatic = "avatar_static" 53 | case header = "header" 54 | case headerStatic = "header_static" 55 | case followersCount = "followers_count" 56 | case followingCount = "following_count" 57 | case statusesCount = "statuses_count" 58 | case lastStatusAt = "last_status_at" 59 | case hideCollections = "hide_collections" 60 | case noindex = "no_index" 61 | case fields = "fields" 62 | } 63 | 64 | public init( 65 | id: String, 66 | username: String, 67 | account: String, 68 | displayName: String, 69 | locked: Bool = false, 70 | bot: Bool = false, 71 | discoverable: Bool = false, 72 | indexable: Bool = false, 73 | group: Bool = false, 74 | createdAt: Date, 75 | note: String, 76 | url: String, 77 | uri: String, 78 | avatar: String, 79 | header: String, 80 | followersCount: UInt64 = 0, 81 | followingCount: UInt64 = 0, 82 | statusesCount: UInt64 = 0, 83 | lastStatusAt: Date, 84 | hideCollections: Bool = false, 85 | noindex: Bool = false, 86 | fields: [AccountField] = [] 87 | ) { 88 | self.id = id 89 | self.username = username 90 | self.account = account 91 | self.displayName = displayName 92 | self.locked = locked 93 | self.bot = bot 94 | self.discoverable = discoverable 95 | self.indexable = indexable 96 | self.group = group 97 | self.createdAt = createdAt.formatted(.iso8601.locale(Locale(identifier: "en_US_POSIX"))) 98 | self.note = note 99 | self.url = url 100 | self.uri = uri 101 | self.avatar = avatar 102 | avatarStatic = avatar 103 | self.header = header 104 | headerStatic = header 105 | self.followersCount = followersCount 106 | self.followingCount = followingCount 107 | self.statusesCount = statusesCount 108 | self.lastStatusAt = lastStatusAt.formatted(.iso8601.year().month().day()) 109 | self.hideCollections = hideCollections 110 | self.noindex = noindex 111 | self.fields = fields 112 | } 113 | } 114 | 115 | struct PersonEndpoints: ResponseEncodable, Codable, Equatable { 116 | var sharedInbox: String 117 | 118 | public enum CodingKeys: String, CodingKey { 119 | case sharedInbox = "shared_inbox" 120 | } 121 | } 122 | 123 | public struct AccountField: ResponseEncodable, Codable, Equatable { 124 | var name: String 125 | var value: String 126 | var verifiedAt: String? 127 | 128 | public enum CodingKeys: String, CodingKey { 129 | case name = "name" 130 | case value = "value" 131 | case verifiedAt = "verified_at" 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/MastodonData/Link.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Link.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 21/09/2024. 6 | // 7 | 8 | import Hummingbird 9 | 10 | public struct Link: ResponseEncodable, Codable, Equatable { 11 | public var rel: String? 12 | public var type: String? 13 | public var href: String? 14 | 15 | public init(rel: String?, type: String?, href: String?) { 16 | self.rel = rel 17 | self.type = type 18 | self.href = href 19 | } 20 | 21 | public enum CodingKeys: String, CodingKey { 22 | case rel = "rel" 23 | case type = "type" 24 | case href = "href" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/MastodonData/Status.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusMessage.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 01/01/2025. 6 | // 7 | import Foundation 8 | import Hummingbird 9 | 10 | public struct MastodonStatus: ResponseEncodable, Codable, Equatable { 11 | public let id: String? 12 | public let uri: String 13 | public let content: String 14 | public let account: MastodonAccount 15 | public let createdAt: String 16 | public let updatedAt: String? 17 | public let inReplyToId: String? 18 | public let reblogOfId: String? 19 | public let url: String 20 | public let sensitive: Bool 21 | public let visibility: String 22 | public let spoilerText: String? 23 | public let reply: Bool? 24 | public let language: String 25 | public let conversationId: String? 26 | public let local: Bool? 27 | public let accountId: String? 28 | public let applicationId: String? 29 | public let inReplyToAccountId: String? 30 | public let pollId: String? 31 | public let deletedAt: String? 32 | public let editedAt: String? 33 | public let trendable: Bool? 34 | public let orderedMediaAttachments: [String] 35 | 36 | public enum CodingKeys: String, CodingKey { 37 | case id = "id" 38 | case uri = "uri" 39 | case content = "content" 40 | case createdAt = "created_at" 41 | case updatedAt = "updated_at" 42 | case inReplyToId = "in_reply_to_id" 43 | case reblogOfId = "reblog_of_id" 44 | case url = "url" 45 | case sensitive = "sensitive" 46 | case visibility = "visibility" 47 | case spoilerText = "spoiler_text" 48 | case reply = "reply" 49 | case language = "language" 50 | case conversationId = "conversation_id" 51 | case local = "local" 52 | case accountId = "account_id" 53 | case account = "account" 54 | case applicationId = "application_id" 55 | case inReplyToAccountId = "in_reply_to_account_id" 56 | case pollId = "pollId" 57 | case deletedAt = "deleted_at" 58 | case editedAt = "edited_at" 59 | case trendable = "trendable" 60 | case orderedMediaAttachments = "media_attachments" 61 | } 62 | 63 | public init( 64 | id: String, 65 | uri: String, 66 | content: String, 67 | account: MastodonAccount, 68 | createdAt: String, 69 | updatedAt: String, 70 | inReplyToId: String, 71 | reblogOfId: String, 72 | url: String, 73 | sensitive: Bool, 74 | visibility: String, 75 | spoilerText: String, 76 | reply: Bool = false, 77 | language: String, 78 | conversationId: String, 79 | local: Bool = false, 80 | accountId: String, 81 | applicationId: String, 82 | inReplyToAccountId: String, 83 | pollId: String, 84 | deletedAt: String, 85 | editedAt: String, 86 | trendable: Bool = false, 87 | orderedMediaAttachments: [String] 88 | ) { 89 | self.id = id 90 | self.uri = uri 91 | self.content = content 92 | self.account = account 93 | self.createdAt = createdAt 94 | self.updatedAt = updatedAt 95 | self.inReplyToId = inReplyToId 96 | self.reblogOfId = reblogOfId 97 | self.url = url 98 | self.sensitive = sensitive 99 | self.visibility = visibility 100 | self.spoilerText = spoilerText 101 | self.reply = reply 102 | self.language = language 103 | self.conversationId = conversationId 104 | self.local = local 105 | self.accountId = accountId 106 | self.applicationId = applicationId 107 | self.inReplyToAccountId = inReplyToAccountId 108 | self.pollId = pollId 109 | self.deletedAt = deletedAt 110 | self.editedAt = editedAt 111 | self.trendable = trendable 112 | self.orderedMediaAttachments = orderedMediaAttachments 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/MastodonData/WebFinger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebFinger.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 21/09/2024. 6 | // 7 | 8 | import Hummingbird 9 | 10 | public struct WebFinger: ResponseEncodable, Codable, Equatable { 11 | public let subject: String 12 | public var links: [Link] = [] 13 | 14 | enum CodingKeys: String, CodingKey { 15 | case subject = "subject" 16 | case links = "links" 17 | } 18 | 19 | public init(subject: String, links: [Link]) { 20 | self.subject = subject 21 | self.links = links 22 | } 23 | 24 | public init(from decoder: any Decoder) throws { 25 | let container = try decoder.container(keyedBy: CodingKeys.self) 26 | subject = try container.decode(String.self, forKey: .subject) 27 | links = try container.decode([Link].self, forKey: .links) 28 | } 29 | 30 | public init(acctValue: String, hostname: String) throws { 31 | self.subject = acctValue 32 | var host = hostname 33 | if host.hasSuffix("/") { 34 | host.remove(at: host.endIndex) 35 | } 36 | if let accountName = extractAccountNameFromAcctValue(acctValue: acctValue) { 37 | self.links = [ 38 | Link( 39 | rel: "self", 40 | type: "application/activity+json", 41 | href: "https://" + host + "/accounts/" + accountName 42 | ) 43 | ] 44 | } 45 | } 46 | } 47 | 48 | /// The 'acctValue' is expected to be a string such as `acct:someone@host.com`. We want to extract the `someone` from 49 | /// that string. 50 | func extractAccountNameFromAcctValue(acctValue: String) -> String? { 51 | let colonIndex = acctValue.firstIndex(of: ":") ?? acctValue.startIndex 52 | let atIndex = acctValue.firstIndex(of: "@") ?? acctValue.endIndex 53 | return String(acctValue[acctValue.index(colonIndex, offsetBy: 1).. { 12 | let repository: Repository 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SignatureMiddleware/SigningBehaviour.md: -------------------------------------------------------------------------------- 1 | # Signing behaviour 2 | 3 | Mastodon has extended the ActivityPub objects to include a PublicKey. This is to enable it to sign the requests used to 4 | distribute status updates out to followers. 5 | 6 | ```mermaid 7 | sequenceDiagram 8 | participant Person 9 | participant Mastodon 10 | participant FollowersServer 11 | participant FollowersInbox 12 | Person->>Mastodon: create new status 13 | Mastodon->>FollowersServer: signed request publishing new status 14 | FollowersServer->>Mastodon: fetch key using keyId url and verify signature 15 | FollowersServer->> FollowersInbox: status is stored if the signature is valid 16 | ``` 17 | 18 | ## How is the signature created 19 | 20 | Mastodon selects particular elements from the request and builds a signature for those using the key for the `Person` 21 | (Actor in Mastodon documentation) originating the status. 22 | 23 | HTTP header looks like: - 24 | ``` 25 | Signature: keyId="https://my.example.com/actor#main-key",headers="(request-target) host date",signature="Y2FiYW...IxNGRiZDk4ZA==" 26 | ``` 27 | The three parts in this signature are:- 28 | * keyId - URL to the public key of the Person 29 | * headers - The headers used as source data for the signature 30 | * signature - SH256 hash of the headers which is signed using the private key of the Person and then base64 encoded 31 | 32 | For POST requests, an additional header is added which is a SHA256 of the request body. This is stored in `Digest` 33 | header which is then included in the signature. 34 | 35 | -------------------------------------------------------------------------------- /Sources/Storage/Storage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Storage.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 28/09/2024. 6 | // 7 | import Foundation 8 | 9 | public enum ServerDialect: String, Codable, CaseIterable { 10 | case mastodon 11 | 12 | public init(fromString: String) { 13 | for dialect in ServerDialect.allCases { 14 | if fromString == dialect.rawValue { 15 | self = dialect 16 | } 17 | } 18 | self = ServerDialect(rawValue: "mastodon")! 19 | } 20 | } 21 | 22 | extension ServerDialect: Sendable { 23 | public func match(string: String) -> ServerDialect? { 24 | for dialect in ServerDialect.allCases { 25 | if string == dialect.rawValue { 26 | return dialect 27 | } 28 | } 29 | return nil 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/AppTests/AppTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Hummingbird 3 | import HummingbirdFluent 4 | import HummingbirdTesting 5 | import Logging 6 | import Testing 7 | 8 | @testable import App 9 | 10 | @Suite struct AppTests { 11 | struct TestAppArguments: AppArguments { 12 | var privateKey: String { "certs/server.key" } 13 | var certificateChain: String { "certs/server.crt" } 14 | 15 | let inMemoryDatabase = true 16 | let hostname = "127.0.0.1" 17 | let port = 0 18 | let logLevel: Logger.Level? = .trace 19 | } 20 | 21 | var app: any ApplicationProtocol 22 | 23 | init() async throws { 24 | self.app = try await buildApplication(TestAppArguments()) 25 | } 26 | 27 | @Test func testAppBuilds() async throws { 28 | try await app.test(.router) { client in 29 | try await client.execute(uri: "/health", method: .get) { response in 30 | #expect(response.status == .ok) 31 | } 32 | } 33 | } 34 | } 35 | 36 | @Suite struct PersonTests { 37 | struct TestAppArguments: AppArguments { 38 | var privateKey: String { "certs/server.key" } 39 | var certificateChain: String { "certs/server.crt" } 40 | 41 | let inMemoryDatabase = true 42 | let hostname = "127.0.0.1" 43 | let port = 0 44 | let logLevel: Logger.Level? = .trace 45 | } 46 | 47 | var app: any ApplicationProtocol 48 | 49 | init() async throws { 50 | self.app = try await buildApplication(TestAppArguments()) 51 | } 52 | 53 | @Test func testPersonCreate() async throws { 54 | try await app.test(.live) { client in 55 | let personRepos = FluentPersonStorage(fluent: app.services[0] as! Fluent) 56 | if let person = try personRepos.create(from: CreatePerson(name: "testperson", fullName: "a test")) { 57 | #expect(person.name == "testperson") 58 | } 59 | } 60 | } 61 | 62 | @Test func testPersonGet() async throws { 63 | try await app.test(.live) { client in 64 | let personRepos = FluentPersonStorage(fluent: app.services[0] as! Fluent) 65 | let person = try personRepos.create(from: CreatePerson(name: "testperson", fullName: "a test"))! 66 | 67 | let getCriteria = PersonCriteria(handle: nil, id: person.id) 68 | let fetchedPerson = await personRepos.get(criteria: getCriteria) 69 | #expect(fetchedPerson != nil) 70 | #expect(fetchedPerson?.fullName == person.fullName) 71 | } 72 | } 73 | } 74 | 75 | @Suite struct ObjectDecoding { 76 | let goodObject: String = """ 77 | { 78 | "@context": "https://www.w3.org/ns/activitystreams", 79 | "type": "Object", 80 | "id": "http://www.test.example/object/1", 81 | "name": "A Simple, non-specific object", 82 | "attachment": [ 83 | { 84 | "@context": "https://www.w3.org/ns/activitystreams", 85 | "type": "Link", 86 | "href": "http://example.org/abc", 87 | "hreflang": "en", 88 | "mediaType": "text/html", 89 | "name": "An example link" 90 | } 91 | ] 92 | } 93 | """ 94 | 95 | @Test func TestObjectDecode() { 96 | let jsonData = goodObject.data(using: .utf8)! 97 | let decodedObject: Object = try! JSONDecoder().decode(Object.self, from: jsonData) 98 | #expect(decodedObject.name == "A Simple, non-specific object") 99 | } 100 | } 101 | 102 | @Test func Test() {} 103 | -------------------------------------------------------------------------------- /Tests/AppTests/SharedTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import App 5 | 6 | @Suite struct DateTimeRFC3339TimestampTests { 7 | @Test func testRFC3339Timestamp() async throws { 8 | let testDate = Date(timeIntervalSince1970: 1736586339.461763) 9 | let timestamp = RFC3339Timestamp(fromDate: testDate) 10 | #expect(timestamp == "2025-01-11T09:05:39Z") 11 | } 12 | } 13 | 14 | @Suite struct DateTimeParseRFCTimestampToUTC { 15 | @Test func middayMonday6thJune2024BST() async throws { 16 | let dateComponents = DateComponents( 17 | calendar: Calendar.current, 18 | timeZone: TimeZone.init(secondsFromGMT: 3600), 19 | year: 2024, 20 | month: 6, 21 | day: 6, 22 | hour: 12, 23 | minute: 0, 24 | second: 0, 25 | nanosecond: 0 26 | ) 27 | let expectedDate = dateComponents.date! 28 | 29 | let timestampToParse = "2024-06-06T12:00:00+01:00" 30 | let parsedDate = ParseRFCTimestampToUTC(fromString: timestampToParse) 31 | 32 | #expect(parsedDate == expectedDate) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/MastodonData/MastodonDataTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MastodonDataTests.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 21/09/2024. 6 | // 7 | 8 | import Foundation 9 | import MastodonData 10 | import Testing 11 | 12 | @Suite struct TestDecoding { 13 | let webFingerExample: String = """ 14 | { 15 | 16 | "subject": "acct:bugle@bugle.lol", 17 | "links": [ 18 | { 19 | "rel": "self", 20 | "type": "application/activity+json", 21 | "href": "https://bugle.lol/@bugle" 22 | } 23 | ] 24 | } 25 | """ 26 | 27 | @Test func TestDecodingWebFingerExample() { 28 | let jsonData = webFingerExample.data(using: .utf8)! 29 | let decodedObject: WebFinger = try! JSONDecoder().decode(WebFinger.self, from: jsonData) 30 | #expect(decodedObject.subject == "acct:bugle@bugle.lol") 31 | do { 32 | try #require(decodedObject.links.count == 1) 33 | #expect(decodedObject.links[0].rel == "self") 34 | #expect(decodedObject.links[0].type == "application/activity+json") 35 | #expect(decodedObject.links[0].href == "https://bugle.lol/@bugle") 36 | } catch {} 37 | } 38 | } 39 | 40 | @Suite struct TestEncoding { 41 | @Test func TestEncodingWebFingerExample() { 42 | // encode the object to JSON 43 | let encodeObject = WebFinger( 44 | subject: "acct:bugle@bugle.lol", 45 | links: [ 46 | Link(rel: "self", type: "application/activity+json", href: "https://bugle.lol/@bugle") 47 | ] 48 | ) 49 | let encoder = JSONEncoder() 50 | encoder.outputFormatting = .withoutEscapingSlashes 51 | let encodedJsonData = try! encoder.encode(encodeObject) 52 | 53 | // Decode the JSON back to an object and compare it with the original 54 | let decodedObject: WebFinger = try! JSONDecoder().decode(WebFinger.self, from: encodedJsonData) 55 | #expect(decodedObject.subject == encodeObject.subject) 56 | do { 57 | try #require(decodedObject.links.count == 1) 58 | #expect(decodedObject.links[0].rel == encodeObject.links[0].rel) 59 | #expect(decodedObject.links[0].type == encodeObject.links[0].type) 60 | #expect(decodedObject.links[0].href == encodeObject.links[0].href) 61 | } catch {} 62 | } 63 | } 64 | 65 | @Suite struct TestWebFinger { 66 | @Test func TestInitWithValidData() throws { 67 | let webFinger = try WebFinger(acctValue: "acct:someone@host.com", hostname: "https://host.com") 68 | #expect(webFinger.links[0].href == "https://host.com/users/someone") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/SignatureMiddleware/SignatureMiddlewareTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignatureMiddlewareTests.swift 3 | // swiftodon 4 | // 5 | // Created by Jonathan Pulfer on 05/10/2024. 6 | // 7 | 8 | import Crypto 9 | import Foundation 10 | import SwiftASN1 11 | import Testing 12 | 13 | @Suite struct KeyTests { 14 | 15 | // Valid Curve25519 key details verified by openssl. 16 | let privateKeyBase64 = "sffPc3mtiJDKdGkN7TiascPiMeXm7UZqfXdBlYG7iyc=" 17 | let expectedPublicKeyPem = 18 | "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEAtV5dzF+zZV9Yup+riEAqaCNol/JumbAPjrT6CkEdpGg=\n-----END PUBLIC KEY-----\n" 19 | 20 | @Test func TestLoadingKeyFromBase64Data() throws { 21 | let keyData = Data(base64Encoded: privateKeyBase64)! 22 | let privateKey = try Curve25519.Signing.PrivateKey.init(rawRepresentation: keyData) 23 | #expect(privateKey.publicKey.exportAsPem() == expectedPublicKeyPem) 24 | } 25 | 26 | @Test func TestPEMDecoding() throws { 27 | let pemDoc = try PEMDocument.init(pemString: self.expectedPublicKeyPem) 28 | print("length of derBytes: \(pemDoc.derBytes.count)") 29 | let ecKey = try Curve25519.Signing.PublicKey.init(rawRepresentation: pemDoc.derBytes[12...]) 30 | print("ecKey: \(ecKey)") 31 | } 32 | } 33 | 34 | extension Curve25519.Signing.PublicKey { 35 | func exportAsPem() -> String { 36 | let prefix = Data([0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65, 0x6E, 0x03, 0x21, 0x00]) 37 | let subjectPublicKeyInfo = prefix + self.rawRepresentation 38 | return 39 | "-----BEGIN PUBLIC KEY-----\n\(subjectPublicKeyInfo.base64EncodedString())\n-----END PUBLIC KEY-----\n" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /public/js/webauthn.js: -------------------------------------------------------------------------------- 1 | // WebAuthn Javascript functions 2 | 3 | /** 4 | * Register User 5 | * @param {*} username 6 | * @param {*} fullname 7 | */ 8 | async function register(username) { 9 | try { 10 | let data = { 11 | "username": username 12 | } 13 | // signup api call 14 | const response = await fetch('/api/user/signup', { 15 | method: 'POST', 16 | headers: {"content-type": "application/json"}, 17 | body: JSON.stringify(data) 18 | }); 19 | switch (response.status ) { 20 | case 200: 21 | break 22 | case 409: 23 | throw Error("Username already exist"); 24 | default: 25 | throw Error(`Error: status code: ${response.status}`) 26 | } 27 | const responseJSON = await response.json(); 28 | const publicKeyCredentialCreationOptions = createPublicKeyCredentialCreationOptionsFromServerResponse(responseJSON); 29 | const result = await navigator.credentials.create({publicKey: publicKeyCredentialCreationOptions}); 30 | const registrationCredential = createRegistrationCredentialForServer(result); 31 | // finish registration api call 32 | const finishResponse = await fetch('/api/user/register/finish', { 33 | method: "POST", 34 | headers: {"content-type": "application/json"}, 35 | body: JSON.stringify(registrationCredential) 36 | }); 37 | if (finishResponse.status !== 200) { 38 | throw Error(`Error: status code: ${finishResponse.status}`) 39 | } 40 | location = "/login.html"; 41 | } catch(error) { 42 | alert(`Login failed: ${error.message}`) 43 | } 44 | } 45 | 46 | /** 47 | * Login user 48 | */ 49 | async function login() { 50 | try { 51 | const redirectTo = location.search.split('from=')[1]; 52 | // initiate login 53 | const response = await fetch('/api/user/login') 54 | if (response.status !== 200) { 55 | throw Error(`Error: status code: ${response.status}`) 56 | } 57 | const responseBody = await response.json() 58 | const publicKeyCredentialRequestOptions = createPublicKeyCredentialRequestOptionsFromServerResponse(responseBody) 59 | const result = await navigator.credentials.get({ 60 | publicKey: publicKeyCredentialRequestOptions 61 | }); 62 | const credential = createAuthenicationCredentialForServer(result); 63 | // finish login 64 | const finishResponse = await fetch('/api/user/login', { 65 | method: 'POST', 66 | headers: {"content-type": 'application/json'}, 67 | body: JSON.stringify(credential) 68 | }); 69 | if (finishResponse.status !== 200) { 70 | throw Error(`Error: status code: ${finishResponse.status}`) 71 | } 72 | if (redirectTo.length > 0) { 73 | location = redirectTo; 74 | } else { 75 | location = "/"; 76 | } 77 | } catch(error) { 78 | alert(`Login failed: ${error.message}`) 79 | } 80 | } 81 | 82 | /** 83 | * Logout user 84 | */ 85 | async function logout() { 86 | try { 87 | // initiate logout 88 | const response = await fetch('/api/user/logout') 89 | if (response.status !== 200) { 90 | throw Error(`Error: status code: ${response.status}`) 91 | } 92 | location.reload(); 93 | } catch(error) { 94 | alert(`Logout failed: ${error.message}`) 95 | } 96 | } 97 | 98 | /** 99 | * Test authentication details 100 | */ 101 | async function test() { 102 | try { 103 | const response = await fetch('/api/user/test') 104 | if (response.status !== 200) { 105 | throw Error(`Error: status code: ${response.status}`) 106 | } 107 | const responseJSON = await response.json(); 108 | return JSON.stringify(responseJSON); 109 | } catch(error) { 110 | return "Failed to get authenication data"; 111 | } 112 | } 113 | 114 | /** 115 | * Convert server response from /api/user/beginregister to PublicKeyCredentialCreationOptions 116 | * @param {*} response Server response from /api/user/beginregister 117 | * @returns PublicKeyCredentialCreationOptions 118 | */ 119 | function createPublicKeyCredentialCreationOptionsFromServerResponse(response) { 120 | const challenge = bufferDecode(response.challenge); 121 | const userId = bufferDecode(response.user.id); 122 | return { 123 | challenge: challenge, 124 | rp: response.rp, 125 | user: { 126 | id: userId, 127 | name: response.user.name, 128 | displayName: response.user.displayName, 129 | }, 130 | pubKeyCredParams: response.pubKeyCredParams, 131 | timeout: response.timeout, 132 | }; 133 | } 134 | 135 | /** 136 | * Convert return value from navigator.credentials.create to input JSON for /api/user/finishregister 137 | * @param {*} registrationCredential Result of navigator.credentials.create 138 | * @returns Input for /api/user/finishregister 139 | */ 140 | function createRegistrationCredentialForServer(registrationCredential) { 141 | return { 142 | authenicatorAttachment: registrationCredential.authenicatorAttachment, 143 | id: registrationCredential.id, 144 | rawId: bufferEncode(registrationCredential.rawId), 145 | type: registrationCredential.type, 146 | response: { 147 | attestationObject: bufferEncode(registrationCredential.response.attestationObject), 148 | clientDataJSON: bufferEncode(registrationCredential.response.clientDataJSON) 149 | } 150 | } 151 | } 152 | 153 | /** 154 | * Convert return value from GET /api/user/login to PublicKeyCredentialRequestOptions 155 | * @param {*} response Server response from GET /api/user/login 156 | * @returns PublicKeyCredentialRequestOptions 157 | */ 158 | function createPublicKeyCredentialRequestOptionsFromServerResponse(response) { 159 | return { 160 | challenge: bufferDecode(response.challenge), 161 | allowCredentials: response.allowCredentials, 162 | timeout: response.timeout, 163 | } 164 | } 165 | 166 | /** 167 | * Convert return value of navigator.credentials.get to input JSON for POST /api/user/login 168 | * @param {*} credential Result of navigator.credentials.get 169 | * @returns Input for POST /api/user/login 170 | */ 171 | function createAuthenicationCredentialForServer(credential) { 172 | return { 173 | id: credential.id, 174 | rawId: bufferEncode(credential.rawId), 175 | authenticatorAttachment: credential.authenticatorAttachment, 176 | type: credential.type, 177 | response: { 178 | authenticatorData: bufferEncode(credential.response.authenticatorData), 179 | clientDataJSON: bufferEncode(credential.response.clientDataJSON), 180 | signature: bufferEncode(credential.response.signature), 181 | userHandle: bufferEncode(credential.response.userHandle)//String.fromCharCode(...new Uint8Array(credential.response.userHandle)) 182 | } 183 | } 184 | } 185 | 186 | function bufferEncode(value) { 187 | return btoa(String.fromCharCode(...new Uint8Array(value))) 188 | .replace(/\+/g, "-") 189 | .replace(/\//g, "_") 190 | .replace(/=/g, ""); 191 | } 192 | 193 | function bufferDecode(value) { 194 | return Uint8Array.from(atob(value.replace(/_/g, '/').replace(/-/g, '+')), c => c.charCodeAt(0)); 195 | } 196 | 197 | -------------------------------------------------------------------------------- /public/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Swiftodon 5 | 6 | 7 | 8 | 9 | 10 |
11 |

Swiftodon login

12 |
13 |
14 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /public/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Swiftodon Register 5 | 6 | 7 | 8 | 15 | 16 | 17 |
18 |

Swiftodon Register

19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 |
28 |
29 |
30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /status.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "113765980864234756", 3 | "created_at": "2025-01-03T19:05:02.000Z", 4 | "in_reply_to_id": null, 5 | "in_reply_to_account_id": null, 6 | "sensitive": false, 7 | "spoiler_text": "", 8 | "visibility": "public", 9 | "language": "en", 10 | "uri": "https://ubuntu.social/users/askubuntu/statuses/113765980745936672", 11 | "url": "https://ubuntu.social/@askubuntu/113765980745936672", 12 | "replies_count": 0, 13 | "reblogs_count": 0, 14 | "favourites_count": 0, 15 | "edited_at": null, 16 | "favourited": false, 17 | "reblogged": false, 18 | "muted": false, 19 | "bookmarked": false, 20 | "content": "\u003cp\u003eWhy does \"cat\" sometimes skip bytes when reading from another terminal, but the golang scan function does not? \u003ca href=\"https://ubuntu.social/tags/gnometerminal\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e#\u003cspan\u003egnometerminal\u003c/span\u003e\u003c/a\u003e \u003ca href=\"https://ubuntu.social/tags/golang\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e#\u003cspan\u003egolang\u003c/span\u003e\u003c/a\u003e\u003c/p\u003e\u003cp\u003e\u003ca href=\"https://askubuntu.com/q/1537124/612\" rel=\"nofollow noopener noreferrer\" translate=\"no\" target=\"_blank\"\u003e\u003cspan class=\"invisible\"\u003ehttps://\u003c/span\u003e\u003cspan class=\"\"\u003easkubuntu.com/q/1537124/612\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e\u003c/p\u003e", 21 | "filtered": [], 22 | "reblog": null, 23 | "account": { 24 | "id": "109818358752466148", 25 | "username": "askubuntu", 26 | "acct": "askubuntu@ubuntu.social", 27 | "display_name": "AskUbuntu", 28 | "locked": false, 29 | "bot": true, 30 | "discoverable": true, 31 | "indexable": false, 32 | "group": false, 33 | "created_at": "2023-02-06T00:00:00.000Z", 34 | "note": "\u003cp\u003eBot created by \u003cspan class=\"h-card\" translate=\"no\"\u003e\u003ca href=\"https://ubuntu.social/@popey\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003epopey\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e to post hot questions from AskUbuntu into the Fediverse.\u003c/p\u003e", 35 | "url": "https://ubuntu.social/@askubuntu", 36 | "uri": "https://ubuntu.social/users/askubuntu", 37 | "avatar": "https://media.mstdn.social/cache/accounts/avatars/109/818/358/752/466/148/original/8bac460a10649f0b.png", 38 | "avatar_static": "https://media.mstdn.social/cache/accounts/avatars/109/818/358/752/466/148/original/8bac460a10649f0b.png", 39 | "header": "https://media.mstdn.social/cache/accounts/headers/109/818/358/752/466/148/original/c9de7da84f72349d.png", 40 | "header_static": "https://media.mstdn.social/cache/accounts/headers/109/818/358/752/466/148/original/c9de7da84f72349d.png", 41 | "followers_count": 471, 42 | "following_count": 0, 43 | "statuses_count": 1217, 44 | "last_status_at": "2025-01-03", 45 | "hide_collections": false, 46 | "emojis": [], 47 | "fields": [{ 48 | "name": "AskUbuntu", 49 | "value": "\u003ca href=\"https://askubuntu.com/\" rel=\"nofollow noopener noreferrer\" translate=\"no\" target=\"_blank\"\u003e\u003cspan class=\"invisible\"\u003ehttps://\u003c/span\u003e\u003cspan class=\"\"\u003easkubuntu.com/\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e", 50 | "verified_at": null 51 | }] 52 | }, 53 | "media_attachments": [], 54 | "mentions": [], 55 | "tags": [{ 56 | "name": "gnometerminal", 57 | "url": "https://mstdn.social/tags/gnometerminal" 58 | }, { 59 | "name": "Golang", 60 | "url": "https://mstdn.social/tags/Golang" 61 | }], 62 | "emojis": [], 63 | "card": { 64 | "url": "https://askubuntu.com/q/1537124/612", 65 | "title": "Why does \"cat\" sometimes skip bytes when reading from another terminal, but the golang scan function does not?", 66 | "description": "One question related to pty-s (pseudo-terminals) and the way (cat) golang reads from them.\nFirst, I did the following.\n\nopen two terminals (dev/pts/0 - 0 and dev/pts/1 - 1)\nthen I type in second te...", 67 | "language": "en", 68 | "type": "link", 69 | "author_name": "", 70 | "author_url": "", 71 | "provider_name": "Ask Ubuntu", 72 | "provider_url": "", 73 | "html": "", 74 | "width": 316, 75 | "height": 316, 76 | "image": "https://media.mstdn.social/cache/preview_cards/images/068/335/960/original/1b7c6b6fd482e311.png", 77 | "image_description": "", 78 | "embed_url": "", 79 | "blurhash": "UpR_x8WBuPoLayoKfkWquPj[e8fQoeWqfQn%", 80 | "published_at": null, 81 | "authors": [{ 82 | "name": "", 83 | "url": "", 84 | "account": null 85 | }] 86 | }, 87 | "poll": null 88 | } 89 | --------------------------------------------------------------------------------