/
nasya
/
SafeDrop
Обзор
Документация
Войти
/
nasya
/
SafeDrop
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
apps/api-gateway/src/app.ts
581 строка
16 KB
nasya
Initial commit
24 май 2026, 16:12
24 май 2026, 16:12
16bc032
Код
Авторство
О чём код?
import cors from "@fastify/cors"; import swagger from "@fastify/swagger"; import { errorResponseJsonSchema, healthResponseJsonSchema, identityHeader, optionalEnv, updateFileReadStateRequestJsonSchema, } from "@safedrop/shared"; import scalarApiReference from "@scalar/fastify-api-reference"; import Fastify, { type FastifyBaseLogger, type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"; export type ApiGatewayOptions = { logger?: boolean | FastifyBaseLogger; authMode?: string; hydraAdminUrl?: string; fileServiceUrl?: string; keyServiceUrl?: string; shareServiceUrl?: string; fetchImpl?: typeof fetch; }; type AuthContext = { identityId: string; tokenSubject: string; }; export function bearerToken(request: FastifyRequest): string | null { const header = request.headers.authorization; if (!header?.startsWith("Bearer ")) { return null; } return header.slice("Bearer ".length).trim(); } export function isAnonymousRoute(request: Pick<FastifyRequest, "method" | "url">): boolean { const method = request.method.toUpperCase(); const url = request.url; return ( (method === "POST" && url === "/api/shares/secret") || (method === "POST" && url === "/api/shares/recipient") || (method === "GET" && url.startsWith("/api/keys/public/")) || (method === "GET" && url.startsWith("/api/shares/public/")) || (method === "POST" && url.startsWith("/api/shares/public/")) ); } async function authenticate( request: FastifyRequest, authMode: string, hydraAdminUrl: string, fetchImpl: typeof fetch, ): Promise<AuthContext | null> { const token = bearerToken(request); if (authMode === "dev") { const identityId = request.headers["x-dev-identity-id"]; if (typeof identityId === "string" && identityId.length > 0) { return { identityId, tokenSubject: identityId }; } if (token?.startsWith("dev:")) { const identity = token.slice("dev:".length); return { identityId: identity, tokenSubject: identity }; } } if (!token) { return null; } const response = await fetchImpl(`${hydraAdminUrl}/admin/oauth2/introspect`, { body: new URLSearchParams({ token }), headers: { "content-type": "application/x-www-form-urlencoded" }, method: "POST", }); if (!response.ok) { request.log.warn({ status: response.status }, "Hydra token introspection failed"); return null; } const payload = (await response.json()) as { active?: boolean; sub?: string }; if (!payload.active || !payload.sub) { return null; } return { identityId: payload.sub, tokenSubject: payload.sub }; } async function forward( request: FastifyRequest, reply: FastifyReply, baseUrl: string, stripPrefix: string, identityId: string, fetchImpl: typeof fetch, ) { const path = request.url.replace(stripPrefix, ""); const target = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`; const headers: Record<string, string> = { [identityHeader]: identityId, }; const contentType = request.headers["content-type"]; if (typeof contentType === "string") { headers["content-type"] = contentType; } const init: RequestInit = { headers, method: request.method, }; if (!["GET", "HEAD"].includes(request.method.toUpperCase())) { init.body = JSON.stringify(request.body ?? {}); headers["content-type"] = headers["content-type"] ?? "application/json"; } const response = await fetchImpl(target, init); const text = await response.text(); reply.status(response.status); const responseContentType = response.headers.get("content-type"); if (responseContentType) { reply.header("content-type", responseContentType); } return reply.send(text); } export async function buildApp(options: ApiGatewayOptions = {}): Promise<FastifyInstance> { const authMode = options.authMode ?? optionalEnv("AUTH_MODE", "dev"); const hydraAdminUrl = options.hydraAdminUrl ?? optionalEnv("HYDRA_ADMIN_URL", "http://hydra:4445"); const fileServiceUrl = options.fileServiceUrl ?? optionalEnv("FILE_SERVICE_URL", "http://file-service:3001"); const keyServiceUrl = options.keyServiceUrl ?? optionalEnv("KEY_SERVICE_URL", "http://key-service:3002"); const shareServiceUrl = options.shareServiceUrl ?? optionalEnv("SHARE_SERVICE_URL", "http://share-service:3003"); const fetchImpl = options.fetchImpl ?? fetch; const app = Fastify({ logger: options.logger ?? true }); await app.register(cors, { credentials: true, origin: true, }); await app.register(swagger, { openapi: { components: { securitySchemes: { bearerAuth: { bearerFormat: "Hydra access token или dev:<identityId> в AUTH_MODE=dev", scheme: "bearer", type: "http", }, }, }, info: { description: "Публичная документация маршрутов SafeDrop через api-gateway.", title: "SafeDrop API", version: "0.1.0", }, servers: [{ description: "Локальный dev gateway", url: "http://localhost:8080" }], tags: [ { description: "Служебные маршруты api-gateway", name: "gateway" }, { description: "Encrypted key vault и публичные ключи", name: "keys" }, { description: "Metadata файлов и доступы", name: "files" }, { description: "Анонимные ссылки и отправка получателю без подписи", name: "shares" }, ], }, }); await app.register(scalarApiReference, { configuration: { title: "SafeDrop API", url: "/openapi.json", }, logLevel: "silent", routePrefix: "/docs", }); app.get("/openapi.json", { schema: { hide: true, tags: ["gateway"] } }, async () => (app as unknown as { swagger: () => unknown }).swagger(), ); app.get( "/health", { schema: { response: { 200: healthResponseJsonSchema }, tags: ["gateway"], }, }, async () => ({ authMode, service: "api-gateway", status: "ok", }), ); app.addHook("preHandler", async (request, reply) => { if (request.url === "/health" || request.url === "/openapi.json" || request.url.startsWith("/docs")) { return; } if (isAnonymousRoute(request)) { const auth = await authenticate(request, authMode, hydraAdminUrl, fetchImpl); if (auth) { request.headers[identityHeader] = auth.identityId; } return; } const auth = await authenticate(request, authMode, hydraAdminUrl, fetchImpl); if (!auth) { return reply.status(401).send({ error: "unauthorized" }); } request.headers[identityHeader] = auth.identityId; }); app.get( "/api/me", { schema: { response: { 200: { properties: { authMode: { type: "string" }, identityId: { nullable: true, type: "string" }, }, type: "object", }, 401: errorResponseJsonSchema, }, security: [{ bearerAuth: [] }], tags: ["gateway"], }, }, async (request) => ({ authMode, identityId: request.headers[identityHeader] ?? null, }), ); app.get( "/api/keys/public/email/:email", { schema: { params: { properties: { email: { type: "string" } }, required: ["email"], type: "object", }, response: { 200: { additionalProperties: true, type: "object" }, 404: errorResponseJsonSchema, }, tags: ["keys"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, keyServiceUrl, "/api/keys", identityId, fetchImpl); }, ); app.get( "/api/keys/public/:identityId", { schema: { params: { properties: { identityId: { type: "string" } }, required: ["identityId"], type: "object", }, response: { 200: { additionalProperties: true, type: "object" }, 404: errorResponseJsonSchema, }, tags: ["keys"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, keyServiceUrl, "/api/keys", identityId, fetchImpl); }, ); app.all( "/api/keys/*", { schema: { response: { 401: errorResponseJsonSchema }, security: [{ bearerAuth: [] }], tags: ["keys"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, keyServiceUrl, "/api/keys", identityId, fetchImpl); }, ); app.all( "/api/files", { schema: { response: { 401: errorResponseJsonSchema }, security: [{ bearerAuth: [] }], tags: ["files"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, fileServiceUrl, "/api/files", identityId, fetchImpl); }, ); app.patch( "/api/files/inbox/read-all", { schema: { response: { 200: { properties: { updated: { minimum: 0, type: "integer" } }, required: ["updated"], type: "object", }, 401: errorResponseJsonSchema, }, security: [{ bearerAuth: [] }], tags: ["files"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, fileServiceUrl, "/api/files", identityId, fetchImpl); }, ); app.patch( "/api/files/:id/read-state", { schema: { body: updateFileReadStateRequestJsonSchema, params: { properties: { id: { type: "string" } }, required: ["id"], type: "object" }, response: { 400: errorResponseJsonSchema, 401: errorResponseJsonSchema, 403: errorResponseJsonSchema, 404: errorResponseJsonSchema, }, security: [{ bearerAuth: [] }], tags: ["files"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, fileServiceUrl, "/api/files", identityId, fetchImpl); }, ); app.delete( "/api/files/inbox/history", { schema: { response: { 200: { properties: { updated: { minimum: 0, type: "integer" } }, required: ["updated"], type: "object", }, 401: errorResponseJsonSchema, }, security: [{ bearerAuth: [] }], tags: ["files"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, fileServiceUrl, "/api/files", identityId, fetchImpl); }, ); app.delete( "/api/files/sent/history", { schema: { response: { 200: { properties: { updated: { minimum: 0, type: "integer" } }, required: ["updated"], type: "object", }, 401: errorResponseJsonSchema, }, security: [{ bearerAuth: [] }], tags: ["files"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, fileServiceUrl, "/api/files", identityId, fetchImpl); }, ); app.all( "/api/files/*", { schema: { response: { 401: errorResponseJsonSchema }, security: [{ bearerAuth: [] }], tags: ["files"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, fileServiceUrl, "/api/files", identityId, fetchImpl); }, ); app.all( "/api/shares/public/:token/access", { schema: { response: { 404: errorResponseJsonSchema }, tags: ["shares"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, shareServiceUrl, "/api/shares", identityId, fetchImpl); }, ); app.all( "/api/shares/*", { schema: { response: { 401: errorResponseJsonSchema }, tags: ["shares"], }, }, async (request, reply) => { const identityId = String(request.headers[identityHeader] ?? "anonymous"); return forward(request, reply, shareServiceUrl, "/api/shares", identityId, fetchImpl); }, ); return app; } export async function start() { const port = Number(optionalEnv("PORT", "8080")); const app = await buildApp(); await app.listen({ host: "0.0.0.0", port }); } if (import.meta.rstest) { const { describe, expect, it } = import.meta.rstest; function testJsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status, }); } describe("api-gateway routes", () => { it("returns health and authenticates dev tokens", async () => { const app = await buildApp({ authMode: "dev", logger: false }); await app.ready(); const health = await app.inject({ method: "GET", url: "/health" }); expect(health.statusCode).toBe(200); expect(health.json().service).toBe("api-gateway"); const unauthorized = await app.inject({ method: "GET", url: "/api/me" }); expect(unauthorized.statusCode).toBe(401); const me = await app.inject({ headers: { authorization: "Bearer dev:alice" }, method: "GET", url: "/api/me", }); expect(me.statusCode).toBe(200); expect(me.json().identityId).toBe("alice"); await app.close(); }); it("forwards anonymous routes without bearer token", async () => { const calls: Array<{ url: string | URL | Request; init?: RequestInit }> = []; const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { calls.push({ init, url }); return testJsonResponse({ identityHeader: (init?.headers as Record<string, string>)["x-safedrop-identity-id"], }); }) as typeof fetch; const app = await buildApp({ authMode: "dev", fetchImpl, logger: false }); await app.ready(); const response = await app.inject({ method: "GET", url: "/api/shares/public/token-1" }); expect(response.statusCode).toBe(200); expect(response.json().identityHeader).toBe("anonymous"); const keyLookup = await app.inject({ method: "GET", url: "/api/keys/public/email/alice%40example.com" }); expect(keyLookup.statusCode).toBe(200); expect(keyLookup.json().identityHeader).toBe("anonymous"); const createdByAlice = await app.inject({ headers: { authorization: "Bearer dev:alice" }, method: "POST", payload: {}, url: "/api/shares/secret", }); expect(createdByAlice.statusCode).toBe(200); expect(createdByAlice.json().identityHeader).toBe("alice"); expect(calls).toHaveLength(3); await app.close(); }); it("authenticates Hydra introspection tokens in hydra mode", async () => { const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => { const token = init?.body instanceof URLSearchParams ? init.body.get("token") : null; return testJsonResponse(token === "active-token" ? { active: true, sub: "kratos-alice" } : { active: false }); }) as typeof fetch; const app = await buildApp({ authMode: "hydra", fetchImpl, logger: false }); await app.ready(); const active = await app.inject({ headers: { authorization: "Bearer active-token" }, method: "GET", url: "/api/me", }); expect(active.statusCode).toBe(200); expect(active.json().identityId).toBe("kratos-alice"); const inactive = await app.inject({ headers: { authorization: "Bearer inactive-token" }, method: "GET", url: "/api/me", }); expect(inactive.statusCode).toBe(401); await app.close(); }); it("serves OpenAPI and Scalar API Reference routes", async () => { const app = await buildApp({ authMode: "dev", logger: false }); await app.ready(); const openapi = await app.inject({ method: "GET", url: "/openapi.json" }); expect(openapi.statusCode).toBe(200); const document = openapi.json(); expect(document.openapi).toBe("3.0.3"); expect(Object.keys(document.paths)).toEqual( expect.arrayContaining([ "/api/me", "/api/keys/public/email/{email}", "/api/keys/public/{identityId}", "/api/keys/{*}", "/api/files/inbox/read-all", "/api/files/{id}/read-state", "/api/files/inbox/history", "/api/files/sent/history", "/api/files/{*}", "/api/shares/public/{token}/access", "/api/shares/{*}", ]), ); const docs = await app.inject({ method: "GET", url: "/docs" }); expect(docs.statusCode).toBeLessThan(400); await app.close(); }); }); describe("api-gateway auth helpers", () => { it("detects anonymous routes", () => { expect(isAnonymousRoute({ method: "GET", url: "/api/shares/public/token-1" })).toBe(true); expect(isAnonymousRoute({ method: "POST", url: "/api/shares/public/token-1/access" })).toBe(true); expect(isAnonymousRoute({ method: "GET", url: "/api/me" })).toBe(false); }); }); }