mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(federation): ♻️ Move Versia Note URIs to /notes, instead of /objects
This commit is contained in:
parent
4063d58d79
commit
6622ee9020
75
api/notes/:uuid/index.ts
Normal file
75
api/notes/:uuid/index.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { apiRoute } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { Note as NoteSchema } from "@versia/federation/schemas";
|
||||||
|
import { Note } from "@versia/kit/db";
|
||||||
|
import { Notes } from "@versia/kit/tables";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { Status as StatusSchema } from "~/classes/schemas/status";
|
||||||
|
import { config } from "~/config.ts";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/notes/{id}",
|
||||||
|
summary: "Retrieve the Versia representation of a note.",
|
||||||
|
request: {
|
||||||
|
params: z.object({
|
||||||
|
id: StatusSchema.shape.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Note",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: NoteSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description:
|
||||||
|
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
|
const note = await Note.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.id, id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(note && (await note.isViewableByUser(null))) || note.isRemote()) {
|
||||||
|
throw new ApiError(404, "Note not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If base_url uses https and request uses http, rewrite request to use https
|
||||||
|
// This fixes reverse proxy errors
|
||||||
|
const reqUrl = new URL(context.req.url);
|
||||||
|
if (
|
||||||
|
config.http.base_url.protocol === "https:" &&
|
||||||
|
reqUrl.protocol === "http:"
|
||||||
|
) {
|
||||||
|
reqUrl.protocol = "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { headers } = await note.author.sign(
|
||||||
|
note.toVersia(),
|
||||||
|
reqUrl,
|
||||||
|
"GET",
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.json(note.toVersia(), 200, headers.toJSON());
|
||||||
|
}),
|
||||||
|
);
|
||||||
131
api/notes/:uuid/quotes.ts
Normal file
131
api/notes/:uuid/quotes.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { apiRoute } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { URICollection as URICollectionSchema } from "@versia/federation/schemas";
|
||||||
|
import type { URICollection } from "@versia/federation/types";
|
||||||
|
import { Note, db } from "@versia/kit/db";
|
||||||
|
import { Notes } from "@versia/kit/tables";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { Status as StatusSchema } from "~/classes/schemas/status";
|
||||||
|
import { config } from "~/config.ts";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/notes/{id}/quotes",
|
||||||
|
summary: "Retrieve all quotes of a Versia Note.",
|
||||||
|
request: {
|
||||||
|
params: z.object({
|
||||||
|
id: StatusSchema.shape.id,
|
||||||
|
}),
|
||||||
|
query: z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(40),
|
||||||
|
offset: z.coerce.number().int().nonnegative().default(0),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Note quotes",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: URICollectionSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description:
|
||||||
|
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { limit, offset } = context.req.valid("query");
|
||||||
|
|
||||||
|
const note = await Note.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.id, id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(note && (await note.isViewableByUser(null))) || note.isRemote()) {
|
||||||
|
throw new ApiError(404, "Note not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const replies = await Note.manyFromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.quotingId, note.id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
const replyCount = await db.$count(
|
||||||
|
Notes,
|
||||||
|
and(
|
||||||
|
eq(Notes.quotingId, note.id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uriCollection = {
|
||||||
|
author: note.author.getUri().href,
|
||||||
|
first: new URL(
|
||||||
|
`/notes/${note.id}/quotes?offset=0`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href,
|
||||||
|
last:
|
||||||
|
replyCount > limit
|
||||||
|
? new URL(
|
||||||
|
`/notes/${note.id}/quotes?offset=${replyCount - limit}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href
|
||||||
|
: new URL(`/notes/${note.id}/quotes`, config.http.base_url)
|
||||||
|
.href,
|
||||||
|
next:
|
||||||
|
offset + limit < replyCount
|
||||||
|
? new URL(
|
||||||
|
`/notes/${note.id}/quotes?offset=${offset + limit}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href
|
||||||
|
: null,
|
||||||
|
previous:
|
||||||
|
offset - limit >= 0
|
||||||
|
? new URL(
|
||||||
|
`/notes/${note.id}/quotes?offset=${offset - limit}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href
|
||||||
|
: null,
|
||||||
|
total: replyCount,
|
||||||
|
items: replies.map((reply) => reply.getUri().href),
|
||||||
|
} satisfies URICollection;
|
||||||
|
|
||||||
|
// If base_url uses https and request uses http, rewrite request to use https
|
||||||
|
// This fixes reverse proxy errors
|
||||||
|
const reqUrl = new URL(context.req.url);
|
||||||
|
if (
|
||||||
|
config.http.base_url.protocol === "https:" &&
|
||||||
|
reqUrl.protocol === "http:"
|
||||||
|
) {
|
||||||
|
reqUrl.protocol = "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { headers } = await note.author.sign(
|
||||||
|
uriCollection,
|
||||||
|
reqUrl,
|
||||||
|
"GET",
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.json(uriCollection, 200, headers.toJSON());
|
||||||
|
}),
|
||||||
|
);
|
||||||
131
api/notes/:uuid/replies.ts
Normal file
131
api/notes/:uuid/replies.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { apiRoute } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { URICollection as URICollectionSchema } from "@versia/federation/schemas";
|
||||||
|
import type { URICollection } from "@versia/federation/types";
|
||||||
|
import { Note, db } from "@versia/kit/db";
|
||||||
|
import { Notes } from "@versia/kit/tables";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { Status as StatusSchema } from "~/classes/schemas/status";
|
||||||
|
import { config } from "~/config.ts";
|
||||||
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
|
const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/notes/{id}/replies",
|
||||||
|
summary: "Retrieve all replies to a Versia Note.",
|
||||||
|
request: {
|
||||||
|
params: z.object({
|
||||||
|
id: StatusSchema.shape.id,
|
||||||
|
}),
|
||||||
|
query: z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(40),
|
||||||
|
offset: z.coerce.number().int().nonnegative().default(0),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Note replies",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: URICollectionSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description:
|
||||||
|
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: ErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { limit, offset } = context.req.valid("query");
|
||||||
|
|
||||||
|
const note = await Note.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.id, id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(note && (await note.isViewableByUser(null))) || note.isRemote()) {
|
||||||
|
throw new ApiError(404, "Note not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const replies = await Note.manyFromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.replyId, note.id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
const replyCount = await db.$count(
|
||||||
|
Notes,
|
||||||
|
and(
|
||||||
|
eq(Notes.replyId, note.id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uriCollection = {
|
||||||
|
author: note.author.getUri().href,
|
||||||
|
first: new URL(
|
||||||
|
`/notes/${note.id}/replies?offset=0`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href,
|
||||||
|
last:
|
||||||
|
replyCount > limit
|
||||||
|
? new URL(
|
||||||
|
`/notes/${note.id}/replies?offset=${replyCount - limit}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href
|
||||||
|
: new URL(`/notes/${note.id}/replies`, config.http.base_url)
|
||||||
|
.href,
|
||||||
|
next:
|
||||||
|
offset + limit < replyCount
|
||||||
|
? new URL(
|
||||||
|
`/notes/${note.id}/replies?offset=${offset + limit}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href
|
||||||
|
: null,
|
||||||
|
previous:
|
||||||
|
offset - limit >= 0
|
||||||
|
? new URL(
|
||||||
|
`/notes/${note.id}/replies?offset=${offset - limit}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href
|
||||||
|
: null,
|
||||||
|
total: replyCount,
|
||||||
|
items: replies.map((reply) => reply.getUri().href),
|
||||||
|
} satisfies URICollection;
|
||||||
|
|
||||||
|
// If base_url uses https and request uses http, rewrite request to use https
|
||||||
|
// This fixes reverse proxy errors
|
||||||
|
const reqUrl = new URL(context.req.url);
|
||||||
|
if (
|
||||||
|
config.http.base_url.protocol === "https:" &&
|
||||||
|
reqUrl.protocol === "http:"
|
||||||
|
) {
|
||||||
|
reqUrl.protocol = "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { headers } = await note.author.sign(
|
||||||
|
uriCollection,
|
||||||
|
reqUrl,
|
||||||
|
"GET",
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.json(uriCollection, 200, headers.toJSON());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -19,7 +19,6 @@ import {
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
import { BaseInterface } from "./base.ts";
|
import { BaseInterface } from "./base.ts";
|
||||||
import { Note } from "./note.ts";
|
|
||||||
import { User } from "./user.ts";
|
import { User } from "./user.ts";
|
||||||
|
|
||||||
type LikeType = InferSelectModel<typeof Likes> & {
|
type LikeType = InferSelectModel<typeof Likes> & {
|
||||||
|
|
@ -172,12 +171,9 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
||||||
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:
|
liked:
|
||||||
Note.getUri(
|
this.data.liked.uri ??
|
||||||
this.data.liked.id,
|
new URL(`/notes/${this.data.liked.id}`, config.http.base_url)
|
||||||
this.data.liked.uri
|
.href,
|
||||||
? new URL(this.data.liked.uri)
|
|
||||||
: undefined,
|
|
||||||
)?.toString() ?? "",
|
|
||||||
uri: this.getUri().toString(),
|
uri: this.getUri().toString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { idValidator } from "@/api";
|
import { idValidator } from "@/api";
|
||||||
import { localObjectUri } from "@/constants";
|
|
||||||
import { mergeAndDeduplicate } from "@/lib.ts";
|
import { mergeAndDeduplicate } from "@/lib.ts";
|
||||||
import { sanitizedHtmlStrip } from "@/sanitization";
|
import { sanitizedHtmlStrip } from "@/sanitization";
|
||||||
import { sentry } from "@/sentry";
|
import { sentry } from "@/sentry";
|
||||||
|
|
@ -858,14 +857,9 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getUri(): URL {
|
public getUri(): URL {
|
||||||
return new URL(this.data.uri || localObjectUri(this.id));
|
return this.data.uri
|
||||||
}
|
? new URL(this.data.uri)
|
||||||
|
: new URL(`/notes/${this.id}`, config.http.base_url);
|
||||||
public static getUri(id: string | null, uri?: URL | null): URL | null {
|
|
||||||
if (!id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return uri || localObjectUri(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -928,14 +922,16 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
mention.uri ? new URL(mention.uri) : null,
|
mention.uri ? new URL(mention.uri) : null,
|
||||||
).toString(),
|
).toString(),
|
||||||
),
|
),
|
||||||
quotes: Note.getUri(
|
quotes: status.quote
|
||||||
status.quotingId,
|
? (status.quote.uri ??
|
||||||
status.quote?.uri ? new URL(status.quote.uri) : null,
|
new URL(`/notes/${status.quote.id}`, config.http.base_url)
|
||||||
)?.toString(),
|
.href)
|
||||||
replies_to: Note.getUri(
|
: null,
|
||||||
status.replyId,
|
replies_to: status.reply
|
||||||
status.reply?.uri ? new URL(status.reply.uri) : null,
|
? (status.reply.uri ??
|
||||||
)?.toString(),
|
new URL(`/notes/${status.reply.id}`, config.http.base_url)
|
||||||
|
.href)
|
||||||
|
: null,
|
||||||
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",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ReactionExtension } from "@versia/federation/types";
|
import type { ReactionExtension } from "@versia/federation/types";
|
||||||
import { Emoji, Instance, Note, User, db } from "@versia/kit/db";
|
import { Emoji, Instance, type Note, User, db } from "@versia/kit/db";
|
||||||
import { type Notes, Reactions, type Users } from "@versia/kit/tables";
|
import { type Notes, Reactions, type Users } from "@versia/kit/tables";
|
||||||
import {
|
import {
|
||||||
type InferInsertModel,
|
type InferInsertModel,
|
||||||
|
|
@ -159,7 +159,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||||
return this.data.uri
|
return this.data.uri
|
||||||
? new URL(this.data.uri)
|
? new URL(this.data.uri)
|
||||||
: new URL(
|
: new URL(
|
||||||
`/objects/${this.data.noteId}/reactions/${this.id}`,
|
`/notes/${this.data.noteId}/reactions/${this.id}`,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -187,12 +187,9 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||||
created_at: new Date(this.data.createdAt).toISOString(),
|
created_at: new Date(this.data.createdAt).toISOString(),
|
||||||
id: this.id,
|
id: this.id,
|
||||||
object:
|
object:
|
||||||
Note.getUri(
|
this.data.note.uri ??
|
||||||
this.data.note.id,
|
new URL(`/notes/${this.data.noteId}`, config.http.base_url)
|
||||||
this.data.note.uri
|
.href,
|
||||||
? 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 || "",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export const localObjectUri = (id: string): URL =>
|
|
||||||
new URL(`/objects/${id}`, config.http.base_url);
|
|
||||||
Loading…
Reference in a new issue