diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -687,9 +687,9 @@ "clap", "confique", "futures", - "rustls", "jacquard-common", "jacquard-identity", + "rustls", "serde", "socket2", "thiserror 2.0.18", diff --git a/web/src/app.d.ts b/web/src/app.d.ts --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -5,6 +5,7 @@ bobbinUrl: string; knotMirrorUrl: string; apiUrl: string; + sitesDomain: string; camoEnabled: boolean; }; } diff --git a/web/static/oauth-client-metadata.json b/web/static/oauth-client-metadata.json --- a/web/static/oauth-client-metadata.json +++ b/web/static/oauth-client-metadata.json @@ -3,7 +3,7 @@ "client_name": "Tangled", "client_uri": "https://tangled.org", "redirect_uris": ["https://tangled.org/oauth/callback"], - "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.feed.comment repo:sh.tangled.feed.reaction repo:sh.tangled.feed.star repo:sh.tangled.graph.follow repo:sh.tangled.graph.vouch repo:sh.tangled.knot repo:sh.tangled.knot.member repo:sh.tangled.label.definition repo:sh.tangled.label.op repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.artifact repo:sh.tangled.repo.collaborator repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.spindle repo:sh.tangled.spindle.member repo:sh.tangled.string blob:*/* rpc:sh.tangled.knot.addMember?aud=* rpc:sh.tangled.knot.removeMember?aud=* rpc:sh.tangled.ci.triggerPipeline?aud=* rpc:sh.tangled.ci.cancelPipeline?aud=* rpc:sh.tangled.repo.addCollaborator?aud=* rpc:sh.tangled.repo.addSecret?aud=* rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.deleteBranch?aud=* rpc:sh.tangled.repo.forkStatus?aud=* rpc:sh.tangled.repo.forkSync?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.listSecrets?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.mergeCheck?aud=* rpc:sh.tangled.repo.removeCollaborator?aud=* rpc:sh.tangled.repo.removeSecret?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=*", + "scope": "atproto repo:sh.tangled.actor.profile repo:sh.tangled.feed.comment repo:sh.tangled.feed.reaction repo:sh.tangled.feed.star repo:sh.tangled.graph.follow repo:sh.tangled.graph.vouch repo:sh.tangled.knot repo:sh.tangled.knot.member repo:sh.tangled.label.definition repo:sh.tangled.label.op repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.artifact repo:sh.tangled.repo.collaborator repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.spindle repo:sh.tangled.spindle.member repo:sh.tangled.string blob:*/* rpc:sh.tangled.knot.addMember?aud=* rpc:sh.tangled.knot.removeMember?aud=* rpc:sh.tangled.ci.triggerPipeline?aud=* rpc:sh.tangled.ci.cancelPipeline?aud=* rpc:sh.tangled.repo.addCollaborator?aud=* rpc:sh.tangled.repo.addSecret?aud=* rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.deleteBranch?aud=* rpc:sh.tangled.repo.forkStatus?aud=* rpc:sh.tangled.repo.forkSync?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.listSecrets?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.mergeCheck?aud=* rpc:sh.tangled.repo.removeCollaborator?aud=* rpc:sh.tangled.repo.removeSecret?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=* rpc:org.tangled.temp.notification.getPreferences?aud=* rpc:org.tangled.temp.notification.updatePreferences?aud=* rpc:org.tangled.temp.site.getDomainClaim?aud=* rpc:org.tangled.temp.site.claimDomain?aud=* rpc:org.tangled.temp.site.releaseDomain?aud=*", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", diff --git a/web/src/lib/api/appview.ts b/web/src/lib/api/appview.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/api/appview.ts @@ -0,0 +1,80 @@ +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { mintServiceAuth, serviceDidForHost } from "$lib/auth/agent"; +import { buildUrl, toResponseError } from "./_request"; +import type { QueryValue, XrpcRequestInit } from "./client"; + +// Authenticated client for the go appview's org.tangled.temp.* xrpc methods. +// Unlike bobbin (public reads) and the pds (record writes), these are authed +// with an atproto service-auth jwt: the browser mints a per-call token via the +// user's pds (getServiceAuth), scoped to the method (lxm) and the appview's did +// (aud). The appview verifies the issuer's signature and the aud/lxm claims. +export interface AppviewContext { + readonly serviceUrl: string; + readonly aud: string; + readonly agent: OAuthUserAgent; + readonly fetch: typeof globalThis.fetch; +} + +export interface CreateAppviewOptions { + apiUrl: string; + agent: OAuthUserAgent; + fetch?: typeof globalThis.fetch; +} + +export const createAppviewClient = ({ + apiUrl, + agent, + fetch +}: CreateAppviewOptions): AppviewContext => { + const serviceUrl = apiUrl.replace(/\/+$/, ""); + // aud host must match the appview's TANGLED_APPVIEW_HOST, which is the host + // it's reached at; derive both from the same url so they can't drift. + const aud = serviceDidForHost(new URL(serviceUrl).host); + return { serviceUrl, aud, agent, fetch: fetch ?? globalThis.fetch }; +}; + +const authHeader = async (ctx: AppviewContext, nsid: string): Promise => { + const token = await mintServiceAuth(ctx.agent, { aud: ctx.aud, lxm: nsid }); + return `Bearer ${token}`; +}; + +export const authedGet = async ( + ctx: AppviewContext, + nsid: string, + params?: Record, + init?: XrpcRequestInit +): Promise => { + const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid, params), { + headers: { + accept: "application/json", + authorization: await authHeader(ctx, nsid), + ...init?.headers + }, + signal: init?.signal + }); + if (!response.ok) throw await toResponseError(response); + return (await response.json()) as T; +}; + +export const authedPost = async ( + ctx: AppviewContext, + nsid: string, + body: unknown, + init?: XrpcRequestInit +): Promise => { + const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid), { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + authorization: await authHeader(ctx, nsid), + ...init?.headers + }, + body: JSON.stringify(body ?? {}), + signal: init?.signal + }); + if (!response.ok) throw await toResponseError(response); + // most temp procedures return no body (200 with empty payload) + const text = await response.text(); + return text ? (JSON.parse(text) as T) : null; +}; diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/api/notifications.ts @@ -0,0 +1,32 @@ +import { authedGet, authedPost, type AppviewContext } from "./appview"; +import type { XrpcRequestInit } from "./client"; + +// mirrors org.tangled.temp.notification.getPreferences#preferences +export interface NotificationPreferences { + repoStarred: boolean; + issueCreated: boolean; + issueCommented: boolean; + issueClosed: boolean; + pullCreated: boolean; + pullCommented: boolean; + pullMerged: boolean; + followed: boolean; + userMentioned: boolean; + emailNotifications: boolean; +} + +const GET_PREFERENCES = "org.tangled.temp.notification.getPreferences"; +const UPDATE_PREFERENCES = "org.tangled.temp.notification.updatePreferences"; + +export const getNotificationPreferences = ( + ctx: AppviewContext, + init?: XrpcRequestInit +): Promise => + authedGet(ctx, GET_PREFERENCES, undefined, init); + +// only the provided fields are updated server-side. +export const updateNotificationPreferences = ( + ctx: AppviewContext, + patch: Partial, + init?: XrpcRequestInit +): Promise => authedPost(ctx, UPDATE_PREFERENCES, patch, init).then(() => undefined); diff --git a/web/src/lib/api/sites.ts b/web/src/lib/api/sites.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/api/sites.ts @@ -0,0 +1,33 @@ +import { authedGet, authedPost, type AppviewContext } from "./appview"; +import type { XrpcRequestInit } from "./client"; + +// org.tangled.temp.site.getDomainClaim — domain is absent when unclaimed. +interface DomainClaimResponse { + domain?: string; +} + +const GET_DOMAIN_CLAIM = "org.tangled.temp.site.getDomainClaim"; +const CLAIM_DOMAIN = "org.tangled.temp.site.claimDomain"; +const RELEASE_DOMAIN = "org.tangled.temp.site.releaseDomain"; + +// returns the user's active sites domain, or null when none is claimed. +export const getDomainClaim = async ( + ctx: AppviewContext, + init?: XrpcRequestInit +): Promise => { + const res = await authedGet(ctx, GET_DOMAIN_CLAIM, undefined, init); + return res.domain ?? null; +}; + +// claims .; the server appends the configured suffix. +export const claimDomain = ( + ctx: AppviewContext, + subdomain: string, + init?: XrpcRequestInit +): Promise => authedPost(ctx, CLAIM_DOMAIN, { subdomain }, init).then(() => undefined); + +export const releaseDomain = ( + ctx: AppviewContext, + domain: string, + init?: XrpcRequestInit +): Promise => authedPost(ctx, RELEASE_DOMAIN, { domain }, init).then(() => undefined); diff --git a/web/src/lib/server/config.ts b/web/src/lib/server/config.ts --- a/web/src/lib/server/config.ts +++ b/web/src/lib/server/config.ts @@ -9,6 +9,8 @@ bobbinUrl: string; knotMirrorUrl: string; apiUrl: string; + /** the domain user sites are served under, e.g. "tngl.io" */ + sitesDomain: string; camoUrl: string; avatarUrl: string; /** the secrets camo and avatar sign with, so neither leaves the server */ @@ -18,7 +20,7 @@ export type PublicWebConfig = Pick< WebConfig, - "bobbinUrl" | "knotMirrorUrl" | "apiUrl" + "bobbinUrl" | "knotMirrorUrl" | "apiUrl" | "sitesDomain" > & { /** camo has a secret, so markup can route images through it */ camoEnabled: boolean; @@ -30,6 +32,7 @@ TANGLED_API_URL?: string; API_URL?: string; KNOT_RESOLVER_URL?: string; + SITES_DOMAIN?: string; CAMO_URL?: string; CAMO_SHARED_SECRET?: string; AVATAR_URL?: string; @@ -40,6 +43,7 @@ bobbinUrl: cleanUrl(values.BOBBIN_URL, "http://127.0.0.1:8090"), knotMirrorUrl: cleanUrl(values.KNOTMIRROR_URL, ""), apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"), + sitesDomain: values.SITES_DOMAIN?.trim() || "tngl.io", camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh"), avatarUrl: cleanUrl(values.AVATAR_URL, "https://avatar.tangled.sh"), camoSecret: values.CAMO_SHARED_SECRET?.trim() ?? "", @@ -56,6 +60,7 @@ bobbinUrl: config.bobbinUrl, knotMirrorUrl: config.knotMirrorUrl, apiUrl: config.apiUrl, + sitesDomain: config.sitesDomain, camoEnabled: config.camoSecret !== "" }; }; diff --git a/web/src/lib/components/ui/Input.svelte b/web/src/lib/components/ui/Input.svelte --- a/web/src/lib/components/ui/Input.svelte +++ b/web/src/lib/components/ui/Input.svelte @@ -35,6 +35,8 @@ loading?: boolean; iconLeft?: Component; iconRight?: Component; + /** static, non-editable trailing text shown inside the field, e.g. a domain suffix */ + suffix?: string; class?: string; } @@ -45,6 +47,7 @@ loading = false, iconLeft, iconRight, + suffix, class: className, ...rest }: Props = $props(); @@ -65,6 +68,9 @@ class="flex-1 bg-transparent text-sm outline-none placeholder:text-foreground-placeholder disabled:cursor-not-allowed" {...rest} /> + {#if suffix} + {suffix} + {/if} {#if loading} {:else if iconRight} diff --git a/web/src/lib/components/settings/tabs/NotificationsTab.svelte b/web/src/lib/components/settings/tabs/NotificationsTab.svelte --- a/web/src/lib/components/settings/tabs/NotificationsTab.svelte +++ b/web/src/lib/components/settings/tabs/NotificationsTab.svelte @@ -1,8 +1,18 @@ {#snippet action()} - + {/snippet} - - {#each prefs as pref (pref.id)} - {@const Glyph = pref.icon} -
-
- - - {pref.description} +{#if loadError} + +{/if} +{#if saveError} + +{/if} + +{#if loading} +
+ Loading… +
+{:else} + + {#each prefs as pref (pref.key)} + {@const Glyph = pref.icon} +
+
+ + + {pref.description} +
+
- -
- {/each} - + {/each} + +{/if} diff --git a/web/src/lib/components/settings/tabs/SitesTab.svelte b/web/src/lib/components/settings/tabs/SitesTab.svelte --- a/web/src/lib/components/settings/tabs/SitesTab.svelte +++ b/web/src/lib/components/settings/tabs/SitesTab.svelte @@ -1,33 +1,102 @@
@@ -37,22 +106,53 @@ {/snippet} - {#if sites.length === 0} - - {:else} + {#if loadError} + + {/if} + {#if actionError} + + {/if} + + {#if loading} +
+ Loading… +
+ {:else if domain} - {#each sites as site (site.id)} - - {#snippet tags()} - {#if site.active} - Active - {/if} - {/snippet} - {#snippet actions()} - - {/snippet} - - {/each} + + {#snippet tags()} + Active + {/snippet} + {#snippet actions()} + + {/snippet} + + {:else} +
{ + e.preventDefault(); + void claim(); + }} + > + +
+ + +
+
+ + {/if}