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";
|
||||
import { config } from "~/config.ts";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
import { Note } from "./note.ts";
|
||||
import { User } from "./user.ts";
|
||||
|
||||
type LikeType = InferSelectModel<typeof Likes> & {
|
||||
|
|
@ -172,12 +171,9 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
|
|||
type: "pub.versia:likes/Like",
|
||||
created_at: new Date(this.data.createdAt).toISOString(),
|
||||
liked:
|
||||
Note.getUri(
|
||||
this.data.liked.id,
|
||||
this.data.liked.uri
|
||||
? new URL(this.data.liked.uri)
|
||||
: undefined,
|
||||
)?.toString() ?? "",
|
||||
this.data.liked.uri ??
|
||||
new URL(`/notes/${this.data.liked.id}`, config.http.base_url)
|
||||
.href,
|
||||
uri: this.getUri().toString(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { idValidator } from "@/api";
|
||||
import { localObjectUri } from "@/constants";
|
||||
import { mergeAndDeduplicate } from "@/lib.ts";
|
||||
import { sanitizedHtmlStrip } from "@/sanitization";
|
||||
import { sentry } from "@/sentry";
|
||||
|
|
@ -858,14 +857,9 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
}
|
||||
|
||||
public getUri(): URL {
|
||||
return new URL(this.data.uri || localObjectUri(this.id));
|
||||
}
|
||||
|
||||
public static getUri(id: string | null, uri?: URL | null): URL | null {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return uri || localObjectUri(id);
|
||||
return this.data.uri
|
||||
? new URL(this.data.uri)
|
||||
: new URL(`/notes/${this.id}`, config.http.base_url);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -928,14 +922,16 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
|||
mention.uri ? new URL(mention.uri) : null,
|
||||
).toString(),
|
||||
),
|
||||
quotes: Note.getUri(
|
||||
status.quotingId,
|
||||
status.quote?.uri ? new URL(status.quote.uri) : null,
|
||||
)?.toString(),
|
||||
replies_to: Note.getUri(
|
||||
status.replyId,
|
||||
status.reply?.uri ? new URL(status.reply.uri) : null,
|
||||
)?.toString(),
|
||||
quotes: status.quote
|
||||
? (status.quote.uri ??
|
||||
new URL(`/notes/${status.quote.id}`, config.http.base_url)
|
||||
.href)
|
||||
: null,
|
||||
replies_to: status.reply
|
||||
? (status.reply.uri ??
|
||||
new URL(`/notes/${status.reply.id}`, config.http.base_url)
|
||||
.href)
|
||||
: null,
|
||||
subject: status.spoilerText,
|
||||
// TODO: Refactor as part of groups
|
||||
group: status.visibility === "public" ? "public" : "followers",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 InferInsertModel,
|
||||
|
|
@ -159,7 +159,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
|||
return this.data.uri
|
||||
? new URL(this.data.uri)
|
||||
: new URL(
|
||||
`/objects/${this.data.noteId}/reactions/${this.id}`,
|
||||
`/notes/${this.data.noteId}/reactions/${this.id}`,
|
||||
baseUrl,
|
||||
);
|
||||
}
|
||||
|
|
@ -187,12 +187,9 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
|||
created_at: new Date(this.data.createdAt).toISOString(),
|
||||
id: this.id,
|
||||
object:
|
||||
Note.getUri(
|
||||
this.data.note.id,
|
||||
this.data.note.uri
|
||||
? new URL(this.data.note.uri)
|
||||
: undefined,
|
||||
)?.toString() ?? "",
|
||||
this.data.note.uri ??
|
||||
new URL(`/notes/${this.data.noteId}`, config.http.base_url)
|
||||
.href,
|
||||
content: this.hasCustomEmoji()
|
||||
? `:${this.data.emoji?.shortcode}:`
|
||||
: 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