diff --git a/web/src/lib/api/blame.ts b/web/src/lib/api/blame.ts index efc285c4c..9c07fb62e 100644 --- a/web/src/lib/api/blame.ts +++ b/web/src/lib/api/blame.ts @@ -1,5 +1,5 @@ import type { ResourceUri } from "@atcute/lexicons"; -import type { Did } from "@atcute/lexicons/syntax"; +import { isDid, type Did } from "@atcute/lexicons/syntax"; import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "$lib/api/client"; import { resolveEmailToDid } from "$lib/api/emailDid"; import { getBlame } from "$lib/api/gitmirror"; @@ -120,7 +120,7 @@ export const hydrateBlameAuthors = async (blame: Blame, ctx: BobbinContext): Pro ].slice(0, MAX_AUTHOR_HYDRATIONS); const emailDids = await resolveEmailToDid(ctx.fetch, emails); const didFor = (email: string): string | undefined => - email.startsWith("did:") ? email : emailDids.get(email); + isDid(email) ? email : emailDids.get(email); const dids = [ ...new Set( blame.flatMap(({ commit }) => { diff --git a/web/src/lib/api/identity.ts b/web/src/lib/api/identity.ts index ac076ef05..61a2f5ddf 100644 --- a/web/src/lib/api/identity.ts +++ b/web/src/lib/api/identity.ts @@ -1,4 +1,5 @@ import type * as BlueMicrocosmIdentityResolveMiniDoc from "@atcute/microcosm/types/blue/microcosm/identity/resolveMiniDoc"; +import { isDid } from "@atcute/lexicons/syntax"; import { cached } from "$lib/api/cache"; import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; import { jsonGet } from "$lib/api/_request"; @@ -40,7 +41,7 @@ export class IdentityCache { } resolve(identifier: string, init?: XrpcRequestInit): Promise { - const hit = identifier.startsWith("did:") + const hit = isDid(identifier) ? this.#byDid.get(identifier) : this.#byHandle.get(identifier); if (hit) return Promise.resolve(hit); diff --git a/web/src/lib/api/labels.ts b/web/src/lib/api/labels.ts index cdcd16241..42433c1b7 100644 --- a/web/src/lib/api/labels.ts +++ b/web/src/lib/api/labels.ts @@ -1,10 +1,11 @@ -import type { Nsid, ResourceUri } from "@atcute/lexicons/syntax"; +import type { Datetime, Nsid, ResourceUri } from "@atcute/lexicons/syntax"; import { now as tidNow } from "@atcute/tid"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; import type * as ShTangledLabelDefinition from "$lib/api/lexicons/types/sh/tangled/label/definition"; import { collect } from "$lib/api/pagination"; import { createRecord, deleteRecord, type WrittenRecord } from "$lib/api/write"; +import { rkeyFromUri } from "$lib/api/uri"; export type LabelDefinition = ShTangledLabelDefinition.Main; @@ -65,10 +66,10 @@ export const createLabelDefinition = ( valueType: input.keyValue ? { type: "string", format: "any" } : { type: "null", format: "any" }, - createdAt: new Date().toISOString() as never + createdAt: new Date().toISOString() as Datetime }, tidNow() ); export const deleteLabelDefinition = (agent: OAuthUserAgent, uri: string): Promise => - deleteRecord(agent, LABEL_DEFINITION_COLLECTION, uri.slice(uri.lastIndexOf("/") + 1)); + deleteRecord(agent, LABEL_DEFINITION_COLLECTION, rkeyFromUri(uri)); diff --git a/web/src/lib/api/pull.ts b/web/src/lib/api/pull.ts index d1104af51..986491ac7 100644 --- a/web/src/lib/api/pull.ts +++ b/web/src/lib/api/pull.ts @@ -1,10 +1,11 @@ import { ok } from "@atcute/client"; import { mainSchema as getRecordSchema } from "@atcute/atproto/types/repo/getRecord"; -import type { Nsid, RecordKey, ResourceUri } from "@atcute/lexicons/syntax"; +import type { Nsid, ResourceUri } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { now as tidNow } from "@atcute/tid"; import { createClient, mintServiceAuth, serviceDidForHost } from "$lib/auth/agent"; import { deleteRecord, putRecord } from "$lib/api/write"; +import { recordKeyOf } from "$lib/api/syntax"; import { buildUrl, toResponseError } from "$lib/api/_request"; import type { PullRecord, PullState, PullStatusRecord, RecordView } from "$lib/api/records"; @@ -32,7 +33,7 @@ const updatePull = async ( params: { repo: agent.sub, collection: PULL_COLLECTION, - rkey: rkey as RecordKey + rkey: recordKeyOf(rkey) } }) ); diff --git a/web/src/lib/api/repo.ts b/web/src/lib/api/repo.ts index bc9e2d1bf..6c06eb5d1 100644 --- a/web/src/lib/api/repo.ts +++ b/web/src/lib/api/repo.ts @@ -1,3 +1,4 @@ +import { isDid } from "@atcute/lexicons/syntax"; import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "$lib/api/client"; import type { NiceCommit } from "$lib/api/diff"; import { REPO_OWNER_DOCS } from "$lib/api/descriptors"; @@ -205,7 +206,7 @@ export interface CommitSummary { } export const didFromSignature = (email: string): string | undefined => - email.startsWith("did:") ? email : undefined; + isDid(email) ? email : undefined; export const splitMessage = (message: string): [string, string] => { const separator = message.indexOf("\n\n"); diff --git a/web/src/lib/api/repoCreate.ts b/web/src/lib/api/repoCreate.ts index 2d4d01e7d..4a14a6e8d 100644 --- a/web/src/lib/api/repoCreate.ts +++ b/web/src/lib/api/repoCreate.ts @@ -1,5 +1,6 @@ import { ok } from "@atcute/client"; -import type { Cid, Did, Nsid } from "@atcute/lexicons/syntax"; +import type { Cid, Datetime, Did, Nsid } from "@atcute/lexicons/syntax"; +import { recordKeyOf } from "$lib/api/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { awaitIndexedRecord } from "$lib/api/awaitRecord"; import { serviceUrlFor } from "$lib/api/_request"; @@ -96,7 +97,7 @@ const deleteKnotRepo = async ( repo: repoDid, did: input.ownerDid, name: rkey, - rkey: rkey as never + rkey: recordKeyOf(rkey) } }) ); @@ -126,7 +127,7 @@ const completeCreation = async ( const created = await ok( knot.xrpc.call(createRepoSchema, { input: { - rkey: rkey as never, + rkey: recordKeyOf(rkey), name: rkey, ...(input.defaultBranch ? { defaultBranch: input.defaultBranch } : {}), ...(input.source ? { source: input.source.cloneUrl } : {}) @@ -179,7 +180,7 @@ export const createRepo = async ( const record: RepoRecord = { $type: "sh.tangled.repo", knot: input.knot, - createdAt: new Date().toISOString() as never, + createdAt: new Date().toISOString() as Datetime, labels: DEFAULT_LABELS, ...(name !== rkey ? { name } : {}), ...(description ? { description } : {}), diff --git a/web/src/lib/api/repoCreationTargets.ts b/web/src/lib/api/repoCreationTargets.ts index f079158d2..0e5207d02 100644 --- a/web/src/lib/api/repoCreationTargets.ts +++ b/web/src/lib/api/repoCreationTargets.ts @@ -5,7 +5,8 @@ import { jsonGet } from "$lib/api/_request"; import { mainSchema as listKnotsSchema } from "$lib/api/lexicons/types/sh/tangled/knot/listKnots"; import { mainSchema as listSpindleMembersSchema } from "$lib/api/lexicons/types/sh/tangled/spindle/listMembers"; import { mainSchema as listSpindlesSchema } from "$lib/api/lexicons/types/sh/tangled/spindle/listSpindles"; -import { isDid, parseResourceUri } from "@atcute/lexicons/syntax"; +import { parseResourceUri } from "@atcute/lexicons/syntax"; +import { hostForServiceDid } from "$lib/auth/agent"; interface Page { items: T[]; @@ -51,20 +52,11 @@ const recordName = (uri: unknown): string | null => { } }; -const knotHostFromDid = (did: unknown): string | null => { - if (!isDid(did) || !did.startsWith("did:web:")) return null; - const host = did - .slice("did:web:".length) - .replace(/%3A/gi, ":") - .replace(/\.+$/, "") - .toLowerCase(); - return host || null; -}; - const knotMembershipHost = (item: unknown): string | null => { if (typeof item !== "object" || item === null) return null; const record = item as { uri?: unknown; value?: unknown }; - const host = knotHostFromDid(repoFromUri(record.uri)); + const repo = repoFromUri(record.uri); + const host = repo ? hostForServiceDid(repo) : null; if (host) return host; if (record.value !== undefined) { const domain = knotDomain(record.value); diff --git a/web/src/lib/api/repoSettings.ts b/web/src/lib/api/repoSettings.ts index 21985818b..85309dbf5 100644 --- a/web/src/lib/api/repoSettings.ts +++ b/web/src/lib/api/repoSettings.ts @@ -1,5 +1,5 @@ import { ok } from "@atcute/client"; -import type { Did, Nsid, ResourceUri } from "@atcute/lexicons/syntax"; +import type { Nsid, ResourceUri } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { awaitDeletedRecord, awaitIndexedRecord } from "$lib/api/awaitRecord"; import { serviceClient } from "$lib/api/_request"; @@ -11,6 +11,7 @@ import { mainSchema as setDefaultBranchSchema } from "$lib/api/lexicons/types/sh import type { RecordView, RepoRecord } from "$lib/api/records"; import { rkeyFromUri } from "$lib/api/uri"; import { deleteRecord, putRecord, type WrittenRecord } from "$lib/api/write"; +import { didOf, recordKeyOf } from "$lib/api/syntax"; const REPO_COLLECTION = "sh.tangled.repo" as Nsid; @@ -58,7 +59,7 @@ export const setDefaultBranch = async ( ): Promise => { await ok( serviceClient(agent, knot).xrpc.call(setDefaultBranchSchema, { - input: { repo: repoDid as Did, defaultBranch } + input: { repo: didOf(repoDid), defaultBranch } }) ); }; @@ -71,7 +72,7 @@ export const addCollaborator = async ( ): Promise => { await ok( serviceClient(agent, knot).xrpc.call(addCollaboratorSchema, { - input: { repo: repoDid as Did, subject: subject as Did } + input: { repo: didOf(repoDid), subject: didOf(subject) } }) ); }; @@ -84,7 +85,7 @@ export const removeCollaborator = async ( ): Promise => { await ok( serviceClient(agent, knot).xrpc.call(removeCollaboratorSchema, { - input: { repo: repoDid as Did, subject: subject as Did } + input: { repo: didOf(repoDid), subject: didOf(subject) } }) ); }; @@ -109,10 +110,10 @@ export const deleteRepo = async ( await ok( serviceClient(agent, repo.knot).xrpc.call(deleteRepoSchema, { input: { - repo: repo.repoDid as Did, - did: repo.ownerDid as Did, + repo: didOf(repo.repoDid), + did: didOf(repo.ownerDid), name: rkey, - rkey: rkey as never + rkey: recordKeyOf(rkey) } }) ); diff --git a/web/src/lib/api/syntax.ts b/web/src/lib/api/syntax.ts new file mode 100644 index 000000000..ab5c24853 --- /dev/null +++ b/web/src/lib/api/syntax.ts @@ -0,0 +1,14 @@ +import { isDid, isRecordKey, type Did, type RecordKey } from "@atcute/lexicons/syntax"; + +// asserting wrappers over atcute's syntax guards for xrpc input boundaries. +// untrusted strings (user input, route params, storage) get rejected here +// instead of traveling to the server and failing as a 400. +export const didOf = (value: string): Did => { + if (!isDid(value)) throw new Error(`invalid did: ${value}`); + return value; +}; + +export const recordKeyOf = (value: string): RecordKey => { + if (!isRecordKey(value)) throw new Error(`invalid rkey: ${value}`); + return value; +}; diff --git a/web/src/lib/api/uri.ts b/web/src/lib/api/uri.ts index bbd097872..706e3ca1a 100644 --- a/web/src/lib/api/uri.ts +++ b/web/src/lib/api/uri.ts @@ -1,9 +1,25 @@ // at-uri helpers: at://// -export const didFromUri = (uri: string): string => { +import { parseResourceUri } from "@atcute/lexicons/syntax"; + +const legacyAuthority = (uri: string): string => { const rest = uri.startsWith("at://") ? uri.slice(5) : uri; const slash = rest.indexOf("/"); return slash === -1 ? rest : rest.slice(0, slash); }; -export const rkeyFromUri = (uri: string): string => uri.slice(uri.lastIndexOf("/") + 1); +export const didFromUri = (uri: string): string => { + try { + return parseResourceUri(uri).repo; + } catch { + return legacyAuthority(uri); + } +}; + +export const rkeyFromUri = (uri: string): string => { + try { + return parseResourceUri(uri).rkey ?? uri.slice(uri.lastIndexOf("/") + 1); + } catch { + return uri.slice(uri.lastIndexOf("/") + 1); + } +}; diff --git a/web/src/lib/api/write.ts b/web/src/lib/api/write.ts index 598ed4f16..b5921c083 100644 --- a/web/src/lib/api/write.ts +++ b/web/src/lib/api/write.ts @@ -2,10 +2,11 @@ import { ok } from "@atcute/client"; import { mainSchema as createRecordSchema } from "@atcute/atproto/types/repo/createRecord"; import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; -import type { Cid, Nsid, RecordKey, ResourceUri } from "@atcute/lexicons/syntax"; +import type { Cid, Nsid, ResourceUri } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { createClient } from "$lib/auth/agent"; import { clear } from "$lib/api/cache"; +import { recordKeyOf } from "$lib/api/syntax"; export interface WrittenRecord { uri: ResourceUri; @@ -25,7 +26,7 @@ export const createRecord = async ( input: { repo: agent.sub, collection, - ...(rkey ? { rkey: rkey as RecordKey } : {}), + ...(rkey ? { rkey: recordKeyOf(rkey) } : {}), record } }) @@ -47,7 +48,7 @@ export const putRecord = async ( input: { repo: agent.sub, collection, - rkey: rkey as RecordKey, + rkey: recordKeyOf(rkey), ...(swapRecord ? { swapRecord } : {}), record } @@ -68,7 +69,7 @@ export const deleteRecord = async ( input: { repo: agent.sub, collection, - rkey: rkey as RecordKey, + rkey: recordKeyOf(rkey), ...(swapRecord ? { swapRecord } : {}) } }) diff --git a/web/src/lib/auth/accounts.ts b/web/src/lib/auth/accounts.ts index 730a40d9e..48c05c229 100644 --- a/web/src/lib/auth/accounts.ts +++ b/web/src/lib/auth/accounts.ts @@ -1,5 +1,5 @@ import { browser } from "$app/environment"; -import type { Did } from "@atcute/lexicons/syntax"; +import { isDid, type Did } from "@atcute/lexicons/syntax"; // atcute owns oauth sessions; this stores metadata/order and active-account cookies. @@ -17,9 +17,6 @@ export interface AuthAccount { addedAt: number; } -const isDid = (value: unknown): value is Did => - typeof value === "string" && value.startsWith("did:"); - const isAccount = (value: unknown): value is AuthAccount => !!value && typeof value === "object" && diff --git a/web/src/lib/auth/agent.ts b/web/src/lib/auth/agent.ts index 58db4b43d..3e4f95b50 100644 --- a/web/src/lib/auth/agent.ts +++ b/web/src/lib/auth/agent.ts @@ -1,11 +1,16 @@ import { Client, ok } from "@atcute/client"; import { mainSchema as getServiceAuthSchema } from "@atcute/atproto/types/server/getServiceAuth"; -import type { AtprotoAudience, Nsid } from "@atcute/lexicons/syntax"; +import { isDid, isNsid, type AtprotoAudience, type Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; export const createClient = (agent: OAuthUserAgent, proxy?: AtprotoAudience): Client => new Client({ handler: agent, proxy: proxy ?? null }); +const nsidOf = (lxm: string): Nsid => { + if (!isNsid(lxm)) throw new Error(`invalid nsid: ${lxm}`); + return lxm; +}; + export interface ServiceAuthOptions { // service did for the jwt aud claim aud: string; @@ -19,14 +24,11 @@ export interface ServiceAuthOptions { export const serviceDidForHost = (host: string): string => `did:web:${host.replace(/:/g, "%3A")}`; export const hostForServiceDid = (did: string): string | null => { - if (!did.startsWith("did:web:")) return null; + if (!isDid(did) || !did.startsWith("did:web:")) return null; const encoded = did.slice("did:web:".length); - if (encoded === "") return null; - try { - return decodeURIComponent(encoded); - } catch { - return null; - } + if (/%(?![0-9A-Fa-f]{2})/.test(encoded)) return null; + const host = encoded.replace(/%3A/gi, ":").replace(/\.+$/, "").toLowerCase(); + return host || null; }; // mint a service-auth jwt for knot/spindle xrpc calls. @@ -38,7 +40,7 @@ export const mintServiceAuth = async ( const exp = Math.floor(Date.now() / 1000) + Math.max(expiresInSeconds, 60); const { token } = await ok( client.call(getServiceAuthSchema, { - params: { aud, exp, lxm: lxm as Nsid }, + params: { aud, exp, lxm: nsidOf(lxm) }, signal }) ); diff --git a/web/src/lib/auth/pending.ts b/web/src/lib/auth/pending.ts index aaddc2237..14b101e0e 100644 --- a/web/src/lib/auth/pending.ts +++ b/web/src/lib/auth/pending.ts @@ -1,5 +1,5 @@ import { browser } from "$app/environment"; -import type { Did } from "@atcute/lexicons/syntax"; +import { isDid, type Did } from "@atcute/lexicons/syntax"; import { safeReturnTo } from "$lib/auth/returnTo"; const PENDING_LOGIN_KEY = "tangled.pendingLogin"; @@ -21,8 +21,7 @@ export interface PendingCallback { const isPendingLogin = (value: unknown): value is PendingLogin => !!value && typeof value === "object" && - typeof (value as PendingLogin).did === "string" && - (value as PendingLogin).did.startsWith("did:") && + isDid((value as PendingLogin).did) && typeof (value as PendingLogin).handle === "string" && typeof (value as PendingLogin).returnTo === "string" && typeof (value as PendingLogin).at === "number"; diff --git a/web/src/lib/components/notifications/notification.ts b/web/src/lib/components/notifications/notification.ts index d4689df4b..715e1816d 100644 --- a/web/src/lib/components/notifications/notification.ts +++ b/web/src/lib/components/notifications/notification.ts @@ -1,3 +1,4 @@ +import { isDid } from "@atcute/lexicons/syntax"; import type { Component } from "svelte"; import type { SvelteHTMLElements } from "svelte/elements"; import AtSign from "$icon/at-sign"; @@ -136,7 +137,7 @@ export const notificationHref = (notification: NotificationSummary): string => { if (!repo) return `/${notification.actor.handle}`; // did-addressed repos have no name to route on; the did is their address, and // issues/pulls under them can't be addressed either - if (repo.name.startsWith("did:")) return `/${repo.name}`; + if (isDid(repo.name)) return `/${repo.name}`; const base = `/${repo.ownerHandle}/${repo.name}`; const subject = notification.subject; if (!subject) return base; diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts index bac0fb54c..394fb41af 100644 --- a/web/src/lib/components/profile/pages.ts +++ b/web/src/lib/components/profile/pages.ts @@ -1,5 +1,5 @@ import type { BobbinContext } from "$lib/api/client"; -import type { Did } from "@atcute/lexicons/syntax"; +import { isDid, isResourceUri, type Did } from "@atcute/lexicons/syntax"; import { enrich, authorOf, @@ -445,8 +445,8 @@ export const fetchPinned = async ( const resolved = owner.did === did ? handle : owner.handle; return resolved.endsWith(".invalid") ? owner.did : resolved; }; - const dids = keys.filter((key) => key.startsWith("did:")); - const uris = keys.filter((key) => key.startsWith("at://")); + const dids = keys.filter(isDid); + const uris = keys.filter(isResourceUri); const [byDid, byUri] = await Promise.all([ dids.length > 0 ? enrich<{ items: ListItem[] }>(ctx, { diff --git a/web/src/lib/server/repo.ts b/web/src/lib/server/repo.ts index 0401580f4..f7704d328 100644 --- a/web/src/lib/server/repo.ts +++ b/web/src/lib/server/repo.ts @@ -1,4 +1,5 @@ import { error } from "@sveltejs/kit"; +import { isDid, isHandle } from "@atcute/lexicons/syntax"; import { createBobbinClient, type BobbinContext } from "$lib/api/client"; import { gitTarget, type GitTarget } from "$lib/api/gitclient"; import { resolveMiniDoc, type MiniDoc } from "$lib/api/identity"; @@ -24,7 +25,7 @@ export const resolveRepoFromParams = async (event: { const name = event.params.repo ?? ""; // rejects bare words, unrelated paths must 404 instead of resolving as actors - if (!identifier.startsWith("did:") && !identifier.includes(".")) { + if (!isDid(identifier) && !isHandle(identifier)) { error(404, "Not found"); } diff --git a/web/src/routes/[handle]/+layout.ts b/web/src/routes/[handle]/+layout.ts index 54d85e70a..c7e4a8139 100644 --- a/web/src/routes/[handle]/+layout.ts +++ b/web/src/routes/[handle]/+layout.ts @@ -14,6 +14,7 @@ import { VOUCH_COUNT } from "$lib/api/descriptors"; import { toHttpError, httpStatusFor, stream } from "$lib/api/load"; +import { isDid, isHandle } from "@atcute/lexicons/syntax"; import { ClientResponseError } from "$lib/api/client"; import { getViewerVouch } from "$lib/api/graph"; import { resolveRepoByRepoDid } from "$lib/api/repo"; @@ -34,7 +35,7 @@ export const load: LayoutLoad = async (event) => { const identifier = decodeURIComponent(event.params.handle); // rejects bare words so unrelated paths 404 instead of resolving as actors - if (!identifier.startsWith("did:") && !identifier.includes(".")) { + if (!isDid(identifier) && !isHandle(identifier)) { error(404, "Not found"); } @@ -52,7 +53,7 @@ export const load: LayoutLoad = async (event) => { enrich: targetAll([...PROFILE_COUNTS, FOLLOW_VIEWER], ["did"]), viewer: viewerDid }).catch(async (cause) => { - if (identifier.startsWith("did:")) { + if (isDid(identifier)) { const repo = await resolveRepoByRepoDid(ctx, identifier); if (repo) { redirect(307, `/${repo.owner}/${repo.name}${event.url.search}`); @@ -115,7 +116,7 @@ export const load: LayoutLoad = async (event) => { ) ); - if (!browser || identifier.startsWith("did:")) { + if (!browser || isDid(identifier)) { const { identity, canonical } = await resolved; if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { redirect(307, `/${canonical}${event.url.search}`); diff --git a/web/src/routes/[handle]/[repo]/+layout.ts b/web/src/routes/[handle]/[repo]/+layout.ts index 6d2f35b18..91f1f841f 100644 --- a/web/src/routes/[handle]/[repo]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/+layout.ts @@ -1,4 +1,5 @@ import { browser } from "$app/environment"; +import { isDid, isHandle, isResourceUri } from "@atcute/lexicons/syntax"; import { error, redirect } from "@sveltejs/kit"; import { NO_ACCESS, resolveRepoAccess } from "$lib/api/access"; import { createBobbinClient } from "$lib/api/client"; @@ -22,7 +23,7 @@ const resolveSource = async ( ctx: BobbinContext, uri: string | undefined ): Promise => { - if (!uri?.startsWith("at://")) return null; + if (!uri || !isResourceUri(uri)) return null; try { const page = await enrich>(ctx, { xrpc: "sh.tangled.repo.getRepo", @@ -48,7 +49,7 @@ export const load: LayoutLoad = async (event) => { const name = decodeURIComponent(event.params.repo); // rejects bare words so unrelated paths 404 instead of resolving as actors - if (!identifier.startsWith("did:") && !identifier.includes(".")) { + if (!isDid(identifier) && !isHandle(identifier)) { error(404, "Not found"); } @@ -61,7 +62,7 @@ export const load: LayoutLoad = async (event) => { // server awaits for 404s and redirects, client navigation streams for instant commit const resolved = (async () => { const doc = await resolveMiniDoc(ctx, identifier).catch(async (cause) => { - if (identifier.startsWith("did:")) { + if (isDid(identifier)) { const repo = await resolveRepoByRepoDid(ctx, identifier); if (repo) { const prefix = `/${event.params.handle}`; @@ -155,7 +156,7 @@ export const load: LayoutLoad = async (event) => { resolved.then(({ repo }) => resolveRepoAccess(ctx, repo, viewerDid).catch(() => NO_ACCESS)) ); - if (!browser || identifier.startsWith("did:")) { + if (!browser || isDid(identifier)) { const { repo, canonical } = await resolved; if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { redirect(307, `/${canonical}/${event.params.repo}${event.url.search}`); diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts index 6d9eaa819..e33aa3471 100644 --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts @@ -1,4 +1,5 @@ import { browser } from "$app/environment"; +import { isResourceUri } from "@atcute/lexicons/syntax"; import { error, isHttpError } from "@sveltejs/kit"; import { createBobbinClient, ClientResponseError, type BobbinContext } from "$lib/api/client"; import { ISSUE_AUTHOR_DOCS, REACTION_AUTHOR_DOCS } from "$lib/api/descriptors"; @@ -168,7 +169,7 @@ export const load: PageLoad = async (event) => { const parent = await event.parent(); const uri = event.params.aturi; - if (!uri.startsWith("at://")) error(404, "Issue not found"); + if (!isResourceUri(uri)) error(404, "Issue not found"); const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts b/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts index 880bdb2f1..771073f91 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts @@ -1,4 +1,5 @@ import { browser } from "$app/environment"; +import { isResourceUri } from "@atcute/lexicons/syntax"; import { error } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; import { COMMENT_AUTHOR_DOCS } from "$lib/api/descriptors"; @@ -14,7 +15,7 @@ import type { LayoutLoad } from "./$types"; export const load: LayoutLoad = async (event) => { const parent = await event.parent(); const uri = event.params.aturi; - if (!uri.startsWith("at://")) error(404, "Pull request not found"); + if (!isResourceUri(uri)) error(404, "Pull request not found"); const pullPage = (async () => { const repo = await parent.repo; diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/+page.ts b/web/src/routes/[handle]/[repo]/pulls/[aturi]/+page.ts index 2b5db5b2a..325279cbc 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/+page.ts +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/+page.ts @@ -1,11 +1,12 @@ import { error, redirect } from "@sveltejs/kit"; +import { isResourceUri } from "@atcute/lexicons/syntax"; import type { PageLoad } from "./$types"; // every diff lives under a version segment, so the bare pull url picks one export const load: PageLoad = (event) => { const uri = event.params.aturi; - if (!uri.startsWith("at://")) error(404, "Pull request not found"); + if (!isResourceUri(uri)) error(404, "Pull request not found"); redirect(307, `/${event.params.handle}/${event.params.repo}/pulls/${uri}/latest`); }; diff --git a/web/src/routes/_internal/email-did/+server.ts b/web/src/routes/_internal/email-did/+server.ts index ad5034a72..77b471dbd 100644 --- a/web/src/routes/_internal/email-did/+server.ts +++ b/web/src/routes/_internal/email-did/+server.ts @@ -1,4 +1,5 @@ import { json } from "@sveltejs/kit"; +import { isDid } from "@atcute/lexicons/syntax"; import type { RequestHandler } from "./$types"; import { CURRENT_DID_KEY } from "$lib/auth/accounts"; @@ -10,7 +11,7 @@ const MAX_LOOKUPS = 250; export const GET: RequestHandler = async (event) => { const did = event.cookies.get(CURRENT_DID_KEY); - if (!did || !did.startsWith("did:")) { + if (!did || !isDid(did)) { return json({ error: "unauthorized" }, { status: 401 }); }