diff --git a/Dockerfile b/Dockerfile index 6ca4da4..26e3a4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,24 @@ -FROM oven/bun:alpine +FROM oven/bun:alpine as base + +# Install dependencies into temp directory +# This will cache them and speed up future builds +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lockb /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +# Install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json bun.lockb /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production + +FROM base AS builder COPY . /app +RUN cd /app && bun install +RUN cd /app && bun docs:build -RUN cd ./app && bun install -RUN cd ./app && bun docs:build - -FROM oven/bun:alpine +FROM base AS final COPY --from=builder /app/.vitepress/dist/ /app @@ -14,4 +27,7 @@ LABEL org.opencontainers.image.source "https://github.com/lysand-org/docs" LABEL org.opencontainers.image.vendor "Lysand.org" LABEL org.opencontainers.image.licenses "MIT" LABEL org.opencontainers.image.title "Lysand Docs" -LABEL org.opencontainers.image.description "Documentation for Lysand" \ No newline at end of file +LABEL org.opencontainers.image.description "Documentation for Lysand" + +WORKDIR /app +CMD ["bun", "docs:serve"] \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index d72ca56..d8a9601 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/cryptography/signing.md b/docs/cryptography/signing.md index 843836a..d3191f4 100644 --- a/docs/cryptography/signing.md +++ b/docs/cryptography/signing.md @@ -28,7 +28,7 @@ The signature is calculated as follows: ``` (request-target): post /users/uuid/inbox host: example.com -date: Fri, 01 Jan 2021 00:00:00 GMT +date: 2024-04-10T01:27:24.880Z digest: SHA-256=base64_digest ``` @@ -40,66 +40,59 @@ The `digest` field **MUST** be the SHA-256 digest of the request body, base64-en The `date` field **MUST** be the date and time that the request was sent, formatted as follows (ISO 8601): ``` -Fri, 01 Jan 2021 00:00:00 GMT +2024-04-10T01:27:24.880Z ``` -The `host` field **MUST** be the domain of the server that is receiving the request. +The `host` field **MUST** be the host of the server that is receiving the request. The `request-target` field **MUST** be the request target of the request, formatted as follows: ``` post /users/uuid/inbox ``` -Where `/users/uuid/inbox` is the path of the request. +Where `/users/uuid/inbox` is the path of the request (this will depend on implementations). -Here is an example of signing a request using TypeScript and the WebCrypto API: +Let's imagine a user at `example.com` wants to send something to a user at `receiver.com`'s inbox. + +Here is an example of signing a request using TypeScript and the WebCrypto API (replace `status_author_private_key`, `full_lysand_object_as_string` and sample text appropriate): ```typescript -/** - * Convert a string into an ArrayBuffer - * from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String - */ -const str2ab = (str: string) => { - const buf = new ArrayBuffer(str.length); - const bufView = new Uint8Array(buf); - for (let i = 0, strLen = str.length; i < strLen; i++) { - bufView[i] = str.charCodeAt(i); - } - return buf; -}; - const privateKey = await crypto.subtle.importKey( - "pkcs8", - str2ab(atob("base64_private_key")), - "Ed25519", - false, - ["sign"] + "pkcs8", + Uint8Array.from(atob(status_author_private_key), (c) => + c.charCodeAt(0), + ), + "Ed25519", + false, + ["sign"], ); const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode("request_body") + "SHA-256", + new TextEncoder().encode(full_lysand_object_as_string), ); -const userInbox = new URL("..."); +const userInbox = new URL( + "https://receiver.com/users/22a56612-9909-48ca-84af-548b28db6fd5/inbox" +); const date = new Date(); const signature = await crypto.subtle.sign( - "Ed25519", - privateKey, - new TextEncoder().encode( - `(request-target): post ${userInbox.pathname}\n` + - `host: ${userInbox.host}\n` + - `date: ${date.toUTCString()}\n` + - `digest: SHA-256=${btoa( - String.fromCharCode(...new Uint8Array(digest)) - )}\n` - ) + "Ed25519", + privateKey, + new TextEncoder().encode( + `(request-target): post ${userInbox.pathname}\n` + + `host: ${userInbox.host}\n` + + `date: ${date.toISOString()}\n` + + `digest: SHA-256=${btoa( + String.fromCharCode(...new Uint8Array(digest)), + )}\n`, + ), ); const signatureBase64 = btoa( - String.fromCharCode(...new Uint8Array(signature)) + String.fromCharCode(...new Uint8Array(signature)), ); ``` @@ -108,50 +101,79 @@ const signatureBase64 = btoa( The request can then be sent with the `Signature`, `Origin` and `Date` headers as follows: ```ts -await fetch("https://example.com/users/uuid/inbox", { +await fetch("https://receiver.com/users/22a56612-9909-48ca-84af-548b28db6fd5/inbox", { method: "POST", headers: { "Content-Type": "application/json", - Date: date.toUTCString(), - Origin: "https://example.com", - Signature: `keyId="${...}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, + Date: date.toISOString(), + Origin: "example.com", + Signature: `keyId="https://example.com/users/caf18716-800d-4c88-843d-4947ab39ca0f",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, }, - body: JSON.stringify({ - // ... - }) + body: full_lysand_object_as_string, }); ``` Example of validation on the server side: ```typescript -// request is a Request object containing the previous request -// public_key is the user's public key in raw base64 format +// req is a Request object +const signatureHeader = req.headers.get("Signature"); +const origin = req.headers.get("Origin"); +const date = req.headers.get("Date"); -const signatureHeader = request.headers.get("Signature"); +if (!signatureHeader) { + return errorResponse("Missing Signature header", 400); +} -const signature = signatureHeader.split("signature=")[1].replace(/"/g, ""); +if (!origin) { + return errorResponse("Missing Origin header", 400); +} -const origin = request.headers.get("Origin"); +if (!date) { + return errorResponse("Missing Date header", 400); +} -const date = request.headers.get("Date"); +const signature = signatureHeader + .split("signature=")[1] + .replace(/"/g, ""); const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(await request.text()) + "SHA-256", + new TextEncoder().encode(JSON.stringify(body)), ); -const expectedSignedString = `(request-target): ${request.method.toLowerCase()} ${request.url}\n` + - `host: ${request.url}\n` + - `date: ${date}\n` + - `digest: SHA-256=${btoa(digest)}`; +const keyId = signatureHeader + .split("keyId=")[1] + .split(",")[0] + .replace(/"/g, ""); + +// TODO: Fetch sender using WebFinger if not found +const sender = ... // Get sender from your database via its URI (inside the keyId variable) + +const public_key = await crypto.subtle.importKey( + "spki", + Uint8Array.from(atob(sender.publicKey), (c) => c.charCodeAt(0)), + "Ed25519", + false, + ["verify"], +); + +const expectedSignedString = + `(request-target): ${req.method.toLowerCase()} ${ + new URL(req.url).pathname + }\n` + + `host: ${new URL(req.url).host}\n` + + `date: ${date}\n` + + `digest: SHA-256=${btoa( + String.fromCharCode(...new Uint8Array(digest)), + )}\n`; // Check if signed string is valid const isValid = await crypto.subtle.verify( - "Ed25519", - publicKey, - new TextEncoder().encode(signature), - new TextEncoder().encode(expectedSignedString) + "Ed25519", + public_key, + Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)), + new TextEncoder().encode(expectedSignedString), ); if (!isValid) { @@ -170,4 +192,4 @@ When implementing cryptography in Lysand, it is important to consider the follow - **Key Export**: Do not export private keys to untrusted environments, but allow users to export their private keys to secure locations. - **Key Import**: Allow users to import private keys when creating new account, but ensure that the keys are not exposed to unauthorized parties. -Most implementations should not roll their own cryptography, but instead use well-established libraries such as the WebCrypto API. (See the [WebCrypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) documentation for more information). Libraries written in unsafe languages, such as C, or that are a frequent source of security issues (e.g., OpenSSL) should be avoided. \ No newline at end of file +Most implementations should not roll their own cryptography, but instead use well-established libraries such as the WebCrypto API. (See the [WebCrypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) documentation for more information). Libraries written in unsafe languages, such as C, or that are a frequent source of security issues (e.g., OpenSSL) should be avoided. diff --git a/docs/federation/endpoints.md b/docs/federation/endpoints.md index e74bec1..553a9d8 100644 --- a/docs/federation/endpoints.md +++ b/docs/federation/endpoints.md @@ -48,7 +48,7 @@ curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" - }' https://example.com/users/uuid/inbox ``` -The server **MUST** respond with a `200 OK` response code if the operation was successful. +The server **MUST** respond with a `201 CREATED` response code if the operation was successful. ## User Outbox diff --git a/docs/federation/user-discovery.md b/docs/federation/user-discovery.md index 44704cb..96ce849 100644 --- a/docs/federation/user-discovery.md +++ b/docs/federation/user-discovery.md @@ -16,15 +16,15 @@ The document **MUST** contain the following information, as specified by the Web The `template` field **MUST** be the URI of the server's WebFinger endpoint, which is usually `https://example.com/.well-known/webfinger?resource={uri}`. -The `resource` field **MUST** be the URI of the user that the server is trying to discover (in the format `acct:uuid@example.com`) +The `resource` field **MUST** be the URI of the user that the server is trying to discover (in the format `acct:identifier@example.com`) Breaking down this URI, we get the following: - `acct`: The protocol of the URI. This is always `acct` for Lysand. -- `uuid`: The UUID of the user that the server is trying to discover. +- `identifier`: Either the UUID or the username of the user that the server is trying to discover. - `example.com`: The domain of the server that the user is on. This is usually the domain of the server. This can also be a subdomain of the server, such as `lysand.example.com`. -This format is reminiscent of the `acct` format used by ActivityPub, but with a UUID instead of a username. Users will typically not use the `id` of an actor to identify it, but instead its `username`: servers **MUST** only use the `id` to identify actors. +This format is reminiscent of the `acct` format used by ActivityPub, but with either a UUID or a username instead of just an username. Users will typically not use the `id` of an actor to identify it, but instead its `username`: servers **MUST** only use the `id` to identify actors. --- @@ -39,7 +39,7 @@ The requesting server **MUST** send the following headers with the request: The requestinng server **MUST** send the following query parameters with the request: -- `resource`: The URI of the user that the server is trying to discover (in the format `acct:uuid@example.com` (replace `uuid` with the user's ID) +- `resource`: The URI of the user that the server is trying to discover (in the format `acct:identifier@example.com` (replace `identifier` with the user's ID or username) --- @@ -47,7 +47,7 @@ The server **MUST** respond with a `200 OK` response code, and a JSON object in ```json5 { - "subject": "acct:uuid@example.com", + "subject": "acct:identifier@example.com", "links": [ { "rel": "self", diff --git a/docs/objects.md b/docs/objects.md index d7b2cf5..5dcb26b 100644 --- a/docs/objects.md +++ b/docs/objects.md @@ -51,11 +51,17 @@ This document uses TypeScript to define the types of the entities in a clear and ```typescript interface Entity { - id: string; - created_at: string; - uri: string; - type: string; -}; + id: string; + created_at: string; + uri: string; + type: string; + extensions?: { + "org.lysand:custom_emojis"?: { + emojis: Emoji[]; + }; + [key: string]: object | undefined; + }; +} ``` The `Entity` type is the base type for all entities in the Lysand protocol. It includes the `id`, `created_at`, `uri`, and `type` attributes. diff --git a/docs/objects/actions.md b/docs/objects/actions.md index 673dbbf..733a72d 100644 --- a/docs/objects/actions.md +++ b/docs/objects/actions.md @@ -40,4 +40,13 @@ This approach helps prevent potential misuse of the protocol to determine if a u | :----- | :----- | :------- | | author | String | Yes | -URI of the [Actor](./actors) who initiated the action. \ No newline at end of file +URI of the [Actor](./actors) who initiated the action. + +## Types + +```typescript +interface Action extends Entity { + type: "Like" | "Dislike" | "Follow" | "FollowAccept" | "FollowReject" | "Announce" | "Undo"; + author: string +} +``` \ No newline at end of file diff --git a/docs/objects/announce.md b/docs/objects/announce.md index 8c31fdb..ecb8901 100644 --- a/docs/objects/announce.md +++ b/docs/objects/announce.md @@ -1 +1 @@ -This page has been moved to the [Microblogging Extension](../extensions/microblogging#announce). \ No newline at end of file +This page has been moved to the [Microblogging Extension](../extensions/microblogging#announce). diff --git a/docs/objects/dislike.md b/docs/objects/dislike.md index dd773b1..8b7002c 100644 --- a/docs/objects/dislike.md +++ b/docs/objects/dislike.md @@ -35,9 +35,8 @@ URI of the object being disliked. Must be of type [Note](./note) ## Types ```typescript -interface Dislike extends Entity { +interface Dislike extends Action { type: "Dislike"; - author: string; object: string; } ``` \ No newline at end of file diff --git a/docs/objects/follow-accept.md b/docs/objects/follow-accept.md index 48b7c2c..d595c28 100644 --- a/docs/objects/follow-accept.md +++ b/docs/objects/follow-accept.md @@ -35,9 +35,8 @@ URI of the [User](./user) who tried to follow the author ## Types ```typescript -interface FollowAccept extends Entity { +interface FollowAccept extends Action { type: "FollowAccept"; - author: string; follower: string; } ``` \ No newline at end of file diff --git a/docs/objects/follow-reject.md b/docs/objects/follow-reject.md index 6b64bf0..aed6449 100644 --- a/docs/objects/follow-reject.md +++ b/docs/objects/follow-reject.md @@ -35,9 +35,8 @@ URI of the [User](./user) who tried to follow the author. ## Types ```typescript -interface FollowReject extends Entity { +interface FollowReject extends Action { type: "FollowReject"; - author: string; follower: string; } ``` diff --git a/docs/objects/follow.md b/docs/objects/follow.md index 64ebc5f..7d907bd 100644 --- a/docs/objects/follow.md +++ b/docs/objects/follow.md @@ -35,9 +35,8 @@ URI of the [User](./user) who is being follow requested. ## Types ```typescript -interface Follow extends Entity { +interface Follow extends Action { type: "Follow"; - author: string; followee: string; } ``` diff --git a/docs/objects/like.md b/docs/objects/like.md index bdd555c..b80ebba 100644 --- a/docs/objects/like.md +++ b/docs/objects/like.md @@ -35,9 +35,8 @@ URI of the object being liked. Must be of type [Note](./note) ## Types ```typescript -interface Like extends Entity { +interface Like extends Action { type: "Like"; - author: string; object: string; } ``` diff --git a/docs/objects/publications.md b/docs/objects/publications.md index 8566527..7e2242c 100644 --- a/docs/objects/publications.md +++ b/docs/objects/publications.md @@ -212,7 +212,7 @@ Servers **MUST** respect the visibility of the publication and **MUST NOT** show ## Types ```typescript -interface Publication { +interface Publication extends Entity { type: "Note" | "Patch"; author: string; content?: ContentFormat; @@ -223,6 +223,19 @@ interface Publication { subject?: string; is_sensitive?: boolean; visibility: Visibility; + extensions?: Entity["extensions"] & { + "org.lysand:reactions"?: { + reactions: string; + }; + "org.lysand:polls"?: { + poll: { + options: ContentFormat[]; + votes: number[]; + multiple_choice?: boolean; + expires_at: string; + }; + }; + }; } ``` diff --git a/docs/objects/server-metadata.md b/docs/objects/server-metadata.md index 235a3f1..376069d 100644 --- a/docs/objects/server-metadata.md +++ b/docs/objects/server-metadata.md @@ -151,4 +151,24 @@ Clients should display the most modern format that they support, such as WebP, A | :------------------- | :-------------- | :----------------------------- | | supported_extensions | Array of String | Yes, can be empty array (`[]`) | -List of extension names that the server supports, in namespaced format (`"org.lysand:reactions"`). \ No newline at end of file +List of extension names that the server supports, in namespaced format (`"org.lysand:reactions"`). + +## Types + +```typescript +interface ServerMetadata { + type: "ServerMetadata"; + name: string; + version: string; + description?: string; + website?: string; + moderators?: string[]; + admins?: string[]; + logo?: ContentFormat; + banner?: ContentFormat; + supported_extensions: string[]; + extensions?: { + [key: string]: object | undefined; + }; +} +``` \ No newline at end of file diff --git a/docs/objects/undo.md b/docs/objects/undo.md index 057ed17..0643fb0 100644 --- a/docs/objects/undo.md +++ b/docs/objects/undo.md @@ -40,4 +40,14 @@ URI of the [Actor](./actors) who initiated the action. | :----- | :----- | :------- | | object | String | Yes | -URI of the object being undone. The object **MUST** be an [Action](./actions) or a [Note](./note). To undo [Patch](./patch) objects, use a subsequent [Patch](./patch) or delete the original [Note](./note). \ No newline at end of file +URI of the object being undone. The object **MUST** be an [Action](./actions) or a [Note](./note). To undo [Patch](./patch) objects, use a subsequent [Patch](./patch) or delete the original [Note](./note). + +## Types + +```typescript +interface Undo extends Entity { + type: "Undo"; + author: string; + object: string; +} +``` \ No newline at end of file diff --git a/docs/objects/user.md b/docs/objects/user.md index 10707f9..a78bc3c 100644 --- a/docs/objects/user.md +++ b/docs/objects/user.md @@ -332,6 +332,9 @@ interface User extends Entity { dislikes: string; inbox: string; outbox: string; + extensions?: Entity["extensions"] & { + "org.lysand:vanity"?: VanityExtension; + }; } ``` diff --git a/docs/spec.md b/docs/spec.md index c09ae5e..17503cc 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -55,7 +55,7 @@ All JSON objects disseminated during federation **MUST** be handled as follows: ## Requests and Responses -All HTTP requests **MUST** be transmitted over HTTPS. Servers **MUST NOT** accept HTTP requests, unless for development purposes (e.g., if a server is operating on localhost or another local network). +All Hypertext Transfer Protocol requests MUST be transmitted using the Hypertext Transfer Protocol Secure Extension. Servers MUST NOT accept requests without TLS (HTTPS), except for development purposes (e.g., if a server is operating on localhost or another local network). Servers should support HTTP/2 and HTTP/3 for enhanced performance and security. Servers **MUST** support HTTP/1.1 at a minimum. @@ -76,4 +76,4 @@ All responses **MUST** include at least the following headers: - `Signature` if the response body is signed (which is typically the case) - `Cache-Control: no-store` on entities that can be edited directly without using a [Patch](objects/patch), such as [Actors](objects/actors) - A cache header with a `max-age` of at least 5 minutes for entities that are not expected to change frequently, such as [Notes](objects/publications) -- A cache header with a large `max-age` for media files when served by a CDN or other caching service under the server's control \ No newline at end of file +- A cache header with a large `max-age` for media files when served by a CDN or other caching service under the server's control diff --git a/package.json b/package.json index 299f651..1e5ec50 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,6 @@ "docs:preview": "vitepress preview" }, "devDependencies": { - "vitepress": "1.0.0-rc.45" + "vitepress": "^1.1.0" } } \ No newline at end of file