diff --git a/api/notes/:uuid/index.ts b/api/notes/:uuid/index.ts new file mode 100644 index 00000000..edf30ce3 --- /dev/null +++ b/api/notes/:uuid/index.ts @@ -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()); + }), +); diff --git a/api/notes/:uuid/quotes.ts b/api/notes/:uuid/quotes.ts new file mode 100644 index 00000000..9a522722 --- /dev/null +++ b/api/notes/:uuid/quotes.ts @@ -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()); + }), +); diff --git a/api/notes/:uuid/replies.ts b/api/notes/:uuid/replies.ts new file mode 100644 index 00000000..443669a9 --- /dev/null +++ b/api/notes/:uuid/replies.ts @@ -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()); + }), +); diff --git a/classes/database/like.ts b/classes/database/like.ts index 0d7ebe9d..dadd7ef4 100644 --- a/classes/database/like.ts +++ b/classes/database/like.ts @@ -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 & { @@ -172,12 +171,9 @@ export class Like extends BaseInterface { 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(), }; } diff --git a/classes/database/note.ts b/classes/database/note.ts index 1c9aaf86..5049035b 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -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 { } 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 { 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", diff --git a/classes/database/reaction.ts b/classes/database/reaction.ts index 60412ef8..48f08ebf 100644 --- a/classes/database/reaction.ts +++ b/classes/database/reaction.ts @@ -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 { 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 { 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 || "", diff --git a/utils/constants.ts b/utils/constants.ts deleted file mode 100644 index 50f95b80..00000000 --- a/utils/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { config } from "~/config.ts"; - -export const localObjectUri = (id: string): URL => - new URL(`/objects/${id}`, config.http.base_url);