diff --git a/CHANGES.md b/CHANGES.md index a407ca4b..d91d8013 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -94,6 +94,12 @@ To be released. are removed; UnoCSS emits a single _src/public/uno.css_ whose URL is cache-busted by file mtime. + - Improved the performance of authenticated API requests by replacing the + complex multi-table JOIN query in the `tokenRequired` middleware with a + lightweight single-table lookup. Account owner data is now fetched on + demand only by routes that actually need it, and requests that fail scope + validation no longer touch the accounts table at all. [[#127], [#467]] + - Fixed a bug where incoming ActivityPub posts with timestamps more than 12 hours in the future were accepted and stuck to the top of the federated timeline, enabling timeline manipulation via forged timestamps. @@ -107,11 +113,13 @@ To be released. [FEP-044f]: https://w3id.org/fep/044f [#67]: https://github.com/fedify-dev/hollo/issues/67 +[#127]: https://github.com/fedify-dev/hollo/issues/127 [#457]: https://github.com/fedify-dev/hollo/pull/457 [#458]: https://github.com/fedify-dev/hollo/pull/458 [#459]: https://github.com/fedify-dev/hollo/pull/459 [#460]: https://github.com/fedify-dev/hollo/pull/460 [#466]: https://github.com/fedify-dev/hollo/pull/466 +[#467]: https://github.com/fedify-dev/hollo/pull/467 Version 0.8.1 diff --git a/src/api/v1/accounts.ts b/src/api/v1/accounts.ts index e55e3b1e..e88f4728 100644 --- a/src/api/v1/accounts.ts +++ b/src/api/v1/accounts.ts @@ -42,7 +42,8 @@ import { import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { type Account, @@ -63,21 +64,16 @@ import { import { isUuid, type Uuid } from "../../uuid"; import { timelineQuerySchema } from "./timelines"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); const allowedImageMimeTypes = ["image/gif", "image/jpeg", "image/png"]; app.get( "/verify_credentials", tokenRequired, scopeRequired(["read:accounts", "profile"]), + withAccountOwner, async (c) => { - const accountOwner = c.get("token").accountOwner; - if (accountOwner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const accountOwner = c.get("accountOwner"); return c.json(serializeAccountOwner(accountOwner, c.req.url)); }, ); @@ -86,6 +82,7 @@ app.patch( "/update_credentials", tokenRequired, scopeRequired(["write:accounts"]), + withAccountOwner, zValidator( "form", z.object({ @@ -117,13 +114,7 @@ app.patch( import("../../text"), ]); const disk = drive.use(); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const account = owner.account; const form = c.req.valid("form"); let avatarUrl: string | undefined; @@ -266,14 +257,9 @@ app.get( "/relationships", tokenRequired, scopeRequired(["read:follows"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const ids = (c.req.queries("id[]") ?? []).filter(isUuid); const accountList = ids.length > 0 @@ -315,7 +301,6 @@ app.get( }), ), async (c) => { - const owner = c.get("token")?.accountOwner; const query = c.req.valid("query"); const acct = query.acct; let account: @@ -338,15 +323,7 @@ app.get( return c.json({ error: "Record not found" }, 404); } const fedCtx = federation.createContext(c.req.raw, undefined); - const options = - owner == null - ? fedCtx - : { - contextLoader: fedCtx.contextLoader, - documentLoader: await fedCtx.getDocumentLoader({ - username: owner.handle, - }), - }; + const options = fedCtx; const actor = await lookupObject(acct, options); if (!isActor(actor)) return c.json({ error: "Record not found" }, 404); const loaded = await persistAccount(db, actor, c.req.url, options); @@ -448,14 +425,9 @@ app.get( "/familiar_followers", tokenRequired, scopeRequired(["read:follows"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const ids: Uuid[] = (c.req.queries("id[]") ?? []).filter(isUuid); const result: { id: string; @@ -514,6 +486,7 @@ app.get( "/:id/statuses", tokenRequired, scopeRequired(["read:statuses"]), + withAccountOwner, zValidator( "query", timelineQuerySchema.extend({ @@ -527,13 +500,7 @@ app.get( async (c) => { const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); - const tokenOwner = c.get("token").accountOwner; - if (tokenOwner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const tokenOwner = c.get("accountOwner"); const account = await db.query.accounts.findFirst({ where: eq(accounts.id, id), with: { @@ -701,16 +668,11 @@ app.post( "/:id/follow", tokenRequired, scopeRequired(["write:follows"]), + withAccountOwner, async (c) => { const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const following = await db.query.accounts.findFirst({ where: eq(accounts.id, id), with: { owner: true }, @@ -755,16 +717,11 @@ app.post( "/:id/unfollow", tokenRequired, scopeRequired(["write:follows"]), + withAccountOwner, async (c) => { const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const following = await db.query.accounts.findFirst({ where: eq(accounts.id, id), with: { owner: true }, @@ -841,16 +798,11 @@ app.get( "/:id/lists", tokenRequired, scopeRequired(["read:lists"]), + withAccountOwner, async (c) => { const accountId = c.req.param("id"); if (!isUuid(accountId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const listList = await db.query.lists.findMany({ where: and( eq(lists.accountOwnerId, owner.id), @@ -871,6 +823,7 @@ app.post( "/:id/mute", tokenRequired, scopeRequired(["write:mutes"]), + withAccountOwner, zValidator( "json", z.object({ @@ -881,13 +834,7 @@ app.post( async (c) => { const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const { notifications, duration } = c.req.valid("json"); const account = await db.query.accounts.findFirst({ where: eq(accounts.id, id), @@ -950,16 +897,11 @@ app.post( "/:id/unmute", tokenRequired, scopeRequired(["write:mutes"]), + withAccountOwner, async (c) => { const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); await db .delete(mutes) .where(and(eq(mutes.accountId, owner.id), eq(mutes.mutedAccountId, id))); @@ -992,16 +934,11 @@ app.post( "/:id/block", tokenRequired, scopeRequired(["read:blocks"]), + withAccountOwner, async (c) => { const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const acct = await db.query.accounts.findFirst({ where: eq(accounts.id, id), with: { owner: true }, @@ -1038,16 +975,11 @@ app.post( "/:id/unblock", tokenRequired, scopeRequired(["read:blocks"]), + withAccountOwner, async (c) => { const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const acct = await db.query.accounts.findFirst({ where: eq(accounts.id, id), with: { owner: true }, diff --git a/src/api/v1/apps.ts b/src/api/v1/apps.ts index 62360319..5aef64ba 100644 --- a/src/api/v1/apps.ts +++ b/src/api/v1/apps.ts @@ -1,4 +1,5 @@ import { getLogger } from "@logtape/logtape"; +import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; @@ -113,14 +114,17 @@ app.post("/", async (c) => { app.get("/verify_credentials", tokenRequired, async (c) => { const token = c.get("token"); - const app = token.application; + const application = await db.query.applications.findFirst({ + where: eq(applications.id, token.applicationId), + }); + if (application == null) return c.json({ error: "invalid_token" }, 401); return c.json({ - id: app.id, - name: app.name, - website: app.website, - scopes: app.scopes, - redirect_uris: app.redirectUris, - redirect_uri: app.redirectUris.join(" "), + id: application.id, + name: application.name, + website: application.website, + scopes: application.scopes, + redirect_uris: application.redirectUris, + redirect_uri: application.redirectUris.join(" "), }); }); diff --git a/src/api/v1/featured_tags.ts b/src/api/v1/featured_tags.ts index 5c5f140e..9701ea34 100644 --- a/src/api/v1/featured_tags.ts +++ b/src/api/v1/featured_tags.ts @@ -17,38 +17,40 @@ import { serializeFeaturedTag } from "../../entities/tag"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import type * as schema from "../../schema"; import { featuredTags, posts } from "../../schema"; import { isUuid, type Uuid, uuidv7 } from "../../uuid"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); -app.get("/", tokenRequired, scopeRequired(["read:accounts"]), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid." }, 401); - } - const tags = await db.query.featuredTags.findMany({ - where: eq(featuredTags.accountOwnerId, owner.id), - }); - const stats = await getFeaturedTagStats(db, owner.id); - return c.json( - tags.map((tag) => serializeFeaturedTag(tag, stats[tag.name], c.req.url)), - ); -}); +app.get( + "/", + tokenRequired, + scopeRequired(["read:accounts"]), + withAccountOwner, + async (c) => { + const owner = c.get("accountOwner"); + const tags = await db.query.featuredTags.findMany({ + where: eq(featuredTags.accountOwnerId, owner.id), + }); + const stats = await getFeaturedTagStats(db, owner.id); + return c.json( + tags.map((tag) => serializeFeaturedTag(tag, stats[tag.name], c.req.url)), + ); + }, +); app.post( "/", tokenRequired, scopeRequired(["write:accounts"]), + withAccountOwner, zValidator("json", z.object({ name: z.string().trim().min(1) })), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid." }, 401); - } + const owner = c.get("accountOwner"); let name = c.req.valid("json").name; if (name.startsWith("#")) name = name.substring(1); const result = await db @@ -69,15 +71,13 @@ app.delete( "/:id", tokenRequired, scopeRequired(["write:accounts"]), + withAccountOwner, async (c) => { const featuredTagId = c.req.param("id"); if (!isUuid(featuredTagId)) { return c.json({ error: "Record not found" }, 404); } - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid." }, 401); - } + const owner = c.get("accountOwner"); const result = await db .delete(featuredTags) .where( diff --git a/src/api/v1/follow_requests.ts b/src/api/v1/follow_requests.ts index 22e5946c..4fc6f162 100644 --- a/src/api/v1/follow_requests.ts +++ b/src/api/v1/follow_requests.ts @@ -16,48 +16,47 @@ import { import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { accounts, blocks, follows, mutes } from "../../schema"; import { isUuid } from "../../uuid"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); -app.get("/", tokenRequired, scopeRequired(["read:follows"]), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "This method requires an authenticated user" }, 422); - } - const followers = await db.query.follows.findMany({ - where: and(eq(follows.followingId, owner.id), isNull(follows.approved)), - with: { follower: { with: { owner: true, successor: true } } }, - }); - return c.json( - followers.map((f) => - f.follower.owner == null - ? serializeAccount(f.follower, c.req.url) - : serializeAccountOwner( - { ...f.follower.owner, account: f.follower }, - c.req.url, - ), - ), - ); -}); +app.get( + "/", + tokenRequired, + scopeRequired(["read:follows"]), + withAccountOwner, + async (c) => { + const owner = c.get("accountOwner"); + const followers = await db.query.follows.findMany({ + where: and(eq(follows.followingId, owner.id), isNull(follows.approved)), + with: { follower: { with: { owner: true, successor: true } } }, + }); + return c.json( + followers.map((f) => + f.follower.owner == null + ? serializeAccount(f.follower, c.req.url) + : serializeAccountOwner( + { ...f.follower.owner, account: f.follower }, + c.req.url, + ), + ), + ); + }, +); app.post( "/:account_id/authorize", tokenRequired, scopeRequired(["write:follows"]), + withAccountOwner, async (c) => { const followerId = c.req.param("account_id"); if (!isUuid(followerId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const follower = await db.query.accounts.findFirst({ where: eq(accounts.id, followerId), with: { owner: true }, @@ -126,16 +125,11 @@ app.post( "/:account_id/reject", tokenRequired, scopeRequired(["write:follows"]), + withAccountOwner, async (c) => { const followerId = c.req.param("account_id"); if (!isUuid(followerId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const follower = await db.query.accounts.findFirst({ where: eq(accounts.id, followerId), with: { owner: true }, diff --git a/src/api/v1/index.ts b/src/api/v1/index.ts index 87045bc9..4326d125 100644 --- a/src/api/v1/index.ts +++ b/src/api/v1/index.ts @@ -11,7 +11,8 @@ import { serializeTag } from "../../entities/tag"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { accounts as accountsTable, @@ -36,7 +37,7 @@ import statuses from "./statuses"; import tags from "./tags"; import timelines from "./timelines"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); app.route("/apps", apps); app.route("/accounts", accounts); @@ -57,14 +58,9 @@ app.get( "/preferences", tokenRequired, scopeRequired(["read:accounts"]), + withAccountOwner, (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); return c.json({ "posting:default:visibility": owner.visibility, "posting:default:sensitive": owner.account.sensitive, @@ -123,6 +119,7 @@ app.get( "/favourites", tokenRequired, scopeRequired(["read:favourites"]), + withAccountOwner, zValidator( "query", z.object({ @@ -134,13 +131,7 @@ app.get( }), ), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const query = c.req.valid("query"); const favourites = await db.query.likes.findMany({ where: and( @@ -178,6 +169,7 @@ app.get( "/bookmarks", tokenRequired, scopeRequired(["read:bookmarks"]), + withAccountOwner, zValidator( "query", z.object({ @@ -189,13 +181,7 @@ app.get( }), ), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const query = c.req.valid("query"); const bookmarkList = await db.query.bookmarks.findMany({ where: and( @@ -233,14 +219,9 @@ app.get( "/followed_tags", tokenRequired, scopeRequired(["read:follows"]), + withAccountOwner, (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); return c.json( owner.followedTags.map((tag) => serializeTag(tag, owner, c.req.url)), ); @@ -251,6 +232,7 @@ app.get( "/mutes", tokenRequired, scopeRequired(["read:mutes"]), + withAccountOwner, zValidator( "query", z.object({ @@ -266,13 +248,7 @@ app.get( }), ), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const muteList = await db.query.mutes.findMany({ where: eq(mutes.accountId, owner.id), @@ -306,6 +282,7 @@ app.get( "/blocks", tokenRequired, scopeRequired(["read:blocks"]), + withAccountOwner, zValidator( "query", z.object({ @@ -320,13 +297,7 @@ app.get( }), ), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const query = c.req.valid("query"); const blockList = await db.query.blocks.findMany({ diff --git a/src/api/v1/lists.ts b/src/api/v1/lists.ts index b85a59c1..33defc0f 100644 --- a/src/api/v1/lists.ts +++ b/src/api/v1/lists.ts @@ -9,24 +9,28 @@ import { serializeList } from "../../entities/list"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { listMembers, lists } from "../../schema"; import { isUuid, uuid, uuidv7 } from "../../uuid"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); -app.get("/", tokenRequired, scopeRequired(["read:lists"]), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid" }, 401); - } - const listList = await db.query.lists.findMany({ - where: eq(lists.accountOwnerId, owner.id), - orderBy: lists.id, - }); - return c.json(listList.map(serializeList)); -}); +app.get( + "/", + tokenRequired, + scopeRequired(["read:lists"]), + withAccountOwner, + async (c) => { + const owner = c.get("accountOwner"); + const listList = await db.query.lists.findMany({ + where: eq(lists.accountOwnerId, owner.id), + orderBy: lists.id, + }); + return c.json(listList.map(serializeList)); + }, +); const listSchema = z.object({ title: z.string().trim().min(1), @@ -38,12 +42,10 @@ app.post( "/", tokenRequired, scopeRequired(["write:lists"]), + withAccountOwner, zValidator("json", listSchema), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid" }, 401); - } + const owner = c.get("accountOwner"); const input = c.req.valid("json"); const result = await db .insert(lists) @@ -59,32 +61,33 @@ app.post( }, ); -app.get("/:id", tokenRequired, scopeRequired(["read:lists"]), async (c) => { - const listId = c.req.param("id"); - if (!isUuid(listId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid" }, 401); - } - const list = await db.query.lists.findFirst({ - where: and(eq(lists.accountOwnerId, owner.id), eq(lists.id, listId)), - }); - if (list == null) return c.json({ error: "Record not found" }, 404); - return c.json(serializeList(list)); -}); +app.get( + "/:id", + tokenRequired, + scopeRequired(["read:lists"]), + withAccountOwner, + async (c) => { + const listId = c.req.param("id"); + if (!isUuid(listId)) return c.json({ error: "Record not found" }, 404); + const owner = c.get("accountOwner"); + const list = await db.query.lists.findFirst({ + where: and(eq(lists.accountOwnerId, owner.id), eq(lists.id, listId)), + }); + if (list == null) return c.json({ error: "Record not found" }, 404); + return c.json(serializeList(list)); + }, +); app.put( "/:id", tokenRequired, scopeRequired(["write:lists"]), + withAccountOwner, zValidator("json", listSchema), async (c) => { const listId = c.req.param("id"); if (!isUuid(listId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid" }, 401); - } + const owner = c.get("accountOwner"); const input = c.req.valid("json"); const result = await db .update(lists) @@ -100,32 +103,33 @@ app.put( }, ); -app.delete("/:id", tokenRequired, scopeRequired(["write:lists"]), async (c) => { - const listId = c.req.param("id"); - if (!isUuid(listId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid" }, 401); - } - const result = await db - .delete(lists) - .where(and(eq(lists.accountOwnerId, owner.id), eq(lists.id, listId))) - .returning(); - if (result.length < 1) return c.json({ error: "Record not found" }, 404); - return c.json({}); -}); +app.delete( + "/:id", + tokenRequired, + scopeRequired(["write:lists"]), + withAccountOwner, + async (c) => { + const listId = c.req.param("id"); + if (!isUuid(listId)) return c.json({ error: "Record not found" }, 404); + const owner = c.get("accountOwner"); + const result = await db + .delete(lists) + .where(and(eq(lists.accountOwnerId, owner.id), eq(lists.id, listId))) + .returning(); + if (result.length < 1) return c.json({ error: "Record not found" }, 404); + return c.json({}); + }, +); app.get( "/:id/accounts", tokenRequired, scopeRequired(["read:lists"]), + withAccountOwner, async (c) => { const listId = c.req.param("id"); if (!isUuid(listId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid" }, 401); - } + const owner = c.get("accountOwner"); const list = await db.query.lists.findFirst({ where: and(eq(lists.accountOwnerId, owner.id), eq(lists.id, listId)), }); @@ -148,14 +152,12 @@ app.post( "/:id/accounts", tokenRequired, scopeRequired(["write:lists"]), + withAccountOwner, zValidator("json", membersSchema), async (c) => { const listId = c.req.param("id"); if (!isUuid(listId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid" }, 401); - } + const owner = c.get("accountOwner"); const list = await db.query.lists.findFirst({ where: and(eq(lists.accountOwnerId, owner.id), eq(lists.id, listId)), }); @@ -172,14 +174,12 @@ app.delete( "/:id/accounts", tokenRequired, scopeRequired(["write:lists"]), + withAccountOwner, zValidator("json", membersSchema), async (c) => { const listId = c.req.param("id"); if (!isUuid(listId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid" }, 401); - } + const owner = c.get("accountOwner"); const list = await db.query.lists.findFirst({ where: and(eq(lists.accountOwnerId, owner.id), eq(lists.id, listId)), }); diff --git a/src/api/v1/markers.ts b/src/api/v1/markers.ts index 2dc9239b..8e3ffa03 100644 --- a/src/api/v1/markers.ts +++ b/src/api/v1/markers.ts @@ -8,27 +8,32 @@ import { serializeMarkers } from "../../entities/marker"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { type MarkerType, markers, type NewMarker } from "../../schema"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); -app.get("/", tokenRequired, scopeRequired(["read:statuses"]), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "This method requires an authenticated user" }, 422); - } - const markerList = await db.query.markers.findMany({ - where: eq(markers.accountOwnerId, owner.id), - }); - return c.json(serializeMarkers(markerList)); -}); +app.get( + "/", + tokenRequired, + scopeRequired(["read:statuses"]), + withAccountOwner, + async (c) => { + const owner = c.get("accountOwner"); + const markerList = await db.query.markers.findMany({ + where: eq(markers.accountOwnerId, owner.id), + }); + return c.json(serializeMarkers(markerList)); + }, +); app.post( "/", tokenRequired, scopeRequired(["write:statuses"]), + withAccountOwner, zValidator( "json", z.partialRecord( @@ -39,13 +44,7 @@ app.post( ), ), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const payload = c.req.valid("json"); await db.transaction(async (tx) => { for (const key in payload) { diff --git a/src/api/v1/media.ts b/src/api/v1/media.ts index 49f92d83..059520ad 100644 --- a/src/api/v1/media.ts +++ b/src/api/v1/media.ts @@ -8,23 +8,22 @@ import { makeVideoScreenshot, uploadThumbnail } from "../../media"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { media } from "../../schema"; import { isUuid, uuidv7 } from "../../uuid"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); -export async function postMedia(c: Context<{ Variables: Variables }>) { +export async function postMedia( + c: Context<{ Variables: AccountOwnerVariables }>, +) { const [{ drive }, { default: sharp }] = await Promise.all([ import("../../storage"), import("sharp"), ]); const disk = drive.use(); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "This method requires an authenticated user" }, 422); - } const form = await c.req.formData(); const file = form.get("file"); if (!(file instanceof File)) { @@ -79,7 +78,13 @@ export async function postMedia(c: Context<{ Variables: Variables }>) { return c.json(serializeMedium(result[0])); } -app.post("/", tokenRequired, scopeRequired(["write:media"]), postMedia); +app.post( + "/", + tokenRequired, + scopeRequired(["write:media"]), + withAccountOwner, + postMedia, +); app.get("/:id", async (c) => { const mediumId = c.req.param("id"); diff --git a/src/api/v1/notifications.ts b/src/api/v1/notifications.ts index 5483b59c..ca626059 100644 --- a/src/api/v1/notifications.ts +++ b/src/api/v1/notifications.ts @@ -12,7 +12,8 @@ import { getPostRelations, serializePost } from "../../entities/status"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { type NotificationType, @@ -66,7 +67,7 @@ function parseNotificationId(compositeId: string): ParsedNotificationId { return { uuid: compositeId as Uuid, timestamp: null }; } -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); // set for O(1) access to all possible types const notificationTypeSet = new Set(notificationTypeEnum.enumValues); @@ -78,14 +79,9 @@ app.get( "/", tokenRequired, scopeRequired(["read:notifications"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); let types = c.req.queries("types[]") as NotificationType[]; const excludeTypes = c.req.queries("exclude_types[]") as NotificationType[]; const olderThanStr = c.req.query("older_than"); diff --git a/src/api/v1/polls.ts b/src/api/v1/polls.ts index 75107623..d55cf383 100644 --- a/src/api/v1/polls.ts +++ b/src/api/v1/polls.ts @@ -12,33 +12,40 @@ import { toUpdate } from "../../federation/post"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { pollOptions, polls, pollVotes } from "../../schema"; import { isUuid } from "../../uuid"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); -app.get("/:id", tokenRequired, scopeRequired(["read:statuses"]), async (c) => { - const pollId = c.req.param("id"); - if (!isUuid(pollId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) return c.json({ error: "Unauthorized" }, 401); - const poll = await db.query.polls.findFirst({ - with: { - options: { orderBy: pollOptions.index }, - votes: { where: eq(pollVotes.accountId, owner.id) }, - }, - where: eq(polls.id, pollId), - }); - if (poll == null) return c.json({ error: "Record not found" }, 404); - return c.json(serializePoll(poll, owner)); -}); +app.get( + "/:id", + tokenRequired, + scopeRequired(["read:statuses"]), + withAccountOwner, + async (c) => { + const pollId = c.req.param("id"); + if (!isUuid(pollId)) return c.json({ error: "Record not found" }, 404); + const owner = c.get("accountOwner"); + const poll = await db.query.polls.findFirst({ + with: { + options: { orderBy: pollOptions.index }, + votes: { where: eq(pollVotes.accountId, owner.id) }, + }, + where: eq(polls.id, pollId), + }); + if (poll == null) return c.json({ error: "Record not found" }, 404); + return c.json(serializePoll(poll, owner)); + }, +); app.post( "/:id/votes", tokenRequired, scopeRequired(["write:statuses"]), + withAccountOwner, zValidator( "json", z.object({ @@ -56,10 +63,7 @@ app.post( async (c) => { const pollId = c.req.param("id"); if (!isUuid(pollId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "The access token is invalid" }, 401); - } + const owner = c.get("accountOwner"); const choices = c.req.valid("json").choices; let poll = await db.query.polls.findFirst({ with: { diff --git a/src/api/v1/reports.ts b/src/api/v1/reports.ts index bc5d2dd5..5973edf7 100644 --- a/src/api/v1/reports.ts +++ b/src/api/v1/reports.ts @@ -10,7 +10,8 @@ import federation from "../../federation"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { accountOwners, @@ -22,7 +23,7 @@ import { } from "../../schema"; import { uuid, uuidv7 } from "../../uuid"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); const reportSchema = z.object({ comment: z.string().trim().min(1).max(1000).optional().default(""), @@ -39,15 +40,10 @@ app.post( "/", tokenRequired, scopeRequired(["write:reports"]), + withAccountOwner, zValidator("json", reportSchema), async (c) => { - const accountOwner = c.get("token").accountOwner; - if (accountOwner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const accountOwner = c.get("accountOwner"); const data = c.req.valid("json"); diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 7215c31f..5e9d6710 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -49,7 +49,8 @@ import { getAccessToken } from "../../oauth/helpers"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { fetchPreviewCard, type PreviewCard } from "../../previewcard"; import { @@ -83,7 +84,7 @@ import { getPostVisibilityScope, } from "../visibility"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); const logger = getLogger(["hollo", "api", "v1", "statuses"]); const quoteApprovalPolicySchema = z.enum(["public", "followers", "nobody"]); @@ -289,373 +290,385 @@ const createStatusSchema = statusSchema.extend({ scheduled_at: z.iso.datetime().optional().nullable(), }); -app.post("/", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { - const token = c.get("token"); - const owner = token.accountOwner; - if (owner == null) { - return c.json({ error: "This method requires an authenticated user" }, 422); - } - const idempotencyKey = c.req.header("Idempotency-Key"); - if (idempotencyKey != null) { - const post = await db.query.posts.findFirst({ - where: and( - eq(posts.accountId, owner.id), - eq(posts.idempotenceKey, idempotencyKey), - gt(posts.published, sql`CURRENT_TIMESTAMP - INTERVAL '1 hour'`), - ), - with: getPostRelations(owner.id), - }); - if (post != null) return c.json(serializePost(post, owner, c.req.url)); - } +app.post( + "/", + tokenRequired, + scopeRequired(["write:statuses"]), + withAccountOwner, + async (c) => { + const token = c.get("token"); + const owner = c.get("accountOwner"); + const idempotencyKey = c.req.header("Idempotency-Key"); + if (idempotencyKey != null) { + const post = await db.query.posts.findFirst({ + where: and( + eq(posts.accountId, owner.id), + eq(posts.idempotenceKey, idempotencyKey), + gt(posts.published, sql`CURRENT_TIMESTAMP - INTERVAL '1 hour'`), + ), + with: getPostRelations(owner.id), + }); + if (post != null) return c.json(serializePost(post, owner, c.req.url)); + } - const fedCtx = federation.createContext(c.req.raw, undefined); - const fmtOpts = { - url: fedCtx.url, - contextLoader: fedCtx.contextLoader, - documentLoader: await fedCtx.getDocumentLoader({ - username: owner.handle, - }), - }; + const fedCtx = federation.createContext(c.req.raw, undefined); + const fmtOpts = { + url: fedCtx.url, + contextLoader: fedCtx.contextLoader, + documentLoader: await fedCtx.getDocumentLoader({ + username: owner.handle, + }), + }; - const result = await requestBody(c.req, createStatusSchema); + const result = await requestBody(c.req, createStatusSchema); - if (!result.success) { - logger.debug("Invalid request: {error}", { error: result.error.issues }); - return c.json({ error: "invalid_request", zod_error: result.error }, 422); - } + if (!result.success) { + logger.debug("Invalid request: {error}", { error: result.error.issues }); + return c.json({ error: "invalid_request", zod_error: result.error }, 422); + } - const data = result.data; - - const handle = owner.handle; - const id = uuidv7(); - const url = fedCtx.getObjectUri(Note, { username: handle, id }); - const { formatPostContent } = await import("../../text"); - const content = - data.status == null - ? null - : await formatPostContent(db, data.status, data.language, fmtOpts); - const summary = - data.spoiler_text == null || data.spoiler_text.trim() === "" - ? null - : data.spoiler_text; - const mentionedIds = content?.mentions ?? []; - const hashtags = content?.hashtags ?? []; - const emojis = content?.emojis ?? {}; - const tags = Object.fromEntries( - hashtags.map((tag) => [ - tag.toLowerCase(), - new URL(`/tags/${encodeURIComponent(tag.substring(1))}`, c.req.url).href, - ]), - ); - let previewCard: PreviewCard | null = null; - if (content?.previewLink != null) { - previewCard = await fetchPreviewCard(content.previewLink); - } - let quoteTargetId: Uuid | null = null; - let quoteTarget: typeof posts.$inferSelect | null = null; - if (data.quoted_status_id != null) quoteTargetId = data.quoted_status_id; - else if (data.quote_id != null) quoteTargetId = data.quote_id; - else if (content?.quoteTarget != null) { - const quoted = await persistPost( - db, - content.quoteTarget, - c.req.url, - fmtOpts, - ); - if (quoted != null) quoteTargetId = quoted.id; - } - let effectiveVisibility = data.visibility ?? owner.visibility; - if (quoteTargetId != null) { - const validation = await validateQuoteTarget( - quoteTargetId, - owner, - mentionedIds, - effectiveVisibility, + const data = result.data; + + const handle = owner.handle; + const id = uuidv7(); + const url = fedCtx.getObjectUri(Note, { username: handle, id }); + const { formatPostContent } = await import("../../text"); + const content = + data.status == null + ? null + : await formatPostContent(db, data.status, data.language, fmtOpts); + const summary = + data.spoiler_text == null || data.spoiler_text.trim() === "" + ? null + : data.spoiler_text; + const mentionedIds = content?.mentions ?? []; + const hashtags = content?.hashtags ?? []; + const emojis = content?.emojis ?? {}; + const tags = Object.fromEntries( + hashtags.map((tag) => [ + tag.toLowerCase(), + new URL(`/tags/${encodeURIComponent(tag.substring(1))}`, c.req.url) + .href, + ]), ); - if (!validation.ok) { - return c.json({ error: validation.error }, validation.status); + let previewCard: PreviewCard | null = null; + if (content?.previewLink != null) { + previewCard = await fetchPreviewCard(content.previewLink); } - quoteTarget = validation.quoteTarget; - effectiveVisibility = validation.visibility; - } - const quoteApprovalPolicy = normalizeQuoteApprovalPolicy( - data.quote_approval_policy, - ); - let quoteState: "accepted" | "pending" | null = null; - if (quoteTarget != null) { - const localQuoteTargetOwner = - quoteTarget.accountId === owner.id - ? owner - : await db.query.accountOwners.findFirst({ - where: eq(accountOwners.id, quoteTarget.accountId), - }); - quoteState = - localQuoteTargetOwner == null && quoteTarget.quoteApprovalPolicy != null - ? "pending" - : "accepted"; - } - await db.transaction(async (tx) => { - let poll: Poll | null = null; - if (data.poll != null) { - const expires = new Date(Date.now() + data.poll.expires_in * 1000); - [poll] = await tx - .insert(polls) - .values({ - id: uuidv7(), - multiple: data.poll.multiple, - expires, - }) - .returning(); - await tx.insert(pollOptions).values( - data.poll.options.map( - (title, index) => - ({ - pollId: poll!.id, - index, - title, - }) satisfies NewPollOption, - ), + let quoteTargetId: Uuid | null = null; + let quoteTarget: typeof posts.$inferSelect | null = null; + if (data.quoted_status_id != null) quoteTargetId = data.quoted_status_id; + else if (data.quote_id != null) quoteTargetId = data.quote_id; + else if (content?.quoteTarget != null) { + const quoted = await persistPost( + db, + content.quoteTarget, + c.req.url, + fmtOpts, ); + if (quoted != null) quoteTargetId = quoted.id; } - const insertedRows = await tx - .insert(posts) - .values({ - id, - iri: url.href, - type: poll == null ? "Note" : "Question", - accountId: owner.id, - applicationId: token.applicationId, - replyTargetId: data.in_reply_to_id, + let effectiveVisibility = data.visibility ?? owner.visibility; + if (quoteTargetId != null) { + const validation = await validateQuoteTarget( quoteTargetId, - quoteTargetIri: quoteTarget?.iri ?? null, - quoteState, - quoteApprovalPolicy, - sharingId: null, - visibility: effectiveVisibility, - summary, - content: data.status, - contentHtml: content?.html, - language: data.language ?? owner.language, - pollId: poll == null ? null : poll.id, - tags, - emojis, - sensitive: data.sensitive, - url: url.href, - previewCard, - idempotenceKey: idempotencyKey, - published: sql`CURRENT_TIMESTAMP`, - }) - .returning(); - if (data.media_ids != null && data.media_ids.length > 0) { - for (const mediaId of data.media_ids) { - const result = await tx - .update(media) - .set({ postId: id }) - .where(and(eq(media.id, mediaId), isNull(media.postId))) + owner, + mentionedIds, + effectiveVisibility, + ); + if (!validation.ok) { + return c.json({ error: validation.error }, validation.status); + } + quoteTarget = validation.quoteTarget; + effectiveVisibility = validation.visibility; + } + const quoteApprovalPolicy = normalizeQuoteApprovalPolicy( + data.quote_approval_policy, + ); + let quoteState: "accepted" | "pending" | null = null; + if (quoteTarget != null) { + const localQuoteTargetOwner = + quoteTarget.accountId === owner.id + ? owner + : await db.query.accountOwners.findFirst({ + where: eq(accountOwners.id, quoteTarget.accountId), + }); + quoteState = + localQuoteTargetOwner == null && quoteTarget.quoteApprovalPolicy != null + ? "pending" + : "accepted"; + } + await db.transaction(async (tx) => { + let poll: Poll | null = null; + if (data.poll != null) { + const expires = new Date(Date.now() + data.poll.expires_in * 1000); + [poll] = await tx + .insert(polls) + .values({ + id: uuidv7(), + multiple: data.poll.multiple, + expires, + }) .returning(); - if (result.length < 1) { - tx.rollback(); - return c.json({ error: "Media not found" }, 422); + await tx.insert(pollOptions).values( + data.poll.options.map( + (title, index) => + ({ + pollId: poll!.id, + index, + title, + }) satisfies NewPollOption, + ), + ); + } + const insertedRows = await tx + .insert(posts) + .values({ + id, + iri: url.href, + type: poll == null ? "Note" : "Question", + accountId: owner.id, + applicationId: token.applicationId, + replyTargetId: data.in_reply_to_id, + quoteTargetId, + quoteTargetIri: quoteTarget?.iri ?? null, + quoteState, + quoteApprovalPolicy, + sharingId: null, + visibility: effectiveVisibility, + summary, + content: data.status, + contentHtml: content?.html, + language: data.language ?? owner.language, + pollId: poll == null ? null : poll.id, + tags, + emojis, + sensitive: data.sensitive, + url: url.href, + previewCard, + idempotenceKey: idempotencyKey, + published: sql`CURRENT_TIMESTAMP`, + }) + .returning(); + if (data.media_ids != null && data.media_ids.length > 0) { + for (const mediaId of data.media_ids) { + const result = await tx + .update(media) + .set({ postId: id }) + .where(and(eq(media.id, mediaId), isNull(media.postId))) + .returning(); + if (result.length < 1) { + tx.rollback(); + return c.json({ error: "Media not found" }, 422); + } } } + let mentionObjects: Mention[] = []; + if (mentionedIds.length > 0) { + mentionObjects = await tx + .insert(mentions) + .values( + mentionedIds.map((accountId) => ({ + postId: id, + accountId, + })), + ) + .returning(); + } + if ( + quoteTargetId != null && + (quoteState == null || quoteState === "accepted") + ) { + await tx + .update(posts) + .set({ quotesCount: sql`coalesce(${posts.quotesCount}, 0) + 1` }) + .where(eq(posts.id, quoteTargetId)); + } + await updateAccountStats(tx, owner); + await appendPostToTimelines(tx, { + ...insertedRows[0], + sharing: null, + mentions: mentionObjects, + replyTarget: + insertedRows[0].replyTargetId == null + ? null + : ((await db.query.posts.findFirst({ + where: eq(posts.id, insertedRows[0].replyTargetId), + })) ?? null), + }); + }); + const post = (await db.query.posts.findFirst({ + where: eq(posts.id, id), + with: getPostRelations(owner.id), + }))!; + const activity = toCreate(post, fedCtx); + const orderingKey = getPostOrderingKey(post.iri); + await fedCtx.sendActivity( + { username: handle }, + getRecipients(post), + activity, + { + orderingKey, + excludeBaseUris: [new URL(c.req.url)], + }, + ); + if (post.visibility !== "direct") { + await fedCtx.sendActivity({ username: handle }, "followers", activity, { + orderingKey, + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }); + } + if (post.quoteState === "pending" && post.quoteTarget != null) { + await fedCtx.sendActivity( + { username: handle }, + { + id: new URL(post.quoteTarget.account.iri), + inboxId: new URL(post.quoteTarget.account.inboxUrl), + endpoints: + post.quoteTarget.account.sharedInboxUrl == null + ? null + : { + sharedInbox: new URL(post.quoteTarget.account.sharedInboxUrl), + }, + }, + new vocab.QuoteRequest({ + id: new URL("#quote-request", post.iri), + actor: new URL(owner.account.iri), + object: new URL(post.quoteTarget.iri), + instrument: toObject(post, fedCtx, { + includeInactiveQuoteTarget: true, + }), + }), + { + orderingKey, + preferSharedInbox: true, + excludeBaseUris: [new URL(c.req.url)], + }, + ); } - let mentionObjects: Mention[] = []; - if (mentionedIds.length > 0) { - mentionObjects = await tx - .insert(mentions) - .values( + return c.json(serializePost(post, owner, c.req.url)); + }, +); + +app.put( + "/:id", + tokenRequired, + scopeRequired(["write:statuses"]), + withAccountOwner, + async (c) => { + const owner = c.get("accountOwner"); + + const id = c.req.param("id"); + if (!isUuid(id)) { + return c.json({ error: "Record not found" }, 404); + } + + const result = await requestBody(c.req, statusSchema); + + if (!result.success) { + logger.debug("Invalid request: {error}", { error: result.error.issues }); + return c.json({ error: "invalid_request", zod_error: result.error }, 422); + } + + const data = result.data; + + const fedCtx = federation.createContext(c.req.raw, undefined); + const fmtOpts = { + url: fedCtx.url, + contextLoader: fedCtx.contextLoader, + documentLoader: await fedCtx.getDocumentLoader({ + username: owner.handle, + }), + }; + const { formatPostContent } = await import("../../text"); + const content = + data.status == null + ? null + : await formatPostContent(db, data.status, data.language, fmtOpts); + const summary = + data.spoiler_text == null || data.spoiler_text.trim() === "" + ? null + : data.spoiler_text; + const hashtags = content?.hashtags ?? []; + const tags = Object.fromEntries( + hashtags.map((tag) => [ + tag.toLowerCase(), + new URL(`/tags/${encodeURIComponent(tag.substring(1))}`, c.req.url) + .href, + ]), + ); + const emojis = content?.emojis ?? {}; + let previewCard: PreviewCard | null = null; + if (content?.previewLink != null) { + previewCard = await fetchPreviewCard(content.previewLink); + } + const existingPost = await db.query.posts.findFirst({ + where: and(eq(posts.id, id), eq(posts.accountId, owner.id)), + }); + if (existingPost == null) { + return c.json({ error: "Record not found" }, 404); + } + const quoteApprovalPolicy = normalizeQuoteApprovalPolicy( + data.quote_approval_policy ?? existingPost.quoteApprovalPolicy, + ); + await db.transaction(async (tx) => { + const result = await tx + .update(posts) + .set({ + content: data.status, + contentHtml: content?.html, + sensitive: data.sensitive, + summary, + language: data.language ?? owner.language, + tags, + emojis, + previewCard, + quoteApprovalPolicy, + updated: new Date(), + }) + .where(and(eq(posts.id, id), eq(posts.accountId, owner.id))) + .returning(); + if (result.length < 1) return c.json({ error: "Record not found" }, 404); + await tx.delete(mentions).where(eq(mentions.postId, id)); + const mentionedIds = content?.mentions ?? []; + if (mentionedIds.length > 0) { + await tx.insert(mentions).values( mentionedIds.map((accountId) => ({ postId: id, accountId, })), - ) - .returning(); - } - if ( - quoteTargetId != null && - (quoteState == null || quoteState === "accepted") - ) { - await tx - .update(posts) - .set({ quotesCount: sql`coalesce(${posts.quotesCount}, 0) + 1` }) - .where(eq(posts.id, quoteTargetId)); - } - await updateAccountStats(tx, owner); - await appendPostToTimelines(tx, { - ...insertedRows[0], - sharing: null, - mentions: mentionObjects, - replyTarget: - insertedRows[0].replyTargetId == null - ? null - : ((await db.query.posts.findFirst({ - where: eq(posts.id, insertedRows[0].replyTargetId), - })) ?? null), + ); + } }); - }); - const post = (await db.query.posts.findFirst({ - where: eq(posts.id, id), - with: getPostRelations(owner.id), - }))!; - const activity = toCreate(post, fedCtx); - const orderingKey = getPostOrderingKey(post.iri); - await fedCtx.sendActivity( - { username: handle }, - getRecipients(post), - activity, - { - orderingKey, - excludeBaseUris: [new URL(c.req.url)], - }, - ); - if (post.visibility !== "direct") { - await fedCtx.sendActivity({ username: handle }, "followers", activity, { - orderingKey, - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], + const post = await db.query.posts.findFirst({ + where: eq(posts.id, id), + with: getPostRelations(owner.id), }); - } - if (post.quoteState === "pending" && post.quoteTarget != null) { + const activity = toUpdate(post!, fedCtx); + const orderingKey = getPostOrderingKey(post!.iri); await fedCtx.sendActivity( - { username: handle }, + { username: owner.handle }, + getRecipients(post!), + activity, { - id: new URL(post.quoteTarget.account.iri), - inboxId: new URL(post.quoteTarget.account.inboxUrl), - endpoints: - post.quoteTarget.account.sharedInboxUrl == null - ? null - : { - sharedInbox: new URL(post.quoteTarget.account.sharedInboxUrl), - }, + orderingKey, + excludeBaseUris: [new URL(c.req.url)], }, - new vocab.QuoteRequest({ - id: new URL("#quote-request", post.iri), - actor: new URL(owner.account.iri), - object: new URL(post.quoteTarget.iri), - instrument: toObject(post, fedCtx, { - includeInactiveQuoteTarget: true, - }), - }), + ); + await fedCtx.sendActivity( + { username: owner.handle }, + "followers", + activity, { orderingKey, preferSharedInbox: true, excludeBaseUris: [new URL(c.req.url)], }, ); - } - return c.json(serializePost(post, owner, c.req.url)); -}); - -app.put("/:id", tokenRequired, scopeRequired(["write:statuses"]), async (c) => { - const token = c.get("token"); - const owner = token.accountOwner; - if (owner == null) { - return c.json({ error: "This method requires an authenticated user" }, 422); - } - - const id = c.req.param("id"); - if (!isUuid(id)) { - return c.json({ error: "Record not found" }, 404); - } - - const result = await requestBody(c.req, statusSchema); - - if (!result.success) { - logger.debug("Invalid request: {error}", { error: result.error.issues }); - return c.json({ error: "invalid_request", zod_error: result.error }, 422); - } - - const data = result.data; - - const fedCtx = federation.createContext(c.req.raw, undefined); - const fmtOpts = { - url: fedCtx.url, - contextLoader: fedCtx.contextLoader, - documentLoader: await fedCtx.getDocumentLoader({ - username: owner.handle, - }), - }; - const { formatPostContent } = await import("../../text"); - const content = - data.status == null - ? null - : await formatPostContent(db, data.status, data.language, fmtOpts); - const summary = - data.spoiler_text == null || data.spoiler_text.trim() === "" - ? null - : data.spoiler_text; - const hashtags = content?.hashtags ?? []; - const tags = Object.fromEntries( - hashtags.map((tag) => [ - tag.toLowerCase(), - new URL(`/tags/${encodeURIComponent(tag.substring(1))}`, c.req.url).href, - ]), - ); - const emojis = content?.emojis ?? {}; - let previewCard: PreviewCard | null = null; - if (content?.previewLink != null) { - previewCard = await fetchPreviewCard(content.previewLink); - } - const existingPost = await db.query.posts.findFirst({ - where: and(eq(posts.id, id), eq(posts.accountId, owner.id)), - }); - if (existingPost == null) { - return c.json({ error: "Record not found" }, 404); - } - const quoteApprovalPolicy = normalizeQuoteApprovalPolicy( - data.quote_approval_policy ?? existingPost.quoteApprovalPolicy, - ); - await db.transaction(async (tx) => { - const result = await tx - .update(posts) - .set({ - content: data.status, - contentHtml: content?.html, - sensitive: data.sensitive, - summary, - language: data.language ?? owner.language, - tags, - emojis, - previewCard, - quoteApprovalPolicy, - updated: new Date(), - }) - .where(and(eq(posts.id, id), eq(posts.accountId, owner.id))) - .returning(); - if (result.length < 1) return c.json({ error: "Record not found" }, 404); - await tx.delete(mentions).where(eq(mentions.postId, id)); - const mentionedIds = content?.mentions ?? []; - if (mentionedIds.length > 0) { - await tx.insert(mentions).values( - mentionedIds.map((accountId) => ({ - postId: id, - accountId, - })), - ); - } - }); - const post = await db.query.posts.findFirst({ - where: eq(posts.id, id), - with: getPostRelations(owner.id), - }); - const activity = toUpdate(post!, fedCtx); - const orderingKey = getPostOrderingKey(post!.iri); - await fedCtx.sendActivity( - { username: owner.handle }, - getRecipients(post!), - activity, - { - orderingKey, - excludeBaseUris: [new URL(c.req.url)], - }, - ); - await fedCtx.sendActivity({ username: owner.handle }, "followers", activity, { - orderingKey, - preferSharedInbox: true, - excludeBaseUris: [new URL(c.req.url)], - }); - return c.json(serializePost(post!, owner, c.req.url)); -}); + return c.json(serializePost(post!, owner, c.req.url)); + }, +); const interactionPolicySchema = z.object({ quote_approval_policy: quoteApprovalPolicySchema, @@ -665,15 +678,9 @@ app.put( "/:id/interaction_policy", tokenRequired, scopeRequired(["write:statuses"]), + withAccountOwner, async (c) => { - const token = c.get("token"); - const owner = token.accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); @@ -757,14 +764,9 @@ app.delete( "/:id", tokenRequired, scopeRequired(["write:statuses"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); const post = await db.query.posts.findFirst({ @@ -896,14 +898,9 @@ app.post( "/:id/favourite", tokenRequired, scopeRequired(["write:favourites"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const postId = c.req.param("id"); if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); let like: Like; @@ -953,14 +950,9 @@ app.post( "/:id/unfavourite", tokenRequired, scopeRequired(["write:favourites"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const postId = c.req.param("id"); if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); const result = await db @@ -1009,14 +1001,8 @@ app.get( "/:id/favourited_by", tokenRequired, scopeRequired(["read:statuses"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); const likeList = await db.query.likes.findMany({ @@ -1044,15 +1030,10 @@ app.post( "/:id/reblog", tokenRequired, scopeRequired(["write:statuses"]), + withAccountOwner, async (c) => { const token = c.get("token"); - const owner = token.accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const originalPostId = c.req.param("id"); if (!isUuid(originalPostId)) { return c.json({ error: "Record not found" }, 404); @@ -1135,14 +1116,9 @@ app.post( "/:id/unreblog", tokenRequired, scopeRequired(["write:statuses"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const originalPostId = c.req.param("id"); if (!isUuid(originalPostId)) { return c.json({ error: "Record not found" }, 404); @@ -1200,14 +1176,8 @@ app.get( "/:id/reblogged_by", tokenRequired, scopeRequired(["read:statuses"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); const post = await db.query.posts.findFirst({ @@ -1243,14 +1213,9 @@ app.post( "/:id/bookmark", tokenRequired, scopeRequired(["write:bookmarks"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const postId = c.req.param("id"); if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); try { @@ -1273,14 +1238,9 @@ app.post( "/:id/unbookmark", tokenRequired, scopeRequired(["write:bookmarks"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const postId = c.req.param("id"); if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); const result = await db @@ -1307,14 +1267,9 @@ app.post( "/:id/pin", tokenRequired, scopeRequired(["write:accounts"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const postId = c.req.param("id"); if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); const post = await db.query.posts.findFirst({ @@ -1368,14 +1323,9 @@ app.post( "/:id/unpin", tokenRequired, scopeRequired(["write:accounts"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const postId = c.req.param("id"); if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); const result = await db @@ -1419,12 +1369,12 @@ app.post( ); async function addEmojiReaction( - c: Context<{ Variables: Variables }, "/:id/emoji_reactions/:emoji">, + c: Context< + { Variables: AccountOwnerVariables }, + "/:id/emoji_reactions/:emoji" + >, ): Promise { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "This method requires an authenticated user" }, 422); - } + const owner = c.get("accountOwner"); const fedCtx = federation.createContext(c.req.raw, undefined); const postId = c.req.param("id"); if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); @@ -1552,6 +1502,7 @@ app.put( "/:id/emoji_reactions/:emoji", tokenRequired, scopeRequired(["write:favourites"]), + withAccountOwner, addEmojiReaction, ); @@ -1559,16 +1510,17 @@ app.post( "/:id/react/:emoji", tokenRequired, scopeRequired(["write:favourites"]), + withAccountOwner, addEmojiReaction, ); async function removeEmojiReaction( - c: Context<{ Variables: Variables }, "/:id/emoji_reactions/:emoji">, + c: Context< + { Variables: AccountOwnerVariables }, + "/:id/emoji_reactions/:emoji" + >, ): Promise { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "This method requires an authenticated user" }, 422); - } + const owner = c.get("accountOwner"); const fedCtx = federation.createContext(c.req.raw, undefined); const postId = c.req.param("id"); if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404); @@ -1655,6 +1607,7 @@ app.delete( "/:id/emoji_reactions/:emoji", tokenRequired, scopeRequired(["write:favourites"]), + withAccountOwner, removeEmojiReaction, ); @@ -1662,6 +1615,7 @@ app.post( "/:id/unreact/:emoji", tokenRequired, scopeRequired(["write:favourites"]), + withAccountOwner, removeEmojiReaction, ); @@ -1731,14 +1685,9 @@ app.post( "/:id/quotes/:quoting_status_id/revoke", tokenRequired, scopeRequired(["write:statuses"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const id = c.req.param("id"); const quotingStatusId = c.req.param("quoting_status_id"); if (!isUuid(id) || !isUuid(quotingStatusId)) { diff --git a/src/api/v1/tags.ts b/src/api/v1/tags.ts index 05ebbf35..f018dbbf 100644 --- a/src/api/v1/tags.ts +++ b/src/api/v1/tags.ts @@ -1,4 +1,4 @@ -import { sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { Hono } from "hono"; import { db } from "../../db"; @@ -6,42 +6,61 @@ import { serializeTag } from "../../entities/tag"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { accountOwners } from "../../schema"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); app.use(tokenRequired); -app.get("/:id", (c) => { - const owner = c.get("token").accountOwner; +// GET /:id is "OAuth: Public, or User token" per Mastodon API spec — client +// credentials tokens (no accountOwnerId) are valid and return following: false. +app.get("/:id", async (c) => { + const { accountOwnerId } = c.get("token"); const tag = c.req.param("id"); - return c.json(serializeTag(tag, owner, c.req.url)); -}); - -app.post("/:id/follow", scopeRequired(["write:follows"]), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "This method requires an authenticated user" }, 422); + if (accountOwnerId == null) { + return c.json(serializeTag(tag, null, c.req.url)); } - const tag = c.req.param("id"); - await db.update(accountOwners).set({ - followedTags: sql`array_append(${accountOwners.followedTags}, ${tag})`, + const owner = await db.query.accountOwners.findFirst({ + where: eq(accountOwners.id, accountOwnerId), }); - return c.json({ ...serializeTag(tag, null, c.req.url), following: true }); + return c.json(serializeTag(tag, owner, c.req.url)); }); -app.post("/:id/unfollow", scopeRequired(["write:follows"]), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json({ error: "This method requires an authenticated user" }, 422); - } - const tag = c.req.param("id"); - await db.update(accountOwners).set({ - followedTags: sql`array_remove(${accountOwners.followedTags}, ${tag})`, - }); - return c.json({ ...serializeTag(tag, null, c.req.url), following: false }); -}); +app.post( + "/:id/follow", + scopeRequired(["write:follows"]), + withAccountOwner, + async (c) => { + const owner = c.get("accountOwner"); + const tag = c.req.param("id"); + await db + .update(accountOwners) + .set({ + followedTags: sql`array_append(${accountOwners.followedTags}, ${tag})`, + }) + .where(eq(accountOwners.id, owner.id)); + return c.json({ ...serializeTag(tag, null, c.req.url), following: true }); + }, +); + +app.post( + "/:id/unfollow", + scopeRequired(["write:follows"]), + withAccountOwner, + async (c) => { + const owner = c.get("accountOwner"); + const tag = c.req.param("id"); + await db + .update(accountOwners) + .set({ + followedTags: sql`array_remove(${accountOwners.followedTags}, ${tag})`, + }) + .where(eq(accountOwners.id, owner.id)); + return c.json({ ...serializeTag(tag, null, c.req.url), following: false }); + }, +); export default app; diff --git a/src/api/v1/timelines.ts b/src/api/v1/timelines.ts index 48b07e97..9ef2f336 100644 --- a/src/api/v1/timelines.ts +++ b/src/api/v1/timelines.ts @@ -26,7 +26,8 @@ import { import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { accountOwners, @@ -45,7 +46,7 @@ import { postAccountIdInArray, } from "../visibility"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); app.use(tokenRequired); @@ -72,15 +73,10 @@ export const publicTimelineQuerySchema = timelineQuerySchema.extend({ app.get( "/public", + withAccountOwner, zValidator("query", publicTimelineQuerySchema), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const query = c.req.valid("query"); const timeline = await db.query.posts.findMany({ where: and( @@ -203,15 +199,10 @@ app.get( app.get( "/home", scopeRequired(["read:statuses"]), + withAccountOwner, zValidator("query", timelineQuerySchema), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const query = c.req.valid("query"); let timeline: Parameters[0][]; if (TIMELINE_INBOXES) { @@ -492,17 +483,12 @@ app.get( "/list/:list_id", tokenRequired, scopeRequired(["read:lists"]), + withAccountOwner, zValidator("query", publicTimelineQuerySchema), async (c) => { const listId = c.req.param("list_id"); if (!isUuid(listId)) return c.json({ error: "Record not found" }, 404); - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const query = c.req.valid("query"); const list = await db.query.lists.findFirst({ where: and(eq(lists.id, listId), eq(lists.accountOwnerId, owner.id)), @@ -767,15 +753,10 @@ app.get( "/tag/:hashtag", tokenRequired, scopeRequired(["read:statuses"]), + withAccountOwner, zValidator("query", publicTimelineQuerySchema), async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const query = c.req.valid("query"); const hashtag = `#${c.req.param("hashtag")}`; const followingAccountIds = await getApprovedFollowingAccountIds(owner.id); diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index a0557040..4ec66978 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -30,7 +30,8 @@ import { persistPost } from "../../federation/post"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { HANDLE_PATTERN } from "../../patterns"; import { type Account, accounts, posts } from "../../schema"; @@ -40,12 +41,18 @@ import { postMedia } from "../v1/media"; import instance from "./instance"; import notificationsRoutes from "./notifications"; -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); app.route("/instance", instance); app.route("/notifications", notificationsRoutes); -app.post("/media", tokenRequired, scopeRequired(["write:media"]), postMedia); +app.post( + "/media", + tokenRequired, + scopeRequired(["write:media"]), + withAccountOwner, + postMedia, +); app.get( "/suggestions", @@ -60,6 +67,7 @@ app.get( "/search", tokenRequired, scopeRequired(["read:search"]), + withAccountOwner, zValidator( "query", z.object({ @@ -82,8 +90,7 @@ app.get( ), async (c) => { const logger = getLogger(["hollo", "api", "v2", "search"]); - const owner = c.get("token").accountOwner; - if (owner == null) return c.json({ error: "invalid_token" }, 401); + const owner = c.get("accountOwner"); const query = c.req.valid("query"); const q = query.q.trim(); // Check if query is a URL (for post search optimization) diff --git a/src/api/v2/notifications.ts b/src/api/v2/notifications.ts index 8f91215f..b8212bc6 100644 --- a/src/api/v2/notifications.ts +++ b/src/api/v2/notifications.ts @@ -11,7 +11,8 @@ import { getPostRelations, serializePost } from "../../entities/status"; import { scopeRequired, tokenRequired, - type Variables, + withAccountOwner, + type AccountOwnerVariables, } from "../../oauth/middleware"; import { accounts, @@ -25,7 +26,7 @@ import type { Uuid } from "../../uuid"; const logger = getLogger(["hollo", "api", "v2", "notifications"]); -const app = new Hono<{ Variables: Variables }>(); +const app = new Hono<{ Variables: AccountOwnerVariables }>(); // Format notification ID to match v1 API format for consistency // This ensures markers work correctly across v1 and v2 APIs @@ -44,14 +45,9 @@ app.get( "/", tokenRequired, scopeRequired(["read:notifications"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); // Parse query parameters let types = c.req.queries("types[]") as NotificationType[]; @@ -299,14 +295,9 @@ app.get( "/unread_count", tokenRequired, scopeRequired(["read:notifications"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const limit = Math.min( Number.parseInt(c.req.query("limit") ?? "100", 10), @@ -344,14 +335,9 @@ app.get( "/:group_key", tokenRequired, scopeRequired(["read:notifications"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const groupKey = c.req.param("group_key"); @@ -445,14 +431,9 @@ app.post( "/:group_key/dismiss", tokenRequired, scopeRequired(["write:notifications"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const groupKey = c.req.param("group_key"); @@ -491,14 +472,9 @@ app.get( "/:group_key/accounts", tokenRequired, scopeRequired(["read:notifications"]), + withAccountOwner, async (c) => { - const owner = c.get("token").accountOwner; - if (owner == null) { - return c.json( - { error: "This method requires an authenticated user" }, - 422, - ); - } + const owner = c.get("accountOwner"); const groupKey = c.req.param("group_key"); diff --git a/src/oauth/endpoints/userinfo.ts b/src/oauth/endpoints/userinfo.ts index e0e8aec0..1d19a325 100644 --- a/src/oauth/endpoints/userinfo.ts +++ b/src/oauth/endpoints/userinfo.ts @@ -1,34 +1,47 @@ +import { eq } from "drizzle-orm"; import { Hono } from "hono"; +import { db } from "../../db"; +import { accountOwners } from "../../schema"; import { scopeRequired, tokenRequired, type Variables } from "../middleware"; const app = new Hono<{ Variables: Variables }>(); -app.on(["GET", "POST"], "/", tokenRequired, scopeRequired(["profile"]), (c) => { - const accountOwner = c.get("token").accountOwner; +app.on( + ["GET", "POST"], + "/", + tokenRequired, + scopeRequired(["profile"]), + async (c) => { + const { accountOwnerId } = c.get("token"); + if (accountOwnerId == null) { + return c.json( + { error: "This method requires an authenticated user" }, + 401, + ); + } + const accountOwner = await db.query.accountOwners.findFirst({ + where: eq(accountOwners.id, accountOwnerId), + with: { account: { with: { successor: true } } }, + }); + if (accountOwner == null) { + return c.json({ error: "invalid_token" }, 401); + } - if (!accountOwner) { - return c.json( - { - error: "This method requires an authenticated user", - }, - 401, - ); - } + const defaultAvatarUrl = new URL( + "/image/avatars/original/missing.png", + c.req.url, + ).href; - const defaultAvatarUrl = new URL( - "/image/avatars/original/missing.png", - c.req.url, - ).href; - - return c.json({ - iss: new URL("/", c.req.url).href, - sub: accountOwner.account.iri, - name: accountOwner.account.name, - preferredUsername: accountOwner.handle, - profile: accountOwner.account.url, - picture: accountOwner.account.avatarUrl ?? defaultAvatarUrl, - }); -}); + return c.json({ + iss: new URL("/", c.req.url).href, + sub: accountOwner.account.iri, + name: accountOwner.account.name, + preferredUsername: accountOwner.handle, + profile: accountOwner.account.url, + picture: accountOwner.account.avatarUrl ?? defaultAvatarUrl, + }); + }, +); export default app; diff --git a/src/oauth/middleware.test.ts b/src/oauth/middleware.test.ts index 6a54ad24..658fba55 100644 --- a/src/oauth/middleware.test.ts +++ b/src/oauth/middleware.test.ts @@ -41,7 +41,7 @@ describe.sequential("OAuth / Middleware", () => { }); it("Can use a client credentials token", async () => { - expect.assertions(7); + expect.assertions(6); const clientCredential = await createClientCredential(application, [ "read:accounts", @@ -61,14 +61,13 @@ describe.sequential("OAuth / Middleware", () => { expect(json.authorizationHeader).toBe(`Bearer ${clientCredential.token}`); expect(json.grant_type).toBe("client_credentials"); - expect(json.application.clientId).toBe(application.clientId); expect(json.scopes).toEqual(application.scopes); // A client credential grant should not have an account owner - expect(json.accountOwner).toBeNull(); + expect(json.accountOwnerId).toBeNull(); }); it("Can use an access token", async () => { - expect.assertions(8); + expect.assertions(7); const accessToken = await getAccessToken(client, account, [ "read:accounts", @@ -90,7 +89,6 @@ describe.sequential("OAuth / Middleware", () => { expect(json.grant_type).toBe("authorization_code"); expect(json.applicationId).toBe(application.id); expect(json.accountOwnerId).toBe(account.id); - expect(json.application.clientId).toBe(application.clientId); expect(json.scopes).toEqual(application.scopes); }); diff --git a/src/oauth/middleware.ts b/src/oauth/middleware.ts index ada3d887..ed6cef7c 100644 --- a/src/oauth/middleware.ts +++ b/src/oauth/middleware.ts @@ -12,6 +12,7 @@ import { type AccountOwner, type Application, accessTokens, + accountOwners, applications, type Scope, } from "../schema.ts"; @@ -19,11 +20,12 @@ import { const logger = getLogger(["hollo", "oauth", "middleware"]); export type Variables = { - token: AccessToken & { - application: Application; - accountOwner: - | (AccountOwner & { account: Account & { successor: Account | null } }) - | null; + token: AccessToken; +}; + +export type AccountOwnerVariables = Variables & { + accountOwner: AccountOwner & { + account: Account & { successor: Account | null }; }; }; @@ -189,10 +191,6 @@ export const tokenRequired = createMiddleware<{ Variables: Variables }>( const accessToken = await db.query.accessTokens.findFirst({ where: eq(accessTokens.code, token), - with: { - accountOwner: { with: { account: { with: { successor: true } } } }, - application: true, - }, }); if (accessToken === undefined) { @@ -204,6 +202,28 @@ export const tokenRequired = createMiddleware<{ Variables: Variables }>( }, ); +export const withAccountOwner = createMiddleware<{ + Variables: AccountOwnerVariables; +}>(async (c, next) => { + const token = c.get("token"); + if (token == null) { + return c.json({ error: "unauthorized" }, 401); + } + const { accountOwnerId } = token; + if (accountOwnerId == null) { + return c.json({ error: "This method requires an authenticated user" }, 422); + } + const owner = await db.query.accountOwners.findFirst({ + where: eq(accountOwners.id, accountOwnerId), + with: { account: { with: { successor: true } } }, + }); + if (owner == null) { + return c.json({ error: "invalid_token" }, 401); + } + c.set("accountOwner", owner); + await next(); +}); + export function scopeRequired(scopes: Scope[]) { return createMiddleware(async (c, next) => { const token = c.get("token");