diff --git a/api/api/v1/roles/:id/index.ts b/api/api/v1/roles/:id/index.ts index 04e7df2c..8fa848f8 100644 --- a/api/api/v1/roles/:id/index.ts +++ b/api/api/v1/roles/:id/index.ts @@ -1,8 +1,9 @@ -import { apiRoute, applyConfig, auth, handleZodError } from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { z } from "zod"; import { RolePermissions } from "~/drizzle/schema"; import { Role } from "~/packages/database-interface/role"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET", "POST", "DELETE"], @@ -28,76 +29,186 @@ export const schemas = { id: z.string().uuid(), }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { user } = context.get("auth"); - const { id } = context.req.valid("param"); - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const userRoles = await Role.getUserRoles( - user.id, - user.data.isAdmin, - ); - const role = await Role.fromId(id); - - if (!role) { - return context.json({ error: "Role not found" }, 404); - } - - switch (context.req.method) { - case "GET": { - return context.json(role.toApi()); - } - - case "POST": { - const userHighestRole = userRoles.reduce((prev, current) => - prev.data.priority > current.data.priority - ? prev - : current, - ); - - if (role.data.priority > userHighestRole.data.priority) { - return context.json( - { - error: `Cannot assign role '${role.data.name}' with priority ${role.data.priority} to user with highest role priority ${userHighestRole.data.priority}`, - }, - 403, - ); - } - - await role.linkUser(user.id); - - return context.newResponse(null, 204); - } - case "DELETE": { - const userHighestRole = userRoles.reduce((prev, current) => - prev.data.priority > current.data.priority - ? prev - : current, - ); - - if (role.data.priority > userHighestRole.data.priority) { - return context.json( - { - error: `Cannot remove role '${role.data.name}' with priority ${role.data.priority} from user with highest role priority ${userHighestRole.data.priority}`, - }, - 403, - ); - } - - await role.unlinkUser(user.id); - - return context.newResponse(null, 204); - } - } +const routeGet = createRoute({ + method: "get", + path: "/api/v1/roles/{id}", + summary: "Get role data", + middleware: [auth(meta.auth)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Role", + content: { + "application/json": { + schema: Role.schema, + }, + }, }, - ), -); + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Role not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +const routePost = createRoute({ + method: "post", + path: "/api/v1/roles/{id}", + summary: "Assign role to user", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 204: { + description: "Role assigned", + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +const routeDelete = createRoute({ + method: "delete", + path: "/api/v1/roles/{id}", + summary: "Remove role from user", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 204: { + description: "Role removed", + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => { + app.openapi(routeGet, async (context) => { + const { user } = context.get("auth"); + const { id } = context.req.valid("param"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const role = await Role.fromId(id); + + if (!role) { + return context.json({ error: "Role not found" }, 404); + } + + return context.json(role.toApi(), 200); + }); + + app.openapi(routePost, async (context) => { + const { user } = context.get("auth"); + const { id } = context.req.valid("param"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin); + const role = await Role.fromId(id); + + if (!role) { + return context.json({ error: "Role not found" }, 404); + } + + const userHighestRole = userRoles.reduce((prev, current) => + prev.data.priority > current.data.priority ? prev : current, + ); + + if (role.data.priority > userHighestRole.data.priority) { + return context.json( + { + error: `Cannot assign role '${role.data.name}' with priority ${role.data.priority} to user with highest role priority ${userHighestRole.data.priority}`, + }, + 403, + ); + } + + await role.linkUser(user.id); + + return context.newResponse(null, 204); + }); + + app.openapi(routeDelete, async (context) => { + const { user } = context.get("auth"); + const { id } = context.req.valid("param"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin); + const role = await Role.fromId(id); + + if (!role) { + return context.json({ error: "Role not found" }, 404); + } + + const userHighestRole = userRoles.reduce((prev, current) => + prev.data.priority > current.data.priority ? prev : current, + ); + + if (role.data.priority > userHighestRole.data.priority) { + return context.json( + { + error: `Cannot remove role '${role.data.name}' with priority ${role.data.priority} from user with highest role priority ${userHighestRole.data.priority}`, + }, + 403, + ); + } + + await role.unlinkUser(user.id); + + return context.newResponse(null, 204); + }); +}); diff --git a/api/api/v1/roles/index.ts b/api/api/v1/roles/index.ts index 87b7adad..4ed12576 100644 --- a/api/api/v1/roles/index.ts +++ b/api/api/v1/roles/index.ts @@ -1,5 +1,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; +import { createRoute, z } from "@hono/zod-openapi"; import { Role } from "~/packages/database-interface/role"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -13,24 +15,44 @@ export const meta = applyConfig({ route: "/api/v1/roles", }); -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - auth(meta.auth, meta.permissions), - async (context) => { - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const userRoles = await Role.getUserRoles( - user.id, - user.data.isAdmin, - ); - - return context.json(userRoles.map((r) => r.toApi())); +const route = createRoute({ + method: "get", + path: "/api/v1/roles", + summary: "Get user roles", + middleware: [auth(meta.auth)], + responses: { + 200: { + description: "User roles", + content: { + "application/json": { + schema: z.array(Role.schema), + }, + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const userRoles = await Role.getUserRoles(user.id, user.data.isAdmin); + + return context.json( + userRoles.map((r) => r.toApi()), + 200, + ); + }), ); diff --git a/api/api/v1/sso/:id/index.ts b/api/api/v1/sso/:id/index.ts index e9f57f53..c19d361a 100644 --- a/api/api/v1/sso/:id/index.ts +++ b/api/api/v1/sso/:id/index.ts @@ -1,11 +1,12 @@ -import { apiRoute, applyConfig, auth, handleZodError } from "@/api"; +import { apiRoute, applyConfig, auth } from "@/api"; import { proxyUrl } from "@/response"; -import { zValidator } from "@hono/zod-validator"; +import { createRoute } from "@hono/zod-openapi"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { db } from "~/drizzle/db"; import { OpenIdAccounts, RolePermissions } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET", "DELETE"], @@ -28,84 +29,161 @@ export const schemas = { }), }; -/** - * SSO Account Linking management endpoint - * A GET request allows the user to list all their linked accounts - * A POST request allows the user to link a new account - */ -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("param", schemas.param, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { id: issuerId } = context.req.valid("param"); - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const issuer = config.oidc.providers.find( - (provider) => provider.id === issuerId, - ); - - if (!issuer) { - return context.json({ error: "Issuer not found" }, 404); - } - - switch (context.req.method) { - case "GET": { - // Get all linked accounts - const account = await db.query.OpenIdAccounts.findFirst({ - where: (account, { eq, and }) => - and( - eq(account.userId, account.id), - eq(account.issuerId, issuerId), - ), - }); - - if (!account) { - return context.json( - { - error: "Account not found or is not linked to this issuer", - }, - 404, - ); - } - - return context.json({ - id: issuer.id, - name: issuer.name, - icon: proxyUrl(issuer.icon) || undefined, - }); - } - case "DELETE": { - const account = await db.query.OpenIdAccounts.findFirst({ - where: (account, { eq, and }) => - and( - eq(account.userId, user.id), - eq(account.issuerId, issuerId), - ), - }); - - if (!account) { - return context.json( - { - error: "Account not found or is not linked to this issuer", - }, - 404, - ); - } - - await db - .delete(OpenIdAccounts) - .where(eq(OpenIdAccounts.id, account.id)); - - return context.newResponse(null, 204); - } - } +const routeGet = createRoute({ + method: "get", + path: "/api/v1/sso/{id}", + summary: "Get linked account", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 200: { + description: "Linked account", + content: { + "application/json": { + schema: z.object({ + id: z.string(), + name: z.string(), + icon: z.string().optional(), + }), + }, + }, }, - ), -); + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Account not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +const routeDelete = createRoute({ + method: "delete", + path: "/api/v1/sso/{id}", + summary: "Unlink account", + middleware: [auth(meta.auth, meta.permissions)], + request: { + params: schemas.param, + }, + responses: { + 204: { + description: "Account unlinked", + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Account not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => { + app.openapi(routeGet, async (context) => { + const { id: issuerId } = context.req.valid("param"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + // Check if issuer exists + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerId, + ); + + if (!issuer) { + return context.json({ error: "Issuer not found" }, 404); + } + + // Get all linked accounts + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account, { eq, and }) => + and( + eq(account.userId, account.id), + eq(account.issuerId, issuerId), + ), + }); + + if (!account) { + return context.json( + { + error: "Account not found or is not linked to this issuer", + }, + 404, + ); + } + + return context.json( + { + id: issuer.id, + name: issuer.name, + icon: proxyUrl(issuer.icon) || undefined, + }, + 200, + ); + }); + + app.openapi(routeDelete, async (context) => { + const { id: issuerId } = context.req.valid("param"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + // Check if issuer exists + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerId, + ); + + if (!issuer) { + return context.json({ error: "Issuer not found" }, 404); + } + + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account, { eq, and }) => + and( + eq(account.userId, user.id), + eq(account.issuerId, issuerId), + ), + }); + + if (!account) { + return context.json( + { + error: "Account not found or is not linked to this issuer", + }, + 404, + ); + } + + await db + .delete(OpenIdAccounts) + .where(eq(OpenIdAccounts.id, account.id)); + + return context.newResponse(null, 204); + }); +}); diff --git a/api/api/v1/sso/index.ts b/api/api/v1/sso/index.ts index 7b2a20c5..cc5c013f 100644 --- a/api/api/v1/sso/index.ts +++ b/api/api/v1/sso/index.ts @@ -1,8 +1,8 @@ -import { apiRoute, applyConfig, auth, handleZodError, jsonOrForm } from "@/api"; +import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api"; import { oauthRedirectUri } from "@/constants"; import { randomString } from "@/math"; import { proxyUrl } from "@/response"; -import { zValidator } from "@hono/zod-validator"; +import { createRoute } from "@hono/zod-openapi"; import { calculatePKCECodeChallenge, discoveryRequest, @@ -17,6 +17,7 @@ import { RolePermissions, } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET", "POST"], @@ -34,150 +35,204 @@ export const meta = applyConfig({ }); export const schemas = { - json: z - .object({ - issuer: z.string(), - }) - .partial(), + json: z.object({ + issuer: z.string(), + }), }; -/** - * SSO Account Linking management endpoint - * A GET request allows the user to list all their linked accounts - * A POST request allows the user to link a new account, and returns a link - */ -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - jsonOrForm(), - zValidator("json", schemas.json, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const form = context.req.valid("json"); - const { user } = context.get("auth"); +const routeGet = createRoute({ + method: "get", + path: "/api/v1/sso", + summary: "Get linked accounts", + middleware: [auth(meta.auth)], + responses: { + 200: { + description: "Linked accounts", + content: { + "application/json": { + schema: z.array( + z.object({ + id: z.string(), + name: z.string(), + icon: z.string().optional(), + }), + ), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } +const routePost = createRoute({ + method: "post", + path: "/api/v1/sso", + summary: "Link account", + middleware: [auth(meta.auth), jsonOrForm()], + request: { + body: { + content: { + "application/json": { + schema: schemas.json, + }, + "multipart/form-data": { + schema: schemas.json, + }, + "application/x-www-form-urlencoded": { + schema: schemas.json, + }, + }, + }, + }, + responses: { + 200: { + description: "Link URL", + content: { + "application/json": { + schema: z.object({ + link: z.string(), + }), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Issuer not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); - switch (context.req.method) { - case "GET": { - // Get all linked accounts - const accounts = await db.query.OpenIdAccounts.findMany({ - where: (User, { eq }) => eq(User.userId, user.id), - }); +export default apiRoute((app) => { + app.openapi(routeGet, async (context) => { + // const form = context.req.valid("json"); + const { user } = context.get("auth"); - return context.json( - accounts - .map((account) => { - const issuer = config.oidc.providers.find( - (provider) => - provider.id === account.issuerId, - ); + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } - if (!issuer) { - return null; - } - - return { - id: issuer.id, - name: issuer.name, - icon: proxyUrl(issuer.icon) || undefined, - }; - }) - .filter(Boolean) as { - id: string; - name: string; - icon: string | undefined; - }[], - ); - } - case "POST": { - if (!form) { - return context.json( - { error: "Missing issuer in form body" }, - 400, - ); - } - - const { issuer: issuerId } = form; - - if (!issuerId) { - return context.json( - { error: "Missing issuer in form body" }, - 400, - ); - } + // Get all linked accounts + const accounts = await db.query.OpenIdAccounts.findMany({ + where: (User, { eq }) => eq(User.userId, user.id), + }); + return context.json( + accounts + .map((account) => { const issuer = config.oidc.providers.find( - (provider) => provider.id === issuerId, + (provider) => provider.id === account.issuerId, ); if (!issuer) { - return context.json( - { error: `Issuer ${issuerId} not found` }, - 404, - ); + return null; } - const issuerUrl = new URL(issuer.url); + return { + id: issuer.id, + name: issuer.name, + icon: proxyUrl(issuer.icon) || undefined, + }; + }) + .filter(Boolean) as { + id: string; + name: string; + icon: string | undefined; + }[], + 200, + ); + }); - const authServer = await discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then((res) => processDiscoveryResponse(issuerUrl, res)); + app.openapi(routePost, async (context) => { + const { issuer: issuerId } = context.req.valid("json"); + const { user } = context.get("auth"); - const codeVerifier = generateRandomCodeVerifier(); + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } - const application = ( - await db - .insert(Applications) - .values({ - clientId: user.id + randomString(32, "base64"), - name: "Versia", - redirectUri: `${oauthRedirectUri(issuerId)}`, - scopes: "openid profile email", - secret: "", - }) - .returning() - )[0]; + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerId, + ); - // Store into database - const newFlow = ( - await db - .insert(OpenIdLoginFlows) - .values({ - codeVerifier, - issuerId, - applicationId: application.id, - }) - .returning() - )[0]; + if (!issuer) { + return context.json({ error: `Issuer ${issuerId} not found` }, 404); + } - const codeChallenge = - await calculatePKCECodeChallenge(codeVerifier); + const issuerUrl = new URL(issuer.url); - return context.json({ - link: `${ - authServer.authorization_endpoint - }?${new URLSearchParams({ - client_id: issuer.client_id, - redirect_uri: `${oauthRedirectUri( - issuerId, - )}?${new URLSearchParams({ - flow: newFlow.id, - link: "true", - user_id: user.id, - })}`, - response_type: "code", - scope: "openid profile email", - // PKCE - code_challenge_method: "S256", - code_challenge: codeChallenge, - }).toString()}`, - }); - } - } - }, - ), -); + const authServer = await discoveryRequest(issuerUrl, { + algorithm: "oidc", + }).then((res) => processDiscoveryResponse(issuerUrl, res)); + + const codeVerifier = generateRandomCodeVerifier(); + + const application = ( + await db + .insert(Applications) + .values({ + clientId: user.id + randomString(32, "base64"), + name: "Versia", + redirectUri: `${oauthRedirectUri(issuerId)}`, + scopes: "openid profile email", + secret: "", + }) + .returning() + )[0]; + + // Store into database + const newFlow = ( + await db + .insert(OpenIdLoginFlows) + .values({ + codeVerifier, + issuerId, + applicationId: application.id, + }) + .returning() + )[0]; + + const codeChallenge = await calculatePKCECodeChallenge(codeVerifier); + + return context.json( + { + link: `${authServer.authorization_endpoint}?${new URLSearchParams( + { + client_id: issuer.client_id, + redirect_uri: `${oauthRedirectUri( + issuerId, + )}?${new URLSearchParams({ + flow: newFlow.id, + link: "true", + user_id: user.id, + })}`, + response_type: "code", + scope: "openid profile email", + // PKCE + code_challenge_method: "S256", + code_challenge: codeChallenge, + }, + ).toString()}`, + }, + 200, + ); + }); +}); diff --git a/api/api/v1/timelines/home.ts b/api/api/v1/timelines/home.ts index 661cd2a3..18497347 100644 --- a/api/api/v1/timelines/home.ts +++ b/api/api/v1/timelines/home.ts @@ -1,15 +1,11 @@ -import { - apiRoute, - applyConfig, - auth, - handleZodError, - idValidator, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, idValidator } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { and, eq, gt, gte, lt, or, sql } from "drizzle-orm"; import { z } from "zod"; import { Notes, RolePermissions } from "~/drizzle/schema"; +import { Note } from "~/packages/database-interface/note"; import { Timeline } from "~/packages/database-interface/timeline"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -40,50 +36,69 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { max_id, since_id, min_id, limit } = - context.req.valid("query"); - - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - const { objects, link } = await Timeline.getNoteTimeline( - and( - and( - max_id ? lt(Notes.id, max_id) : undefined, - since_id ? gte(Notes.id, since_id) : undefined, - min_id ? gt(Notes.id, min_id) : undefined, - ), - or( - eq(Notes.authorId, user.id), - sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${user.id})`, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`, - ), - sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['home'])`, - ), - limit, - context.req.url, - user.id, - ); - - return context.json( - await Promise.all( - objects.map(async (note) => note.toApi(user)), - ), - 200, - { - Link: link, +const route = createRoute({ + method: "get", + path: "/api/v1/timelines/home", + summary: "Get home timeline", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Home timeline", + content: { + "application/json": { + schema: z.array(Note.schema), }, - ); + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { max_id, since_id, min_id, limit } = context.req.valid("query"); + + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + const { objects, link } = await Timeline.getNoteTimeline( + and( + and( + max_id ? lt(Notes.id, max_id) : undefined, + since_id ? gte(Notes.id, since_id) : undefined, + min_id ? gt(Notes.id, min_id) : undefined, + ), + or( + eq(Notes.authorId, user.id), + sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${user.id})`, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`, + ), + sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['home'])`, + ), + limit, + context.req.url, + user.id, + ); + + return context.json( + await Promise.all(objects.map(async (note) => note.toApi(user))), + 200, + { + Link: link, + }, + ); + }), ); diff --git a/api/api/v1/timelines/public.ts b/api/api/v1/timelines/public.ts index f7de03aa..1f945cfc 100644 --- a/api/api/v1/timelines/public.ts +++ b/api/api/v1/timelines/public.ts @@ -1,15 +1,11 @@ -import { - apiRoute, - applyConfig, - auth, - handleZodError, - idValidator, -} from "@/api"; -import { zValidator } from "@hono/zod-validator"; +import { apiRoute, applyConfig, auth, idValidator } from "@/api"; +import { createRoute } from "@hono/zod-openapi"; import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; import { Notes, RolePermissions } from "~/drizzle/schema"; +import { Note } from "~/packages/database-interface/note"; import { Timeline } from "~/packages/database-interface/timeline"; +import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -51,57 +47,70 @@ export const schemas = { }), }; -export default apiRoute((app) => - app.on( - meta.allowedMethods, - meta.route, - zValidator("query", schemas.query, handleZodError), - auth(meta.auth, meta.permissions), - async (context) => { - const { - max_id, - since_id, - min_id, - limit, - local, - remote, - only_media, - } = context.req.valid("query"); - - const { user } = context.get("auth"); - - const { objects, link } = await Timeline.getNoteTimeline( - and( - max_id ? lt(Notes.id, max_id) : undefined, - since_id ? gte(Notes.id, since_id) : undefined, - min_id ? gt(Notes.id, min_id) : undefined, - remote - ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NOT NULL)` - : undefined, - local - ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)` - : undefined, - only_media - ? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})` - : undefined, - user - ? sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['public'])` - : undefined, - ), - limit, - context.req.url, - user?.id, - ); - - return context.json( - await Promise.all( - objects.map(async (note) => note.toApi(user)), - ), - 200, - { - Link: link, +const route = createRoute({ + method: "get", + path: "/api/v1/timelines/public", + summary: "Get public timeline", + middleware: [auth(meta.auth, meta.permissions)], + request: { + query: schemas.query, + }, + responses: { + 200: { + description: "Public timeline", + content: { + "application/json": { + schema: z.array(Note.schema), }, - ); + }, }, - ), + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, +}); + +export default apiRoute((app) => + app.openapi(route, async (context) => { + const { max_id, since_id, min_id, limit, local, remote, only_media } = + context.req.valid("query"); + + const { user } = context.get("auth"); + + const { objects, link } = await Timeline.getNoteTimeline( + and( + max_id ? lt(Notes.id, max_id) : undefined, + since_id ? gte(Notes.id, since_id) : undefined, + min_id ? gt(Notes.id, min_id) : undefined, + remote + ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NOT NULL)` + : undefined, + local + ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)` + : undefined, + only_media + ? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})` + : undefined, + user + ? sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['public'])` + : undefined, + ), + limit, + context.req.url, + user?.id, + ); + + return context.json( + await Promise.all(objects.map(async (note) => note.toApi(user))), + 200, + { + Link: link, + }, + ); + }), );