From e49bb441bbe6a700fc4f6a25b82c49d21f4dafc7 Mon Sep 17 00:00:00 2001 From: afterlifepro Date: Sat, 20 Dec 2025 19:43:14 +0000 Subject: [PATCH] completely overhaul landing + add knot landing if this breaks im blowing myself up lol --- caddy/Caddyfile | 15 +- compose.yaml | 11 ++ landing/Dockerfile | 4 +- landing/deno.json | 3 + landing/src/index.ts | 36 ++++ landing/src/knot/knot-heading.txt | 10 ++ landing/src/knot/knot.ts | 162 ++++++++++++++++++ .../{heading.txt => src/pds/pds-heading.txt} | 0 landing/{index.ts => src/pds/pds.ts} | 68 ++------ landing/{ => src}/schemas.ts | 18 ++ landing/{ => src}/styles.css | 0 landing/src/utils.ts | 16 ++ 12 files changed, 290 insertions(+), 53 deletions(-) create mode 100644 landing/src/index.ts create mode 100644 landing/src/knot/knot-heading.txt create mode 100644 landing/src/knot/knot.ts rename landing/{heading.txt => src/pds/pds-heading.txt} (100%) rename landing/{index.ts => src/pds/pds.ts} (69%) rename landing/{ => src}/schemas.ts (72%) rename landing/{ => src}/styles.css (100%) create mode 100644 landing/src/utils.ts diff --git a/caddy/Caddyfile b/caddy/Caddyfile index 148c454..05e578a 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -123,8 +123,17 @@ pds.{$HOST:localhost} { format console } - @landing path / /css + rewrite / /pds + @landing path /pds /styles.css reverse_proxy @landing landing:8000 + + # disable age assurance + handle /xrpc/app.bsky.ageassurance.getState { + header content-type "application/json" + header access-control-allow-headers "authorization,dpop,atproto-accept-labelers,atproto-proxy" + header access-control-allow-origin "*" + respond `{"state":{"lastInitiatedAt":"2025-07-14T14:22:43.912Z","status":"assured","access":"full"},"metadata":{"accountCreatedAt":"2022-11-17T00:35:16.391Z"}}` 200 + } reverse_proxy {$PI_ADDRESS:pi}:8000 { transport http { @@ -160,6 +169,10 @@ knot.{$HOST:localhost} { format console } + rewrite / /knot + @landing path /knot /styles.css + reverse_proxy @landing landing:8000 + reverse_proxy {$PI_ADDRESS:pi}:5555 } diff --git a/compose.yaml b/compose.yaml index 9d6fb4b..4a65c28 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,6 +12,11 @@ services: build: ./landing environment: - PORT=8000 + - PDS=http://100.84.64.24:8000 + - KNOT_HOST=http://100.84.64.24:5555 + # tangled uses a plain domain name for knots in lexicons. imo this is bad but it is what it is rn + # allows me to have a diff HOST to the public one which may improve speeds ig? + - KNOT_NAME=knot.vielle.dev restart: unless-stopped caddy: @@ -29,6 +34,12 @@ services: - caddy_config:/config environment: HOST: vielle.dev + DONG_HOST: dongs.zip + PDS_ADMIN_EMAIL: admin@vielle.dev + PI_ADDRESS: "100.84.64.24" + PI_PORT_PDS: 8000 + PI_PORT_KNOT: 5555 + PI_PORT_PIPER: 8010 depends_on: - prs - landing diff --git a/landing/Dockerfile b/landing/Dockerfile index b2e12e3..8475c98 100644 --- a/landing/Dockerfile +++ b/landing/Dockerfile @@ -3,6 +3,6 @@ WORKDIR /app COPY ./ /app RUN deno install -RUN deno bundle --unstable-raw-imports --output bundle.js /app/index.ts +RUN deno bundle --unstable-raw-imports --output bundle.js /app/src/index.ts -CMD deno --allow-net --unstable-temporal bundle.js \ No newline at end of file +CMD deno --allow-net --unstable-temporal --allow-env bundle.js \ No newline at end of file diff --git a/landing/deno.json b/landing/deno.json index ac58819..e735ef6 100644 --- a/landing/deno.json +++ b/landing/deno.json @@ -1,4 +1,7 @@ { + "tasks": { + "dev": "deno run --allow-net --unstable-temporal --unstable-raw-imports --allow-env --watch ./src/index.ts" + }, "imports": { "zod": "npm:zod@^4.1.13" } diff --git a/landing/src/index.ts b/landing/src/index.ts new file mode 100644 index 0000000..cbea29f --- /dev/null +++ b/landing/src/index.ts @@ -0,0 +1,36 @@ +import css from "./styles.css" with { type: "text" }; +import pds from "./pds/pds.ts"; +import knot from "./knot/knot.ts"; + +const PORT = Number(Deno.env.get("PORT")); + +Deno.serve( + { port: !Number.isNaN(PORT) && PORT <= 65535 && PORT > 0 ? PORT : 8000 }, + (req) => { + const path = new URL(req.url).pathname; + console.log("PATH", path); + switch (path) { + case "/styles.css": + return new Response(css, { + headers: { + "Content-Type": "text/css; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }, + }); + + case "/pds": + case "/pds/": + return pds(); + + case "/knot": + case "/knot/": + return knot(); + } + + console.warn(`Request for ${path} returned 404`); + return new Response("404 Not Found", { + status: 404, + statusText: "Not Found", + }); + } +); diff --git a/landing/src/knot/knot-heading.txt b/landing/src/knot/knot-heading.txt new file mode 100644 index 0000000..1c5b7e4 --- /dev/null +++ b/landing/src/knot/knot-heading.txt @@ -0,0 +1,10 @@ + __ ___ _ _ ___ __ + | \ / \| \| |/ __|/__| + _ | - || - || || | |\__\ +|_||__/ \___/|_|\_|\___||__/ + + +Knot Source: https://tangled.org/tangled.org/core/tree/master/knotserver + Docs: https://tangled.org/tangled.org/core/tree/master/docs + .dong file: https://dongs.zip/ + health: \ No newline at end of file diff --git a/landing/src/knot/knot.ts b/landing/src/knot/knot.ts new file mode 100644 index 0000000..bb1fbfc --- /dev/null +++ b/landing/src/knot/knot.ts @@ -0,0 +1,162 @@ +import z from "zod"; +import heading from "./knot-heading.txt" with { type: "text" }; +import { + listRecordsSchema, + slingshotMiniDocSchema, + tangledKnotOwnerSchema, + tangledRepoSchema, +} from "../schemas.ts"; +import { stagger } from "../utils.ts"; + +const KNOT_HOST = Deno.env.get("KNOT_HOST") ?? "http://pi:5555"; +const KNOT_NAME = Deno.env.get("KNOT_NAME") ?? "knot.vielle.dev"; +const SLINGSHOT_INSTANCE = + Deno.env.get("SLINGSHOT_INSTANCE") ?? "https://slingshot.microcosm.blue"; + +function getAllRecords( + owner: string, + pds: string, + collection: string, + validator: z.ZodType, + cursor?: string +): Promise<{ uri: string; cid: string; value: T }[]> { + return fetch( + `${pds}/xrpc/com.atproto.repo.listRecords?repo=${owner}&collection=${collection}&cursor=${cursor}` + ) + .then((res) => res.json()) + + .then( + (res) => + // check that it matches the schema and assume that the schema matches T + listRecordsSchema(validator).safeParse(res).data as { + records: { + uri: string; + cid: string; + value: T; + }[]; + cursor?: string; + } + ) + .then(async (res) => [ + ...res.records, + ...(res.cursor + ? await getAllRecords(owner, pds, collection, validator, res.cursor) + : []), + ]); +} + +export default function (): Response { + // get upstream knot owner/status + const owner = fetch(`${KNOT_HOST}/xrpc/sh.tangled.owner`); + const status = owner.then( + async (res) => `${res.status} ${res.statusText} ${await res.clone().text()}` + ); + + const ownerDid: Promise = owner + .then((res) => res.json()) + .then((x) => tangledKnotOwnerSchema.safeParse(x).data?.owner) + .catch(() => undefined); + + const ownerPds = ownerDid + .then((owner) => + fetch( + `${SLINGSHOT_INSTANCE}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${owner}` + ) + ) + .then((res) => res.json()) + .then((x) => slingshotMiniDocSchema.safeParse(x).data?.pds); + + const members = ownerPds + .then((pds) => + ownerDid.then(async (owner) => [ + owner, + ...(await getAllRecords<{ subject: string; domain: string }>( + owner, + pds, + "sh.tangled.knot.member", + z.object({ + subject: z.string(), + domain: z.string(), + }) + ).then((x) => + x + .filter((x) => x.value.domain === KNOT_NAME) + .map((x) => x.value.subject) + )), + ]) + ) + .then((dids) => + Promise.all( + dids.map((did) => + fetch( + `${SLINGSHOT_INSTANCE}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${did}` + ) + .then((res) => res.json()) + .then((res) => slingshotMiniDocSchema.safeParse(res).data) + ) + ) + ); + + const membersRepr = members.then( + (x) => + " - " + + x + .map((x) => (x.handle !== "handle.invalid" ? x.handle : x.did)) + .join("\n - ") + ); + + const repos = members.then((members) => + Promise.all( + members.map((member) => + getAllRecords>( + member.did, + member.pds, + "sh.tangled.repo", + tangledRepoSchema + ).then((x) => + x.map((x) => ({ + ...x.value, + user: + member.handle !== "handle.invalid" ? member.handle : member.did, + })) + ) + ) + ).then((x) => x.flat().filter((x) => x.knot === KNOT_NAME)) + ); + + const reposRepr = repos.then( + (x) => + " - " + + x + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .map( + (x) => + `${x.user}/${x.name}${x.description ? `\n ${x.description}` : ""}${x.website ? `\n ${x.website}` : ""}` + ) + .join("\n - ") + + "\n" + ); + + const body = new ReadableStream({ + async start(controller) { + controller.enqueue(heading); + controller.enqueue(await status); + controller.enqueue("\n\nMembers:\n"); + controller.enqueue(await membersRepr); + controller.enqueue("\n\nRepos:\n"); + controller.enqueue(await reposRepr); + controller.close(); + }, + }); + + return new Response( + body.pipeThrough(stagger()).pipeThrough(new TextEncoderStream()), + { + headers: { + "Content-Type": "text/text; charset=utf-8", + "Access-Control-Allow-Origin": "*", + Link: "; rel=stylesheet", + }, + } + ); +} diff --git a/landing/heading.txt b/landing/src/pds/pds-heading.txt similarity index 100% rename from landing/heading.txt rename to landing/src/pds/pds-heading.txt diff --git a/landing/index.ts b/landing/src/pds/pds.ts similarity index 69% rename from landing/index.ts rename to landing/src/pds/pds.ts index 841dd1b..711b002 100644 --- a/landing/index.ts +++ b/landing/src/pds/pds.ts @@ -1,5 +1,4 @@ -import css from "./styles.css" with { type: "text" }; -import heading from "./heading.txt" with { type: "text" }; +import heading from "./pds-heading.txt" with { type: "text" }; import { didDocSchema, getRecordSchema, @@ -8,15 +7,16 @@ import { profileSelfSchema, statusphereSchema, tealFmStatusSchema, -} from "./schemas.ts"; +} from "../schemas.ts"; +import { stagger } from "../utils.ts"; -const PORT = Number(Deno.env.get("PORT")); const PDS = Deno.env.get("PDS") ?? "http://pi:8000"; +const PLC_DIRECTORY = Deno.env.get("PLC_DIRECTORY") ?? "https://plc.directory"; async function renderUser(did: string): Promise { const handle = fetch( did.startsWith("did:plc") - ? "https://plc.directory/" + did + ? `${PLC_DIRECTORY}/${did}` : `https://${did.replace("did:web:", "")}/.well-known/did.json` ) .then((res) => res.json()) @@ -25,24 +25,27 @@ async function renderUser(did: string): Promise { (doc) => doc.alsoKnownAs .filter((x) => x.startsWith("at://"))[0] - ?.replace("at://", "") ?? "invalid.handle" - ); + ?.replace("at://", "") ?? "handle.invalid" + ) + .catch(() => "handle.invalid"); - const displayName = fetch( + const displayName: Promise = fetch( `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self` ) .then((res) => res.json()) .then((data) => getRecordSchema(profileSelfSchema).safeParse(data).data) - .then((data) => (data ? data.value.displayName : undefined)); + .then((data) => (data ? data.value.displayName : undefined)) + .catch(() => undefined); - const statusphere = fetch( + const statusphere: Promise = fetch( `${PDS}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=xyz.statusphere.status` ) .then((res) => res.json()) .then((data) => listRecordsSchema(statusphereSchema).safeParse(data).data) .then((data) => data && data.records.length > 0 ? data.records[0].value.status : undefined - ); + ) + .catch(() => undefined); const nowPlaying = fetch( `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=fm.teal.alpha.actor.status&rkey=self` @@ -59,7 +62,8 @@ async function renderUser(did: string): Promise { ? `${data.value.item.artists.length - 1} more artist${data.value.item.artists.length > 1 ? "s" : ""}` : "")) : undefined - ); + ) + .catch(() => undefined); return ( "- " + @@ -77,7 +81,7 @@ async function renderUser(did: string): Promise { ); } -function landingPage(): Response { +export default function (): Response { // get upstream pds status const status = fetch(`${PDS}/xrpc/_health`).then( async (res) => `${res.status} ${res.statusText} ${await res.text()}` @@ -107,21 +111,8 @@ function landingPage(): Response { }, }); - // buffer the output just to be easier to handle - const stagger = new TransformStream({ - async transform( - chunk: string, - controller: TransformStreamDefaultController - ) { - for (const part of chunk) { - controller.enqueue(part); - await new Promise((res) => setTimeout(res, 10)); - } - }, - }); - return new Response( - body.pipeThrough(stagger).pipeThrough(new TextEncoderStream()), + body.pipeThrough(stagger()).pipeThrough(new TextEncoderStream()), { headers: { "Content-Type": "text/text; charset=utf-8", @@ -131,26 +122,3 @@ function landingPage(): Response { } ); } - -Deno.serve( - { port: !Number.isNaN(PORT) && PORT <= 65535 && PORT > 0 ? PORT : 8000 }, - (req) => { - const path = new URL(req.url).pathname; - switch (path) { - case "/": - return landingPage(); - case "/styles.css": - return new Response(css, { - headers: { - "Content-Type": "text/css; charset=utf-8", - "Access-Control-Allow-Origin": "*", - }, - }); - default: - return new Response(`404 Not Found: ${path} does not exist`, { - status: 400, - statusText: "Not Found", - }); - } - } -); diff --git a/landing/schemas.ts b/landing/src/schemas.ts similarity index 72% rename from landing/schemas.ts rename to landing/src/schemas.ts index a7158dd..218ab8c 100644 --- a/landing/schemas.ts +++ b/landing/src/schemas.ts @@ -30,6 +30,23 @@ export const tealFmStatusSchema = z.object({ }), }); +export const tangledKnotOwnerSchema = z.object({ + owner: z.string(), +}); + +export const tangledRepoSchema = z.object({ + knot: z.string(), + name: z.string(), + description: z.string().optional(), + website: z.url().optional(), +}); + +export const slingshotMiniDocSchema = z.object({ + pds: z.string(), + handle: z.string(), + did: z.string(), +}); + export function getRecordSchema(record: T) { return z.object({ value: record, @@ -45,5 +62,6 @@ export function listRecordsSchema(record: T) { value: record, }) ), + cursor: z.string().optional(), }); } diff --git a/landing/styles.css b/landing/src/styles.css similarity index 100% rename from landing/styles.css rename to landing/src/styles.css diff --git a/landing/src/utils.ts b/landing/src/utils.ts new file mode 100644 index 0000000..d0a5c10 --- /dev/null +++ b/landing/src/utils.ts @@ -0,0 +1,16 @@ +/** + * Delays the output of each character in the input stream by 10ms + * @returns TransformStream + */ +export const stagger = () => + new TransformStream({ + async transform( + chunk: string, + controller: TransformStreamDefaultController + ) { + for (const part of chunk) { + controller.enqueue(part); + await new Promise((res) => setTimeout(res, 10)); + } + }, + }); -- 2.51.2