refactor(api): ♻️ Use URL literal instead of strings

This commit is contained in:
Jesse Wierzbinski 2025-02-01 16:32:18 +01:00
parent 99fac323c8
commit 76d1ccc859
No known key found for this signature in database
50 changed files with 343 additions and 256 deletions

View file

@ -69,7 +69,7 @@ export default apiRoute((app) =>
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`, sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
), ),
limit, limit,
context.req.url, new URL(context.req.url),
); );
return context.json( return context.json(

View file

@ -71,7 +71,7 @@ export default apiRoute((app) =>
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`, sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
), ),
context.req.valid("query").limit, context.req.valid("query").limit,
context.req.url, new URL(context.req.url),
); );
return context.json( return context.json(

View file

@ -14,7 +14,7 @@ beforeAll(async () => {
priority: 2, priority: 2,
description: "test", description: "test",
visible: true, visible: true,
icon: "test", icon: "https://test.com",
}); });
expect(role).toBeDefined(); expect(role).toBeDefined();

View file

@ -115,7 +115,7 @@ export default apiRoute((app) =>
exclude_replies ? isNull(Notes.replyId) : undefined, exclude_replies ? isNull(Notes.replyId) : undefined,
), ),
limit, limit,
context.req.url, new URL(context.req.url),
user?.id, user?.id,
); );

View file

