Merge branch 'main' into feat/lysand3

This commit is contained in:
Jesse Wierzbinski 2024-04-22 13:14:10 -10:00
commit 9e0969f332
No known key found for this signature in database
19 changed files with 190 additions and 96 deletions

View file

@ -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 COPY . /app
RUN cd /app && bun install
RUN cd /app && bun docs:build
RUN cd ./app && bun install FROM base AS final
RUN cd ./app && bun docs:build
FROM oven/bun:alpine
COPY --from=builder /app/.vitepress/dist/ /app COPY --from=builder /app/.vitepress/dist/ /app
@ -15,3 +28,6 @@ LABEL org.opencontainers.image.vendor "Lysand.org"
LABEL org.opencontainers.image.licenses "MIT" LABEL org.opencontainers.image.licenses "MIT"
LABEL org.opencontainers.image.title "Lysand Docs" LABEL org.opencontainers.image.title "Lysand Docs"
LABEL org.opencontainers.image.description "Documentation for Lysand" LABEL org.opencontainers.image.description "Documentation for Lysand"
WORKDIR /app
CMD ["bun", "docs:serve"]

BIN
bun.lockb

Binary file not shown.

View file

@ -28,7 +28,7 @@ The signature is calculated as follows:
``` ```
(request-target): post /users/uuid/inbox (request-target): post /users/uuid/inbox
host: example.com host: example.com
date: Fri, 01 Jan 2021 00:00:00 GMT date: 2024-04-10T01:27:24.880Z
digest: SHA-256=base64_digest digest: SHA-256=base64_digest
``` ```
@ -40,48 +40,41 @@ 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): 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: The `request-target` field **MUST** be the request target of the request, formatted as follows:
``` ```
post /users/uuid/inbox 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 ```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( const privateKey = await crypto.subtle.importKey(
"pkcs8", "pkcs8",
str2ab(atob("base64_private_key")), Uint8Array.from(atob(status_author_private_key), (c) =>
c.charCodeAt(0),
),
"Ed25519", "Ed25519",
false, false,
["sign"] ["sign"],
); );
const digest = await crypto.subtle.digest( const digest = await crypto.subtle.digest(
"SHA-256", "SHA-256",
new TextEncoder().encode("request_body") 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 date = new Date();
@ -91,15 +84,15 @@ const signature = await crypto.subtle.sign(
new TextEncoder().encode( new TextEncoder().encode(
`(request-target): post ${userInbox.pathname}\n` + `(request-target): post ${userInbox.pathname}\n` +
`host: ${userInbox.host}\n` + `host: ${userInbox.host}\n` +
`date: ${date.toUTCString()}\n` + `date: ${date.toISOString()}\n` +
`digest: SHA-256=${btoa( `digest: SHA-256=${btoa(
String.fromCharCode(...new Uint8Array(digest)) String.fromCharCode(...new Uint8Array(digest)),
)}\n` )}\n`,
) ),
); );
const signatureBase64 = btoa( 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: The request can then be sent with the `Signature`, `Origin` and `Date` headers as follows:
```ts ```ts
await fetch("https://example.com/users/uuid/inbox", { await fetch("https://receiver.com/users/22a56612-9909-48ca-84af-548b28db6fd5/inbox", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Date: date.toUTCString(), Date: date.toISOString(),
Origin: "https://example.com", Origin: "example.com",
Signature: `keyId="${...}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, 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: Example of validation on the server side:
```typescript ```typescript
// request is a Request object containing the previous request // req is a Request object
// public_key is the user's public key in raw base64 format 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( const digest = await crypto.subtle.digest(
"SHA-256", "SHA-256",
new TextEncoder().encode(await request.text()) new TextEncoder().encode(JSON.stringify(body)),
); );
const expectedSignedString = `(request-target): ${request.method.toLowerCase()} ${request.url}\n` + const keyId = signatureHeader
`host: ${request.url}\n` + .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` + `date: ${date}\n` +
`digest: SHA-256=${btoa(digest)}`; `digest: SHA-256=${btoa(
String.fromCharCode(...new Uint8Array(digest)),
)}\n`;
// Check if signed string is valid // Check if signed string is valid
const isValid = await crypto.subtle.verify( const isValid = await crypto.subtle.verify(
"Ed25519", "Ed25519",
publicKey, public_key,
new TextEncoder().encode(signature), Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)),
new TextEncoder().encode(expectedSignedString) new TextEncoder().encode(expectedSignedString),
); );
if (!isValid) { if (!isValid) {

View file

@ -48,7 +48,7 @@ curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -
}' https://example.com/users/uuid/inbox }' 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 ## User Outbox

View file

@ -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 `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: Breaking down this URI, we get the following:
- `acct`: The protocol of the URI. This is always `acct` for Lysand. - `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`. - `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: 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 ```json5
{ {
"subject": "acct:uuid@example.com", "subject": "acct:identifier@example.com",
"links": [ "links": [
{ {
"rel": "self", "rel": "self",

View file

@ -55,7 +55,13 @@ interface Entity {
created_at: string; created_at: string;
uri: string; uri: string;
type: 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. The `Entity` type is the base type for all entities in the Lysand protocol. It includes the `id`, `created_at`, `uri`, and `type` attributes.

View file

@ -41,3 +41,12 @@ This approach helps prevent potential misuse of the protocol to determine if a u
| author | String | Yes | | author | String | Yes |
URI of the [Actor](./actors) who initiated the action. 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
}
```

View file

@ -35,9 +35,8 @@ URI of the object being disliked. Must be of type [Note](./note)
## Types ## Types
```typescript ```typescript
interface Dislike extends Entity { interface Dislike extends Action {
type: "Dislike"; type: "Dislike";
author: string;
object: string; object: string;
} }
``` ```

View file

@ -35,9 +35,8 @@ URI of the [User](./user) who tried to follow the author
## Types ## Types
```typescript ```typescript
interface FollowAccept extends Entity { interface FollowAccept extends Action {
type: "FollowAccept"; type: "FollowAccept";
author: string;
follower: string; follower: string;
} }
``` ```

View file

@ -35,9 +35,8 @@ URI of the [User](./user) who tried to follow the author.
## Types ## Types
```typescript ```typescript
interface FollowReject extends Entity { interface FollowReject extends Action {
type: "FollowReject"; type: "FollowReject";
author: string;
follower: string; follower: string;
} }
``` ```

View file

@ -35,9 +35,8 @@ URI of the [User](./user) who is being follow requested.
## Types ## Types
```typescript ```typescript
interface Follow extends Entity { interface Follow extends Action {
type: "Follow"; type: "Follow";
author: string;
followee: string; followee: string;
} }
``` ```

View file

@ -35,9 +35,8 @@ URI of the object being liked. Must be of type [Note](./note)
## Types ## Types
```typescript ```typescript
interface Like extends Entity { interface Like extends Action {
type: "Like"; type: "Like";
author: string;
object: string; object: string;
} }
``` ```

View file

@ -212,7 +212,7 @@ Servers **MUST** respect the visibility of the publication and **MUST NOT** show
## Types ## Types
```typescript ```typescript
interface Publication { interface Publication extends Entity {
type: "Note" | "Patch"; type: "Note" | "Patch";
author: string; author: string;
content?: ContentFormat; content?: ContentFormat;
@ -223,6 +223,19 @@ interface Publication {
subject?: string; subject?: string;
is_sensitive?: boolean; is_sensitive?: boolean;
visibility: Visibility; visibility: Visibility;
extensions?: Entity["extensions"] & {
"org.lysand:reactions"?: {
reactions: string;
};
"org.lysand:polls"?: {
poll: {
options: ContentFormat[];
votes: number[];
multiple_choice?: boolean;
expires_at: string;
};
};
};
} }
``` ```

View file

@ -152,3 +152,23 @@ Clients should display the most modern format that they support, such as WebP, A
| supported_extensions | Array of String | Yes, can be empty array (`[]`) | | supported_extensions | Array of String | Yes, can be empty array (`[]`) |
List of extension names that the server supports, in namespaced format (`"org.lysand:reactions"`). 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;
};
}
```

View file

@ -41,3 +41,13 @@ URI of the [Actor](./actors) who initiated the action.
| object | String | Yes | | 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). 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;
}
```

View file

@ -332,6 +332,9 @@ interface User extends Entity {
dislikes: string; dislikes: string;
inbox: string; inbox: string;
outbox: string; outbox: string;
extensions?: Entity["extensions"] & {
"org.lysand:vanity"?: VanityExtension;
};
} }
``` ```

View file

@ -55,7 +55,7 @@ All JSON objects disseminated during federation **MUST** be handled as follows:
## Requests and Responses ## 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. Servers should support HTTP/2 and HTTP/3 for enhanced performance and security. Servers **MUST** support HTTP/1.1 at a minimum.

View file

@ -5,6 +5,6 @@
"docs:preview": "vitepress preview" "docs:preview": "vitepress preview"
}, },
"devDependencies": { "devDependencies": {
"vitepress": "1.0.0-rc.45" "vitepress": "^1.1.0"
} }
} }