@ -60,11 +60,11 @@ describe("/api/v1/accounts/update_credentials", () => {
const object = (await response.json()) as APIAccount; const object = (await response.json()) as APIAccount;
// Proxy url is base_url/media/proxy/<base64url encoded url> // Proxy url is base_url/media/proxy/<base64url encoded url>
expect(object.note).toBe( expect(object.note).toBe(
`<p><img src="${config.http.base_url}/media/proxy/${Buffer.from( `<p><img src="${config.http.base_url}media/proxy/${Buffer.from(
"https://example.com/image.jpg", "https://example.com/image.jpg",
).toString("base64url")}"> <video src="${ ).toString("base64url")}"> <video src="${
config.http.base_url config.http.base_url
}/media/proxy/${Buffer.from( }media/proxy/${Buffer.from(
"https://example.com/video.mp4", "https://example.com/video.mp4",
).toString("base64url")}"> Test!</p>\n`, ).toString("base64url")}"> Test!</p>\n`,
); );

View file

@ -55,7 +55,7 @@ export default apiRoute((app) =>
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`, sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
), ),
limit, limit,
context.req.url, new URL(context.req.url),
); );
return context.json( return context.json(

View file

@ -53,7 +53,7 @@ export default apiRoute((app) =>
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`, sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
), ),
limit, limit,
context.req.url, new URL(context.req.url),
user?.id, user?.id,
); );

View file

@ -54,7 +54,7 @@ export default apiRoute((app) =>
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`, sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
), ),
limit, limit,
context.req.url, new URL(context.req.url),
); );
return context.json( return context.json(

View file

@ -98,8 +98,12 @@ export default apiRoute((app) =>
status_count: statusCount, status_count: statusCount,
user_count: userCount, user_count: userCount,
}, },
thumbnail: proxyUrl(config.instance.logo), thumbnail: config.instance.logo
banner: proxyUrl(config.instance.banner), ? proxyUrl(config.instance.logo).toString()
: null,
banner: config.instance.banner
? proxyUrl(config.instance.banner).toString()
: null,
title: config.instance.name, title: config.instance.name,
uri: config.http.base_url, uri: config.http.base_url,
urls: { urls: {
@ -113,7 +117,9 @@ export default apiRoute((app) =>
providers: providers:
oidcConfig?.providers?.map((p) => ({ oidcConfig?.providers?.map((p) => ({
name: p.name, name: p.name,
icon: proxyUrl(p.icon) || undefined, icon: p.icon
? proxyUrl(new URL(p.icon)).toString()
: undefined,
id: p.id, id: p.id,
})) ?? [], })) ?? [],
}, },

View file

@ -53,7 +53,7 @@ export default apiRoute((app) =>
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`, sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
), ),
limit, limit,
context.req.url, new URL(context.req.url),
); );
return context.json( return context.json(

View file

@ -148,7 +148,7 @@ export default apiRoute((app) =>
)`, )`,
), ),
limit, limit,
context.req.url, new URL(context.req.url),
user.id, user.id,
); );

View file

@ -15,7 +15,7 @@ beforeAll(async () => {
priority: 2, priority: 2,
description: "test", description: "test",
visible: true, visible: true,
icon: "test", icon: "https://test.com",
}); });
expect(role).toBeDefined(); expect(role).toBeDefined();

View file

@ -15,7 +15,7 @@ beforeAll(async () => {
priority: 10, priority: 10,
description: "test", description: "test",
visible: true, visible: true,
icon: "test", icon: "https://test.com",
}); });
expect(role).toBeDefined(); expect(role).toBeDefined();

View file

@ -60,7 +60,7 @@ export default apiRoute((app) =>
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${note.id} AND "Likes"."likerId" = ${Users.id})`, sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${note.id} AND "Likes"."likerId" = ${Users.id})`,
), ),
limit, limit,
context.req.url, new URL(context.req.url),
); );
return context.json( return context.json(

View file

@ -60,7 +60,7 @@ export default apiRoute((app) =>
sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${note.id} AND "Notes"."authorId" = ${Users.id})`, sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${note.id} AND "Notes"."authorId" = ${Users.id})`,
), ),
limit, limit,
context.req.url, new URL(context.req.url),
); );
return context.json( return context.json(

View file

@ -429,11 +429,11 @@ describe("/api/v1/statuses", () => {
const object = (await response.json()) as ApiStatus; const object = (await response.json()) as ApiStatus;
// Proxy url is base_url/media/proxy/<base64url encoded url> // Proxy url is base_url/media/proxy/<base64url encoded url>
expect(object.content).toBe( expect(object.content).toBe(
`<p><img src="${config.http.base_url}/media/proxy/${Buffer.from( `<p><img src="${config.http.base_url}media/proxy/${Buffer.from(
"https://example.com/image.jpg", "https://example.com/image.jpg",
).toString("base64url")}"> <video src="${ ).toString("base64url")}"> <video src="${
config.http.base_url config.http.base_url
}/media/proxy/${Buffer.from( }media/proxy/${Buffer.from(
"https://example.com/video.mp4", "https://example.com/video.mp4",
).toString("base64url")}"> Test!</p>`, ).toString("base64url")}"> Test!</p>`,
); );

View file

@ -70,7 +70,7 @@ describe("/api/v1/timelines/home", () => {
); );
expect(response.headers.get("link")).toBe( expect(response.headers.get("link")).toBe(
`<${config.http.base_url}/api/v1/timelines/home?limit=20&max_id=${timeline[19].id}>; rel="next"`, `<${config.http.base_url}api/v1/timelines/home?limit=20&max_id=${timeline[19].id}>; rel="next"`,
); );
}); });
@ -112,7 +112,7 @@ describe("/api/v1/timelines/home", () => {
); );
expect(response.headers.get("link")).toInclude( expect(response.headers.get("link")).toInclude(
`${config.http.base_url}/api/v1/timelines/home?limit=20&min_id=${timeline[20].id}>; rel="prev"`, `${config.http.base_url}api/v1/timelines/home?limit=20&min_id=${timeline[20].id}>; rel="prev"`,
); );
}); });

View file

@ -70,7 +70,7 @@ export default apiRoute((app) =>
sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['home'])`, sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['home'])`,
), ),
limit, limit,
context.req.url, new URL(context.req.url),
user.id, user.id,
); );

View file

@ -111,7 +111,7 @@ describe("/api/v1/timelines/public", () => {
); );
expect(response.headers.get("link")).toBe( expect(response.headers.get("link")).toBe(
`<${config.http.base_url}/api/v1/timelines/public?limit=20&max_id=${timeline[19].id}>; rel="next"`, `<${config.http.base_url}api/v1/timelines/public?limit=20&max_id=${timeline[19].id}>; rel="next"`,
); );
}); });
@ -153,7 +153,7 @@ describe("/api/v1/timelines/public", () => {
); );
expect(response.headers.get("link")).toInclude( expect(response.headers.get("link")).toInclude(
`${config.http.base_url}/api/v1/timelines/public?limit=20&min_id=${timeline[20].id}>; rel="prev"`, `${config.http.base_url}api/v1/timelines/public?limit=20&min_id=${timeline[20].id}>; rel="prev"`,
); );
}); });

View file

@ -93,7 +93,7 @@ export default apiRoute((app) =>
: eq(Notes.visibility, "public"), : eq(Notes.visibility, "public"),
), ),
limit, limit,
context.req.url, new URL(context.req.url),
user?.id, user?.id,
); );

View file

@ -147,10 +147,14 @@ export default apiRoute((app) =>
}, },
}, },
thumbnail: { thumbnail: {
url: proxyUrl(config.instance.logo), url: config.instance.logo
? proxyUrl(config.instance.logo)
: null,
}, },
banner: { banner: {
url: proxyUrl(config.instance.banner), url: config.instance.banner
? proxyUrl(config.instance.banner)
: null,
}, },
languages: ["en"], languages: ["en"],
configuration: { configuration: {
@ -227,7 +231,7 @@ export default apiRoute((app) =>
providers: providers:
oidcConfig?.providers?.map((p) => ({ oidcConfig?.providers?.map((p) => ({
name: p.name, name: p.name,
icon: proxyUrl(p.icon) ?? "", icon: p.icon ? proxyUrl(new URL(p.icon)) : "",
id: p.id, id: p.id,
})) ?? [], })) ?? [],
}, },

View file

@ -72,7 +72,11 @@ export default apiRoute((app) =>
const userJson = user.toVersia(); const userJson = user.toVersia();
const { headers } = await user.sign(userJson, context.req.url, "GET"); const { headers } = await user.sign(
userJson,
new URL(context.req.url),
"GET",
);
return context.json(userJson, 200, headers.toJSON()); return context.json(userJson, 200, headers.toJSON());
}), }),

View file

@ -107,7 +107,7 @@ export default apiRoute((app) =>
config.http.base_url, config.http.base_url,
).toString(), ).toString(),
total: totalNotes, total: totalNotes,
author: author.getUri(), author: author.getUri().toString(),
next: next:
notes.length === NOTES_PER_PAGE notes.length === NOTES_PER_PAGE
? new URL( ? new URL(
@ -125,7 +125,11 @@ export default apiRoute((app) =>
items: notes.map((note) => note.toVersia()), items: notes.map((note) => note.toVersia()),
}; };
const { headers } = await author.sign(json, context.req.url, "GET"); const { headers } = await author.sign(
json,
new URL(context.req.url),
"GET",
);
return context.json(json, 200, headers.toJSON()); return context.json(json, 200, headers.toJSON());
}), }),

View file

@ -99,7 +99,7 @@ export default apiRoute((app) =>
user.data.username, user.data.username,
new URL(config.http.base_url).host, new URL(config.http.base_url).host,
"application/activity+json", "application/activity+json",
config.federation.bridge.url, config.federation.bridge.url?.toString(),
); );
} catch (e) { } catch (e) {
const error = e as ResponseError; const error = e as ResponseError;

View file

@ -188,8 +188,8 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
return { return {
id: this.id, id: this.id,
shortcode: this.data.shortcode, shortcode: this.data.shortcode,
static_url: proxyUrl(this.media.getUrl()) ?? "", // TODO: Add static version static_url: proxyUrl(this.media.getUrl()).toString(),
url: proxyUrl(this.media.getUrl()) ?? "", url: proxyUrl(this.media.getUrl()).toString(),
visible_in_picker: this.data.visibleInPicker, visible_in_picker: this.data.visibleInPicker,
category: this.data.category ?? undefined, category: this.data.category ?? undefined,
global: this.data.ownerId === null, global: this.data.ownerId === null,

View file

@ -135,7 +135,7 @@ export class Instance extends BaseInterface<typeof Instances> {
return this.data.id; return this.data.id;
} }
public static async fetchMetadata(url: string): Promise<{ public static async fetchMetadata(url: URL): Promise<{
metadata: InstanceMetadata; metadata: InstanceMetadata;
protocol: "versia" | "activitypub"; protocol: "versia" | "activitypub";
}> { }> {
@ -184,7 +184,7 @@ export class Instance extends BaseInterface<typeof Instances> {
} }
private static async fetchActivityPubMetadata( private static async fetchActivityPubMetadata(
url: string, url: URL,
): Promise<InstanceMetadata | null> { ): Promise<InstanceMetadata | null> {
const origin = new URL(url).origin; const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin); const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
@ -311,18 +311,18 @@ export class Instance extends BaseInterface<typeof Instances> {
public static resolveFromHost(host: string): Promise<Instance> { public static resolveFromHost(host: string): Promise<Instance> {
if (host.startsWith("http")) { if (host.startsWith("http")) {
const url = new URL(host).host; const url = new URL(host);
return Instance.resolve(url); return Instance.resolve(url);
} }
const url = new URL(`https://${host}`); const url = new URL(`https://${host}`);
return Instance.resolve(url.origin); return Instance.resolve(url);
} }
public static async resolve(url: string): Promise<Instance> { public static async resolve(url: URL): Promise<Instance> {
const host = new URL(url).host; const host = url.host;
const existingInstance = await Instance.fromSql( const existingInstance = await Instance.fromSql(
eq(Instances.baseUrl, host), eq(Instances.baseUrl, host),
@ -352,7 +352,7 @@ export class Instance extends BaseInterface<typeof Instances> {
const logger = getLogger(["federation", "resolvers"]); const logger = getLogger(["federation", "resolvers"]);
const output = await Instance.fetchMetadata( const output = await Instance.fetchMetadata(
`https://${this.data.baseUrl}`, new URL(`https://${this.data.baseUrl}`),
); );
if (!output) { if (!output) {

View file

@ -167,15 +167,17 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
id: this.data.id, id: this.data.id,
author: User.getUri( author: User.getUri(
this.data.liker.id, this.data.liker.id,
this.data.liker.uri, this.data.liker.uri ? new URL(this.data.liker.uri) : null,
config.http.base_url, ).toString(),
),
type: "pub.versia:likes/Like", type: "pub.versia:likes/Like",
created_at: new Date(this.data.createdAt).toISOString(), created_at: new Date(this.data.createdAt).toISOString(),
liked: Note.getUri( liked:
Note.getUri(
this.data.liked.id, this.data.liked.id,
this.data.liked.uri, this.data.liked.uri
) as string, ? new URL(this.data.liked.uri)
: undefined,
)?.toString() ?? "",
uri: this.getUri().toString(), uri: this.getUri().toString(),
}; };
} }
@ -187,9 +189,12 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
author: User.getUri( author: User.getUri(
unliker?.id ?? this.data.liker.id, unliker?.id ?? this.data.liker.id,
unliker?.data.uri ?? this.data.liker.uri, unliker?.data.uri
config.http.base_url, ? new URL(unliker.data.uri)
), : this.data.liker.uri
? new URL(this.data.liker.uri)
: null,
).toString(),
deleted_type: "pub.versia:likes/Like", deleted_type: "pub.versia:likes/Like",
deleted: this.getUri().toString(), deleted: this.getUri().toString(),
}; };

View file

@ -209,7 +209,7 @@ export class Media extends BaseInterface<typeof Medias> {
const url = Media.getUrl(path); const url = Media.getUrl(path);
let thumbnailUrl = ""; let thumbnailUrl: URL | null = null;
if (options?.thumbnail) { if (options?.thumbnail) {
const { path } = await Media.upload(options.thumbnail); const { path } = await Media.upload(options.thumbnail);
@ -220,10 +220,15 @@ export class Media extends BaseInterface<typeof Medias> {
const content = await Media.fileToContentFormat(file, url, { const content = await Media.fileToContentFormat(file, url, {
description: options?.description, description: options?.description,
}); });
const thumbnailContent = options?.thumbnail const thumbnailContent =
? await Media.fileToContentFormat(options.thumbnail, thumbnailUrl, { thumbnailUrl && options?.thumbnail
? await Media.fileToContentFormat(
options.thumbnail,
thumbnailUrl,
{
description: options?.description, description: options?.description,
}) },
)
: undefined; : undefined;
const newAttachment = await Media.insert({ const newAttachment = await Media.insert({
@ -246,6 +251,12 @@ export class Media extends BaseInterface<typeof Medias> {
return newAttachment; return newAttachment;
} }
/**
* Creates and adds a new media attachment from a URL
* @param uri
* @param options
* @returns
*/
public static async fromUrl( public static async fromUrl(
uri: URL, uri: URL,
options?: { options?: {
@ -377,20 +388,21 @@ export class Media extends BaseInterface<typeof Medias> {
return this.data.id; return this.data.id;
} }
public static getUrl(name: string): string { public static getUrl(name: string): URL {
if (config.media.backend === MediaBackendType.Local) { if (config.media.backend === MediaBackendType.Local) {
return new URL(`/media/${name}`, config.http.base_url).toString(); return new URL(`/media/${name}`, config.http.base_url);
} }
if (config.media.backend === MediaBackendType.S3) { if (config.media.backend === MediaBackendType.S3) {
return new URL(`/${name}`, config.s3?.public_url).toString(); return new URL(`/${name}`, config.s3?.public_url);
}
return "";
} }
public getUrl(): string { throw new Error("Unknown media backend");
}
public getUrl(): URL {
const type = this.getPreferredMimeType(); const type = this.getPreferredMimeType();
return this.data.content[type]?.content; return new URL(this.data.content[type]?.content ?? "");
} }
/** /**
@ -461,7 +473,7 @@ export class Media extends BaseInterface<typeof Medias> {
*/ */
public static async fileToContentFormat( public static async fileToContentFormat(
file: File, file: File,
uri: string, uri: URL,
options?: Partial<{ options?: Partial<{
description: string; description: string;
}>, }>,
@ -475,7 +487,7 @@ export class Media extends BaseInterface<typeof Medias> {
// Thumbhash should be added in a worker after the file is uploaded // Thumbhash should be added in a worker after the file is uploaded
return { return {
[file.type]: { [file.type]: {
content: uri, content: uri.toString(),
remote: true, remote: true,
hash: { hash: {
sha256: hash, sha256: hash,
@ -528,9 +540,11 @@ export class Media extends BaseInterface<typeof Medias> {
return { return {
id: this.data.id, id: this.data.id,
type: this.getMastodonType(), type: this.getMastodonType(),
url: proxyUrl(data.content) ?? "", url: proxyUrl(new URL(data.content)).toString(),
remote_url: null, remote_url: null,
preview_url: proxyUrl(thumbnailData?.content), preview_url: thumbnailData?.content
? proxyUrl(new URL(thumbnailData.content)).toString()
: null,
text_url: null, text_url: null,
meta: this.toApiMeta(), meta: this.toApiMeta(),
description: data.description || null, description: data.description || null,

View file

@ -646,17 +646,17 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
* @param uri - The URI of the note to resolve * @param uri - The URI of the note to resolve
* @returns The resolved note * @returns The resolved note
*/ */
public static async resolve(uri: string): Promise<Note | null> { public static async resolve(uri: URL): Promise<Note | null> {
// Check if note not already in database // Check if note not already in database
const foundNote = await Note.fromSql(eq(Notes.uri, uri)); const foundNote = await Note.fromSql(eq(Notes.uri, uri.toString()));
if (foundNote) { if (foundNote) {
return foundNote; return foundNote;
} }
// Check if URI is of a local note // Check if URI is of a local note
if (uri.startsWith(config.http.base_url)) { if (uri.origin === config.http.base_url.origin) {
const uuid = uri.match(idValidator); const uuid = uri.pathname.match(idValidator);
if (!uuid?.[0]) { if (!uuid?.[0]) {
throw new Error( throw new Error(
@ -675,7 +675,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
* @param uri - The URI of the note to save * @param uri - The URI of the note to save
* @returns The saved note, or null if the note could not be fetched * @returns The saved note, or null if the note could not be fetched
*/ */
public static async fetchFromRemote(uri: string): Promise<Note | null> { public static async fetchFromRemote(uri: URL): Promise<Note | null> {
const instance = await Instance.resolve(uri); const instance = await Instance.resolve(uri);
if (!instance) { if (!instance) {
@ -691,7 +691,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
const note = await new EntityValidator().Note(data); const note = await new EntityValidator().Note(data);
const author = await User.resolve(note.author); const author = await User.resolve(new URL(note.author));
if (!author) { if (!author) {
throw new Error("Invalid object author"); throw new Error("Invalid object author");
@ -773,15 +773,15 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
uri: note.uri, uri: note.uri,
mentions: await Promise.all( mentions: await Promise.all(
(note.mentions ?? []) (note.mentions ?? [])
.map((mention) => User.resolve(mention)) .map((mention) => User.resolve(new URL(mention)))
.filter((mention) => mention !== null) as Promise<User>[], .filter((mention) => mention !== null) as Promise<User>[],
), ),
mediaAttachments: attachments, mediaAttachments: attachments,
replyId: note.replies_to replyId: note.replies_to
? (await Note.resolve(note.replies_to))?.data.id ? (await Note.resolve(new URL(note.replies_to)))?.data.id
: undefined, : undefined,
quoteId: note.quotes quoteId: note.quotes
? (await Note.resolve(note.quotes))?.data.id ? (await Note.resolve(new URL(note.quotes)))?.data.id
: undefined, : undefined,
}; };
@ -908,7 +908,10 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
mention.username, mention.username,
mention.instance?.baseUrl, mention.instance?.baseUrl,
), ),
url: User.getUri(mention.id, mention.uri, config.http.base_url), url: User.getUri(
mention.id,
mention.uri ? new URL(mention.uri) : null,
).toString(),
username: mention.username, username: mention.username,
})), })),
language: null, language: null,
@ -927,9 +930,9 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
sensitive: data.sensitive, sensitive: data.sensitive,
spoiler_text: data.spoilerText, spoiler_text: data.spoilerText,
tags: [], tags: [],
uri: data.uri || this.getUri(), uri: data.uri || this.getUri().toString(),
visibility: data.visibility as ApiStatus["visibility"], visibility: data.visibility as ApiStatus["visibility"],
url: data.uri || this.getMastoUri(), url: data.uri || this.getMastoUri().toString(),
bookmarked: false, bookmarked: false,
quote: data.quotingId quote: data.quotingId
? ((await Note.fromId(data.quotingId, userFetching?.id).then( ? ((await Note.fromId(data.quotingId, userFetching?.id).then(
@ -944,14 +947,11 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
}; };
} }
public getUri(): string { public getUri(): URL {
return this.data.uri || localObjectUri(this.id); return new URL(this.data.uri || localObjectUri(this.id));
} }
public static getUri( public static getUri(id: string | null, uri?: URL | null): URL | null {
id: string | null,
uri?: string | null,
): string | null {
if (!id) { if (!id) {
return null; return null;
} }
@ -962,11 +962,11 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
* Get the frontend URI of this note * Get the frontend URI of this note
* @returns The frontend URI of this note * @returns The frontend URI of this note
*/ */
public getMastoUri(): string { public getMastoUri(): URL {
return new URL( return new URL(
`/@${this.author.data.username}/${this.id}`, `/@${this.author.data.username}/${this.id}`,
config.http.base_url, config.http.base_url,
).toString(); );
} }
public deleteToVersia(): VersiaDelete { public deleteToVersia(): VersiaDelete {
@ -975,9 +975,9 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
return { return {
type: "Delete", type: "Delete",
id, id,
author: this.author.getUri(), author: this.author.getUri().toString(),
deleted_type: "Note", deleted_type: "Note",
deleted: this.getUri(), deleted: this.getUri().toString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}; };
} }
@ -992,8 +992,8 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
type: "Note", type: "Note",
created_at: new Date(status.createdAt).toISOString(), created_at: new Date(status.createdAt).toISOString(),
id: status.id, id: status.id,
author: this.author.getUri(), author: this.author.getUri().toString(),
uri: this.getUri(), uri: this.getUri().toString(),
content: { content: {
"text/html": { "text/html": {
content: status.content, content: status.content,
@ -1009,12 +1009,19 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
), ),
is_sensitive: status.sensitive, is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) => mentions: status.mentions.map((mention) =>
User.getUri(mention.id, mention.uri, config.http.base_url), User.getUri(
mention.id,
mention.uri ? new URL(mention.uri) : null,
).toString(),
), ),
quotes: quotes: Note.getUri(
Note.getUri(status.quotingId, status.quote?.uri) ?? undefined, status.quotingId,
replies_to: status.quote?.uri ? new URL(status.quote.uri) : null,
Note.getUri(status.replyId, status.reply?.uri) ?? undefined, )?.toString(),
replies_to: Note.getUri(
status.replyId,
status.reply?.uri ? new URL(status.reply.uri) : null,
)?.toString(),
subject: status.spoilerText, subject: status.spoilerText,
// TODO: Refactor as part of groups // TODO: Refactor as part of groups
group: status.visibility === "public" ? "public" : "followers", group: status.visibility === "public" ? "public" : "followers",

View file

@ -179,13 +179,12 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
}; };
} }
public getUri(baseUrl: string): string { public getUri(baseUrl: URL): URL {
return ( return this.data.uri
this.data.uri || ? new URL(this.data.uri)
new URL( : new URL(
`/objects/${this.data.noteId}/reactions/${this.id}`, `/objects/${this.data.noteId}/reactions/${this.id}`,
baseUrl, baseUrl,
).toString()
); );
} }
@ -203,19 +202,21 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
} }
return { return {
uri: this.getUri(config.http.base_url), uri: this.getUri(config.http.base_url).toString(),
type: "pub.versia:reactions/Reaction", type: "pub.versia:reactions/Reaction",
author: User.getUri( author: User.getUri(
this.data.authorId, this.data.authorId,
this.data.author.uri, this.data.author.uri ? new URL(this.data.author.uri) : null,
config.http.base_url, ).toString(),
),
created_at: new Date(this.data.createdAt).toISOString(), created_at: new Date(this.data.createdAt).toISOString(),
id: this.id, id: this.id,
object: Note.getUri( object:
Note.getUri(
this.data.note.id, this.data.note.id,
this.data.note.uri, this.data.note.uri
) as string, ? new URL(this.data.note.uri)
: undefined,
)?.toString() ?? "",
content: this.hasCustomEmoji() content: this.hasCustomEmoji()
? `:${this.data.emoji?.shortcode}:` ? `:${this.data.emoji?.shortcode}:`
: this.data.emojiText || "", : this.data.emojiText || "",

View file

@ -227,7 +227,9 @@ export class Role extends BaseInterface<typeof Roles> {
priority: this.data.priority, priority: this.data.priority,
description: this.data.description ?? undefined, description: this.data.description ?? undefined,
visible: this.data.visible, visible: this.data.visible,
icon: proxyUrl(this.data.icon) ?? undefined, icon: this.data.icon
? proxyUrl(new URL(this.data.icon)).toString()
: undefined,
}; };
} }
} }

View file

@ -17,7 +17,7 @@ export class Timeline<Type extends Note | User | Notification> {
public static getNoteTimeline( public static getNoteTimeline(
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
limit: number, limit: number,
url: string, url: URL,
userId?: string, userId?: string,
): Promise<{ link: string; objects: Note[] }> { ): Promise<{ link: string; objects: Note[] }> {
return new Timeline<Note>(TimelineType.Note).fetchTimeline( return new Timeline<Note>(TimelineType.Note).fetchTimeline(
@ -31,7 +31,7 @@ export class Timeline<Type extends Note | User | Notification> {
public static getUserTimeline( public static getUserTimeline(
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
limit: number, limit: number,
url: string, url: URL,
): Promise<{ link: string; objects: User[] }> { ): Promise<{ link: string; objects: User[] }> {
return new Timeline<User>(TimelineType.User).fetchTimeline( return new Timeline<User>(TimelineType.User).fetchTimeline(
sql, sql,
@ -43,7 +43,7 @@ export class Timeline<Type extends Note | User | Notification> {
public static getNotificationTimeline( public static getNotificationTimeline(
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
limit: number, limit: number,
url: string, url: URL,
userId?: string, userId?: string,
): Promise<{ link: string; objects: Notification[] }> { ): Promise<{ link: string; objects: Notification[] }> {
return new Timeline<Notification>( return new Timeline<Notification>(
@ -85,14 +85,11 @@ export class Timeline<Type extends Note | User | Notification> {
private async fetchLinkHeader( private async fetchLinkHeader(
objects: Type[], objects: Type[],
url: string, url: URL,
limit: number, limit: number,
): Promise<string> { ): Promise<string> {
const linkHeader: string[] = []; const linkHeader: string[] = [];
const urlWithoutQuery = new URL( const urlWithoutQuery = new URL(url.pathname, config.http.base_url);
new URL(url).pathname,
config.http.base_url,
).toString();
if (objects.length > 0) { if (objects.length > 0) {
switch (this.type) { switch (this.type) {
@ -130,7 +127,7 @@ export class Timeline<Type extends Note | User | Notification> {
private static async fetchNoteLinkHeader( private static async fetchNoteLinkHeader(
notes: Note[], notes: Note[],
urlWithoutQuery: string, urlWithoutQuery: URL,
limit: number, limit: number,
): Promise<string[]> { ): Promise<string[]> {
const linkHeader: string[] = []; const linkHeader: string[] = [];
@ -158,7 +155,7 @@ export class Timeline<Type extends Note | User | Notification> {
private static async fetchUserLinkHeader( private static async fetchUserLinkHeader(
users: User[], users: User[],
urlWithoutQuery: string, urlWithoutQuery: URL,
limit: number, limit: number,
): Promise<string[]> { ): Promise<string[]> {
const linkHeader: string[] = []; const linkHeader: string[] = [];
@ -186,7 +183,7 @@ export class Timeline<Type extends Note | User | Notification> {
private static async fetchNotificationLinkHeader( private static async fetchNotificationLinkHeader(
notifications: Notification[], notifications: Notification[],
urlWithoutQuery: string, urlWithoutQuery: URL,
limit: number, limit: number,
): Promise<string[]> { ): Promise<string[]> {
const linkHeader: string[] = []; const linkHeader: string[] = [];
@ -220,7 +217,7 @@ export class Timeline<Type extends Note | User | Notification> {
private async fetchTimeline( private async fetchTimeline(
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
limit: number, limit: number,
url: string, url: URL,
userId?: string, userId?: string,
): Promise<{ link: string; objects: Type[] }> { ): Promise<{ link: string; objects: Type[] }> {
const objects = await this.fetchObjects(sql, limit, userId); const objects = await this.fetchObjects(sql, limit, userId);

View file

@ -228,19 +228,14 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return !this.isLocal(); return !this.isLocal();
} }
public getUri(): string { public getUri(): URL {
return ( return this.data.uri
this.data.uri || ? new URL(this.data.uri)
new URL(`/users/${this.data.id}`, config.http.base_url).toString() : new URL(`/users/${this.data.id}`, config.http.base_url);
);
} }
public static getUri( public static getUri(id: string, uri: URL | null): URL {
id: string, return uri ? uri : new URL(`/users/${id}`, config.http.base_url);
uri: string | null,
baseUrl: string,
): string {
return uri || new URL(`/users/${id}`, baseUrl).toString();
} }
public hasPermission(permission: RolePermissions): boolean { public hasPermission(permission: RolePermissions): boolean {
@ -290,8 +285,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
entity: { entity: {
type: "Follow", type: "Follow",
id: crypto.randomUUID(), id: crypto.randomUUID(),
author: this.getUri(), author: this.getUri().toString(),
followee: otherUser.getUri(), followee: otherUser.getUri().toString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}, },
recipientId: otherUser.id, recipientId: otherUser.id,
@ -329,9 +324,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return { return {
type: "Unfollow", type: "Unfollow",
id, id,
author: this.getUri(), author: this.getUri().toString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
followee: followee.getUri(), followee: followee.getUri().toString(),
}; };
} }
@ -347,9 +342,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const entity: VersiaFollowAccept = { const entity: VersiaFollowAccept = {
type: "FollowAccept", type: "FollowAccept",
id: crypto.randomUUID(), id: crypto.randomUUID(),
author: this.getUri(), author: this.getUri().toString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
follower: follower.getUri(), follower: follower.getUri().toString(),
}; };
await deliveryQueue.add(DeliveryJobType.FederateEntity, { await deliveryQueue.add(DeliveryJobType.FederateEntity, {
@ -371,9 +366,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const entity: VersiaFollowReject = { const entity: VersiaFollowReject = {
type: "FollowReject", type: "FollowReject",
id: crypto.randomUUID(), id: crypto.randomUUID(),
author: this.getUri(), author: this.getUri().toString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
follower: follower.getUri(), follower: follower.getUri().toString(),
}; };
await deliveryQueue.add(DeliveryJobType.FederateEntity, { await deliveryQueue.add(DeliveryJobType.FederateEntity, {
@ -390,19 +385,21 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @param hostname * @param hostname
* @returns URI, or null if not found * @returns URI, or null if not found
*/ */
public static webFinger( public static async webFinger(
manager: FederationRequester, manager: FederationRequester,
username: string, username: string,
hostname: string, hostname: string,
): Promise<string | null> { ): Promise<URL | null> {
try { try {
return manager.webFinger(username, hostname); return new URL(await manager.webFinger(username, hostname));
} catch { } catch {
try { try {
return manager.webFinger( return new URL(
await manager.webFinger(
username, username,
hostname, hostname,
"application/activity+json", "application/activity+json",
),
); );
} catch { } catch {
return Promise.resolve(null); return Promise.resolve(null);
@ -511,7 +508,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
id: issuer.id, id: issuer.id,
name: issuer.name, name: issuer.name,
url: issuer.url, url: issuer.url,
icon: proxyUrl(issuer.icon) || undefined, icon: issuer.icon
? proxyUrl(new URL(issuer.icon)).toString()
: undefined,
server_id: account.serverId, server_id: account.serverId,
}; };
}) })
@ -662,11 +661,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return this; return this;
} }
public static async fetchFromRemote(uri: string): Promise<User | null> { public static async fetchFromRemote(uri: URL): Promise<User | null> {
if (!URL.canParse(uri)) {
throw new Error(`Invalid URI: ${uri}`);
}
const instance = await Instance.resolve(uri); const instance = await Instance.resolve(uri);
if (!instance) { if (!instance) {
@ -684,19 +679,19 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const bridgeUri = new URL( const bridgeUri = new URL(
`/apbridge/versia/query?${new URLSearchParams({ `/apbridge/versia/query?${new URLSearchParams({
user_url: uri, user_url: uri.toString(),
})}`, })}`,
config.federation.bridge.url, config.federation.bridge.url,
); );
return await User.saveFromVersia(bridgeUri.toString(), instance); return await User.saveFromVersia(bridgeUri, instance);
} }
throw new Error(`Unsupported protocol: ${instance.data.protocol}`); throw new Error(`Unsupported protocol: ${instance.data.protocol}`);
} }
private static async saveFromVersia( private static async saveFromVersia(
uri: string, uri: URL,
instance: Instance, instance: Instance,
): Promise<User> { ): Promise<User> {
const requester = await User.getFederationRequester(); const requester = await User.getFederationRequester();
@ -859,19 +854,19 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return user; return user;
} }
public static async resolve(uri: string): Promise<User | null> { public static async resolve(uri: URL): Promise<User | null> {
getLogger(["federation", "resolvers"]) getLogger(["federation", "resolvers"])
.debug`Resolving user ${chalk.gray(uri)}`; .debug`Resolving user ${chalk.gray(uri)}`;
// Check if user not already in database // Check if user not already in database
const foundUser = await User.fromSql(eq(Users.uri, uri)); const foundUser = await User.fromSql(eq(Users.uri, uri.toString()));
if (foundUser) { if (foundUser) {
return foundUser; return foundUser;
} }
// Check if URI is of a local user // Check if URI is of a local user
if (uri.startsWith(config.http.base_url)) { if (uri.origin === config.http.base_url.origin) {
const uuid = uri.match(idValidator); const uuid = uri.href.match(idValidator);
if (!uuid?.[0]) { if (!uuid?.[0]) {
throw new Error( throw new Error(
@ -893,11 +888,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @param config The config to use * @param config The config to use
* @returns The raw URL for the user's avatar * @returns The raw URL for the user's avatar
*/ */
public getAvatarUrl(config: Config): string { public getAvatarUrl(config: Config): URL {
if (!this.avatar) { if (!this.avatar) {
return ( return (
config.defaults.avatar || config.defaults.avatar ||
`https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${this.data.username}` new URL(
`https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${this.data.username}`,
)
); );
} }
return this.avatar?.getUrl(); return this.avatar?.getUrl();
@ -988,9 +985,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @param config The config to use * @param config The config to use
* @returns The raw URL for the user's header * @returns The raw URL for the user's header
*/ */
public getHeaderUrl(config: Config): string { public getHeaderUrl(config: Config): URL | null {
if (!this.header) { if (!this.header) {
return config.defaults.header || ""; return config.defaults.header ?? null;
} }
return this.header.getUrl(); return this.header.getUrl();
} }
@ -1052,7 +1049,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
*/ */
public async sign( public async sign(
entity: KnownEntity | Collection, entity: KnownEntity | Collection,
signatureUrl: string | URL, signatureUrl: URL,
signatureMethod: HttpVerb = "POST", signatureMethod: HttpVerb = "POST",
): Promise<{ ): Promise<{
headers: Headers; headers: Headers;
@ -1065,7 +1062,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const output = await signatureConstructor.sign( const output = await signatureConstructor.sign(
signatureMethod, signatureMethod,
new URL(signatureUrl), signatureUrl,
JSON.stringify(entity), JSON.stringify(entity),
); );
@ -1155,7 +1152,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
); );
} }
const { headers } = await this.sign(entity, inbox); const { headers } = await this.sign(entity, new URL(inbox));
try { try {
await new FederationRequester().post(inbox, entity, { await new FederationRequester().post(inbox, entity, {
@ -1185,12 +1182,14 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
username: user.username, username: user.username,
display_name: user.displayName, display_name: user.displayName,
note: user.note, note: user.note,
uri: this.getUri(), uri: this.getUri().toString(),
url: url:
user.uri || user.uri ||
new URL(`/@${user.username}`, config.http.base_url).toString(), new URL(`/@${user.username}`, config.http.base_url).toString(),
avatar: proxyUrl(this.getAvatarUrl(config)) ?? "", avatar: proxyUrl(this.getAvatarUrl(config)).toString(),
header: proxyUrl(this.getHeaderUrl(config)) ?? "", header: this.getHeaderUrl(config)
? proxyUrl(this.getHeaderUrl(config) as URL).toString()
: "",
locked: user.isLocked, locked: user.isLocked,
created_at: new Date(user.createdAt).toISOString(), created_at: new Date(user.createdAt).toISOString(),
followers_count: user.followerCount, followers_count: user.followerCount,
@ -1204,8 +1203,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
bot: user.isBot, bot: user.isBot,
source: isOwnAccount ? user.source : undefined, source: isOwnAccount ? user.source : undefined,
// TODO: Add static avatar and header // TODO: Add static avatar and header
avatar_static: proxyUrl(this.getAvatarUrl(config)) ?? "", avatar_static: proxyUrl(this.getAvatarUrl(config)).toString(),
header_static: proxyUrl(this.getHeaderUrl(config)) ?? "", header_static: this.getHeaderUrl(config)
? proxyUrl(this.getHeaderUrl(config) as URL).toString()
: "",
acct: this.getAcct(), acct: this.getAcct(),
// TODO: Add these fields // TODO: Add these fields
limited: false, limited: false,
@ -1258,7 +1259,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return { return {
id: user.id, id: user.id,
type: "User", type: "User",
uri: this.getUri(), uri: this.getUri().toString(),
bio: { bio: {
"text/html": { "text/html": {
content: user.note, content: user.note,
@ -1308,11 +1309,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
this.getAvatarUrl(config), this.getAvatarUrl(config),
this.data.source.avatar?.content_type, this.data.source.avatar?.content_type,
) ?? undefined, ) ?? undefined,
header: header: this.getHeaderUrl(config)
urlToContentFormat( ? (urlToContentFormat(
this.getHeaderUrl(config), this.getHeaderUrl(config) as URL,
this.data.source.header?.content_type, this.data.source.header?.content_type,
) ?? undefined, ) ?? undefined)
: undefined,
display_name: user.displayName, display_name: user.displayName,
fields: user.fields, fields: user.fields,
public_key: { public_key: {
@ -1335,7 +1337,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
public toMention(): ApiMention { public toMention(): ApiMention {
return { return {
url: this.getUri(), url: this.getUri().toString(),
username: this.data.username, username: this.data.username,
acct: this.getAcct(), acct: this.getAcct(),
id: this.id, id: this.id,

View file

@ -76,7 +76,7 @@ mock.module("~/packages/config-manager/index.ts", () => ({
describe("InboxProcessor", () => { describe("InboxProcessor", () => {
let mockRequest: { let mockRequest: {
url: string; url: URL;
method: string; method: string;
body: string; body: string;
}; };
@ -95,7 +95,7 @@ describe("InboxProcessor", () => {
// Setup basic mock context // Setup basic mock context
mockRequest = { mockRequest = {
url: "https://test.com", url: new URL("https://test.com"),
method: "POST", method: "POST",
body: "test-body", body: "test-body",
}; };
@ -196,7 +196,10 @@ describe("InboxProcessor", () => {
describe("processNote", () => { describe("processNote", () => {
test("successfully processes valid note", async () => { test("successfully processes valid note", async () => {
const mockNote = { author: "test-author" }; const mockNote = {
author: "https://example.com",
uri: "https://note.example.com",
};
const mockAuthor = { id: "test-id" }; const mockAuthor = { id: "test-id" };
const mockInstance = { id: "test-id" }; const mockInstance = { id: "test-id" };
@ -209,7 +212,9 @@ describe("InboxProcessor", () => {
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processNote"](); const result = await processor["processNote"]();
expect(User.resolve).toHaveBeenCalledWith("test-author"); expect(User.resolve).toHaveBeenCalledWith(
new URL("https://example.com"),
);
expect(Note.fromVersia).toHaveBeenCalledWith( expect(Note.fromVersia).toHaveBeenCalledWith(
mockNote, mockNote,
mockAuthor, mockAuthor,
@ -220,7 +225,13 @@ describe("InboxProcessor", () => {
test("returns 404 when author not found", async () => { test("returns 404 when author not found", async () => {
User.resolve = jest.fn().mockResolvedValue(null); User.resolve = jest.fn().mockResolvedValue(null);
const mockNote = {
author: "https://example.com",
uri: "https://note.example.com",
};
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockNote as VersiaNote;
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processNote"](); const result = await processor["processNote"]();
@ -233,8 +244,8 @@ describe("InboxProcessor", () => {
describe("processFollowRequest", () => { describe("processFollowRequest", () => {
test("successfully processes follow request for unlocked account", async () => { test("successfully processes follow request for unlocked account", async () => {
const mockFollow = { const mockFollow = {
author: "test-author", author: "https://example.com",
followee: "test-followee", followee: "https://followee.note.com",
}; };
const mockAuthor = { id: "author-id" }; const mockAuthor = { id: "author-id" };
const mockFollowee = { const mockFollowee = {
@ -273,7 +284,13 @@ describe("InboxProcessor", () => {
test("returns 404 when author not found", async () => { test("returns 404 when author not found", async () => {
User.resolve = jest.fn().mockResolvedValue(null); User.resolve = jest.fn().mockResolvedValue(null);
const mockFollow = {
author: "https://example.com",
followee: "https://followee.note.com",
};
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockFollow as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processFollowRequest"](); const result = await processor["processFollowRequest"]();
@ -287,7 +304,7 @@ describe("InboxProcessor", () => {
test("successfully deletes a note", async () => { test("successfully deletes a note", async () => {
const mockDelete = { const mockDelete = {
deleted_type: "Note", deleted_type: "Note",
deleted: "test-uri", deleted: "https://example.com",
}; };
const mockNote = { const mockNote = {
delete: jest.fn(), delete: jest.fn(),
@ -307,7 +324,7 @@ describe("InboxProcessor", () => {
test("returns 404 when note not found", async () => { test("returns 404 when note not found", async () => {
const mockDelete = { const mockDelete = {
deleted_type: "Note", deleted_type: "Note",
deleted: "test-uri", deleted: "https://example.com",
}; };
Note.fromSql = jest.fn().mockResolvedValue(null); Note.fromSql = jest.fn().mockResolvedValue(null);
@ -331,9 +348,9 @@ describe("InboxProcessor", () => {
describe("processLikeRequest", () => { describe("processLikeRequest", () => {
test("successfully processes like request", async () => { test("successfully processes like request", async () => {
const mockLike = { const mockLike = {
author: "test-author", author: "https://example.com",
liked: "test-note", liked: "https://example.note.com",
uri: "test-uri", uri: "https://example.com",
}; };
const mockAuthor = { const mockAuthor = {
like: jest.fn(), like: jest.fn(),
@ -348,13 +365,23 @@ describe("InboxProcessor", () => {
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processLikeRequest"](); const result = await processor["processLikeRequest"]();
expect(mockAuthor.like).toHaveBeenCalledWith(mockNote, "test-uri"); expect(mockAuthor.like).toHaveBeenCalledWith(
mockNote,
"https://example.com",
);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("returns 404 when author not found", async () => { test("returns 404 when author not found", async () => {
User.resolve = jest.fn().mockResolvedValue(null); User.resolve = jest.fn().mockResolvedValue(null);
const mockLike = {
author: "https://example.com",
liked: "https://example.note.com",
uri: "https://example.com",
};
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockLike as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processLikeRequest"](); const result = await processor["processLikeRequest"]();
@ -367,7 +394,7 @@ describe("InboxProcessor", () => {
describe("processUserRequest", () => { describe("processUserRequest", () => {
test("successfully processes user update", async () => { test("successfully processes user update", async () => {
const mockUser = { const mockUser = {
uri: "test-uri", uri: "https://example.com",
}; };
const mockUpdatedUser = { id: "user-id" }; const mockUpdatedUser = { id: "user-id" };
@ -378,13 +405,20 @@ describe("InboxProcessor", () => {
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processUserRequest"](); const result = await processor["processUserRequest"]();
expect(User.fetchFromRemote).toHaveBeenCalledWith("test-uri"); expect(User.fetchFromRemote).toHaveBeenCalledWith(
new URL("https://example.com"),
);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("returns 500 when update fails", async () => { test("returns 500 when update fails", async () => {
const mockUser = {
uri: "https://example.com",
};
User.fetchFromRemote = jest.fn().mockResolvedValue(null); User.fetchFromRemote = jest.fn().mockResolvedValue(null);
// biome-ignore lint/complexity/useLiteralKeys: Private variable
processor["body"] = mockUser as unknown as Entity;
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
const result = await processor["processUserRequest"](); const result = await processor["processUserRequest"]();

View file

@ -40,7 +40,7 @@ function isDefederated(hostname: string): boolean {
return ( return (
config.federation.blocked.find( config.federation.blocked.find(
(blocked) => pattern.match(blocked) !== null, (blocked) => pattern.match(blocked.toString()) !== null,
) !== undefined ) !== undefined
); );
} }
@ -70,7 +70,7 @@ export class InboxProcessor {
*/ */
public constructor( public constructor(
private request: { private request: {
url: string; url: URL;
method: string; method: string;
body: string; body: string;
}, },
@ -269,8 +269,8 @@ export class InboxProcessor {
*/ */
private async processNote(): Promise<Response | null> { private async processNote(): Promise<Response | null> {
const note = this.body as VersiaNote; const note = this.body as VersiaNote;
const author = await User.resolve(note.author); const author = await User.resolve(new URL(note.author));
const instance = await Instance.resolve(note.uri); const instance = await Instance.resolve(new URL(note.uri));
if (!instance) { if (!instance) {
return Response.json( return Response.json(
@ -298,8 +298,8 @@ export class InboxProcessor {
*/ */
private async processFollowRequest(): Promise<Response | null> { private async processFollowRequest(): Promise<Response | null> {
const follow = this.body as unknown as VersiaFollow; const follow = this.body as unknown as VersiaFollow;
const author = await User.resolve(follow.author); const author = await User.resolve(new URL(follow.author));
const followee = await User.resolve(follow.followee); const followee = await User.resolve(new URL(follow.followee));
if (!author) { if (!author) {
return Response.json( return Response.json(
@ -352,8 +352,8 @@ export class InboxProcessor {
*/ */
private async processFollowAccept(): Promise<Response | null> { private async processFollowAccept(): Promise<Response | null> {
const followAccept = this.body as unknown as VersiaFollowAccept; const followAccept = this.body as unknown as VersiaFollowAccept;
const author = await User.resolve(followAccept.author); const author = await User.resolve(new URL(followAccept.author));
const follower = await User.resolve(followAccept.follower); const follower = await User.resolve(new URL(followAccept.follower));
if (!author) { if (!author) {
return Response.json( return Response.json(
@ -393,8 +393,8 @@ export class InboxProcessor {
*/ */
private async processFollowReject(): Promise<Response | null> { private async processFollowReject(): Promise<Response | null> {
const followReject = this.body as unknown as VersiaFollowReject; const followReject = this.body as unknown as VersiaFollowReject;
const author = await User.resolve(followReject.author); const author = await User.resolve(new URL(followReject.author));
const follower = await User.resolve(followReject.follower); const follower = await User.resolve(new URL(followReject.follower));
if (!author) { if (!author) {
return Response.json( return Response.json(
@ -438,7 +438,7 @@ export class InboxProcessor {
const toDelete = delete_.deleted; const toDelete = delete_.deleted;
const author = delete_.author const author = delete_.author
? await User.resolve(delete_.author) ? await User.resolve(new URL(delete_.author))
: null; : null;
switch (delete_.deleted_type) { switch (delete_.deleted_type) {
@ -461,7 +461,7 @@ export class InboxProcessor {
return null; return null;
} }
case "User": { case "User": {
const userToDelete = await User.resolve(toDelete); const userToDelete = await User.resolve(new URL(toDelete));
if (!userToDelete) { if (!userToDelete) {
return Response.json( return Response.json(
@ -516,8 +516,8 @@ export class InboxProcessor {
*/ */
private async processLikeRequest(): Promise<Response | null> { private async processLikeRequest(): Promise<Response | null> {
const like = this.body as unknown as VersiaLikeExtension; const like = this.body as unknown as VersiaLikeExtension;
const author = await User.resolve(like.author); const author = await User.resolve(new URL(like.author));
const likedNote = await Note.resolve(like.liked); const likedNote = await Note.resolve(new URL(like.liked));
if (!author) { if (!author) {
return Response.json( return Response.json(
@ -546,7 +546,7 @@ export class InboxProcessor {
private async processUserRequest(): Promise<Response | null> { private async processUserRequest(): Promise<Response | null> {
const user = this.body as unknown as VersiaUser; const user = this.body as unknown as VersiaUser;
// FIXME: Instead of refetching the remote user, we should read the incoming json and update from that // FIXME: Instead of refetching the remote user, we should read the incoming json and update from that
const updatedAccount = await User.fetchFromRemote(user.uri); const updatedAccount = await User.fetchFromRemote(new URL(user.uri));
if (!updatedAccount) { if (!updatedAccount) {
return Response.json( return Response.json(

View file

@ -41,7 +41,7 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
return; return;
} }
await Instance.resolve(uri); await Instance.resolve(new URL(uri));
await job.log( await job.log(
`✔ Finished fetching instance metadata from [${uri}]`, `✔ Finished fetching instance metadata from [${uri}]`,

View file

@ -22,7 +22,10 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
if (headers.authorization) { if (headers.authorization) {
const processor = new InboxProcessor( const processor = new InboxProcessor(
request, {
...request,
url: new URL(request.url),
},
data, data,
null, null,
{ {
@ -67,7 +70,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
"x-signed-by": string; "x-signed-by": string;
}; };
const sender = await User.resolve(signedBy); const sender = await User.resolve(new URL(signedBy));
if (!(sender || signedBy.startsWith("instance "))) { if (!(sender || signedBy.startsWith("instance "))) {
await job.log( await job.log(
@ -106,7 +109,10 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
} }
const processor = new InboxProcessor( const processor = new InboxProcessor(
request, {
...request,
url: new URL(request.url),
},
data, data,
{ {
instance: remoteInstance, instance: remoteInstance,

View file

@ -113,7 +113,7 @@ export const getPushWorker = (): Worker<PushJobData, void, PushJobType> =>
vapidDetails: { vapidDetails: {
subject: subject:
config.notifications.push.vapid.subject || config.notifications.push.vapid.subject ||
config.http.base_url, config.http.base_url.origin,
privateKey: config.notifications.push.vapid.private, privateKey: config.notifications.push.vapid.private,
publicKey: config.notifications.push.vapid.public, publicKey: config.notifications.push.vapid.public,
}, },

View file

@ -101,7 +101,7 @@ export abstract class UserFinderCommand<
// Check instance exists, if not, create it // Check instance exists, if not, create it
await Instance.resolve( await Instance.resolve(
`https://${parseUserAddress(identifier).domain}`, new URL(`https://${parseUserAddress(identifier).domain}`),
); );
} }

View file

@ -43,7 +43,7 @@ export default class FederationUserFetch extends BaseCommand<
} }
// Check instance exists, if not, create it // Check instance exists, if not, create it
await Instance.resolve(`https://${host}`); await Instance.resolve(new URL(`https://${host}`));
const manager = await User.getFederationRequester(); const manager = await User.getFederationRequester();

View file

@ -44,7 +44,7 @@ export default class FederationUserFinger extends BaseCommand<
} }
// Check instance exists, if not, create it // Check instance exists, if not, create it
await Instance.resolve(`https://${host}`); await Instance.resolve(new URL(`https://${host}`));
const manager = await User.getFederationRequester(); const manager = await User.getFederationRequester();

View file

@ -23,7 +23,8 @@ const zUrl = z
.trim() .trim()
.min(1) .min(1)
.refine((arg) => URL.canParse(arg), "Invalid url") .refine((arg) => URL.canParse(arg), "Invalid url")
.transform((arg) => arg.replace(/\/$/, "")); .transform((arg) => arg.replace(/\/$/, ""))
.transform((arg) => new URL(arg));
export const configValidator = z export const configValidator = z
.object({ .object({
@ -124,7 +125,7 @@ export const configValidator = z
.strict(), .strict(),
http: z http: z
.object({ .object({
base_url: z.string().min(1).default("http://versia.social"), base_url: zUrl.default("http://versia.social"),
bind: z.string().min(1).default("0.0.0.0"), bind: z.string().min(1).default("0.0.0.0"),
bind_port: z bind_port: z
.number() .number()

View file

@ -86,7 +86,9 @@ export default (plugin: PluginType): void => {
{ {
id: issuer.id, id: issuer.id,
name: issuer.name, name: issuer.name,
icon: proxyUrl(issuer.icon) ?? undefined, icon: issuer.icon
? proxyUrl(new URL(issuer.icon))
: undefined,
}, },
200, 200,
); );

View file

@ -114,7 +114,9 @@ export default (plugin: PluginType): void => {
); );
} }
const authServer = await oauthDiscoveryRequest(issuer.url); const authServer = await oauthDiscoveryRequest(
new URL(issuer.url),
);
const codeVerifier = generateRandomCodeVerifier(); const codeVerifier = generateRandomCodeVerifier();
@ -130,7 +132,7 @@ export default (plugin: PluginType): void => {
crypto.getRandomValues(new Uint8Array(32)), crypto.getRandomValues(new Uint8Array(32)),
).toString("base64"), ).toString("base64"),
name: "Versia", name: "Versia",
redirectUri, redirectUri: redirectUri.toString(),
scopes: "openid profile email", scopes: "openid profile email",
secret: "", secret: "",
}); });

View file

@ -20,17 +20,15 @@ import {
} from "oauth4webapi"; } from "oauth4webapi";
export const oauthDiscoveryRequest = ( export const oauthDiscoveryRequest = (
issuerUrl: string | URL, issuerUrl: URL,
): Promise<AuthorizationServer> => { ): Promise<AuthorizationServer> => {
const issuerUrlurl = new URL(issuerUrl); return discoveryRequest(issuerUrl, {
return discoveryRequest(issuerUrlurl, {
algorithm: "oidc", algorithm: "oidc",
}).then((res) => processDiscoveryResponse(issuerUrlurl, res)); }).then((res) => processDiscoveryResponse(issuerUrl, res));
}; };
export const oauthRedirectUri = (baseUrl: string, issuer: string): string => export const oauthRedirectUri = (baseUrl: URL, issuer: string): URL =>
new URL(`/oauth/sso/${issuer}/callback`, baseUrl).toString(); new URL(`/oauth/sso/${issuer}/callback`, baseUrl);
const getFlow = ( const getFlow = (
flowId: string, flowId: string,
@ -73,7 +71,7 @@ const getOIDCResponse = (
authServer: AuthorizationServer, authServer: AuthorizationServer,
clientId: string, clientId: string,
clientSecret: string, clientSecret: string,
redirectUri: string, redirectUri: URL,
codeVerifier: string, codeVerifier: string,
parameters: URLSearchParams, parameters: URLSearchParams,
): Promise<Response> => { ): Promise<Response> => {
@ -84,7 +82,7 @@ const getOIDCResponse = (
}, },
ClientSecretPost(clientSecret), ClientSecretPost(clientSecret),
parameters, parameters,
redirectUri, redirectUri.toString(),
codeVerifier, codeVerifier,
); );
}; };
@ -177,7 +175,7 @@ export const automaticOidcFlow = async (
authServer, authServer,
issuer.client_id, issuer.client_id,
issuer.client_secret, issuer.client_secret,
redirectUrl.toString(), redirectUrl,
flow.codeVerifier, flow.codeVerifier,
parameters, parameters,
); );

View file

@ -32,7 +32,7 @@ export const applyToHono = (app: OpenAPIHono<HonoEnv>): void => {
}, },
boardLogo: { boardLogo: {
path: path:
config.instance.logo ?? config.instance.logo?.toString() ??
"https://cdn.versia.pub/branding/icon.svg", "https://cdn.versia.pub/branding/icon.svg",
height: 40, height: 40,
}, },

View file

@ -1,4 +1,4 @@
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/packages/config-manager/index.ts";
export const localObjectUri = (id: string): string => export const localObjectUri = (id: string): URL =>
new URL(`/objects/${id}`, config.http.base_url).toString(); new URL(`/objects/${id}`, config.http.base_url);

View file

@ -30,28 +30,25 @@ export const getBestContentType = (
}; };
export const urlToContentFormat = ( export const urlToContentFormat = (
url: string, url: URL,
contentType?: string, contentType?: string,
): ContentFormat | null => { ): ContentFormat | null => {
if (!url) { if (url.href.startsWith("https://api.dicebear.com/")) {
return null;
}
if (url.startsWith("https://api.dicebear.com/")) {
return { return {
"image/svg+xml": { "image/svg+xml": {
content: url, content: url.toString(),
remote: true, remote: true,
}, },
}; };
} }
const mimeType = const mimeType =
contentType || contentType ||
lookup(url.replace(new URL(url).search, "")) || lookup(url.toString().replace(url.search, "")) ||
"application/octet-stream"; "application/octet-stream";
return { return {
[mimeType]: { [mimeType]: {
content: url, content: url.toString(),
remote: true, remote: true,
}, },
}; };

View file

@ -9,12 +9,9 @@ export type Json =
| Json[] | Json[]
| { [key: string]: Json }; | { [key: string]: Json };
export const proxyUrl = (url: string | null = null): string | null => { export const proxyUrl = (url: URL): URL => {
const urlAsBase64Url = Buffer.from(url || "").toString("base64url"); const urlAsBase64Url = Buffer.from(url.toString() || "").toString(
return url "base64url",
? new URL( );
`/media/proxy/${urlAsBase64Url}`, return new URL(`/media/proxy/${urlAsBase64Url}`, config.http.base_url);
config.http.base_url,
).toString()
: url;
}; };

View file

@ -136,7 +136,11 @@ export const sanitizeHtml = async (
element(element): void { element(element): void {
element.setAttribute( element.setAttribute(
"src", "src",
proxyUrl(element.getAttribute("src") ?? "") ?? "", element.getAttribute("src")
? proxyUrl(
new URL(element.getAttribute("src") as string),
).toString()
: "",
); );
}, },
}) })