338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { env } from '../config/env.js';
|
|
import {
|
|
deleteSharedResourceTokens,
|
|
deleteSharedResourceToken,
|
|
getSharedResourceTokenDetail,
|
|
getSharedResourceTokenDetailBySharePath,
|
|
listSharedResourceTokens,
|
|
recordSharedResourceTokenUsage,
|
|
restoreSharedResourceToken,
|
|
revokeSharedResourceToken,
|
|
revokeSharedResourceTokens,
|
|
sharedResourceTokenSchema,
|
|
upsertSharedResourceToken,
|
|
} from '../services/shared-resource-token-service.js';
|
|
|
|
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
|
const tokenHeader = request.headers['x-access-token'];
|
|
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
|
}
|
|
|
|
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
|
const tokenHeader = request.headers['x-chat-share-token'];
|
|
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
|
}
|
|
|
|
function resolveChatSharePath(token: string) {
|
|
return `/chat/share/${encodeURIComponent(token)}`;
|
|
}
|
|
|
|
type SharedResourceTokenAccessContext =
|
|
| { scope: 'full' }
|
|
| { scope: 'shared'; tokenId: string };
|
|
|
|
async function resolveSharedResourceTokenAccessContext(
|
|
request: { headers: Record<string, string | string[] | undefined> },
|
|
) {
|
|
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
|
return { scope: 'full' } satisfies SharedResourceTokenAccessContext;
|
|
}
|
|
|
|
const shareToken = getRequestChatShareToken(request);
|
|
if (!shareToken) {
|
|
return null;
|
|
}
|
|
|
|
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
|
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
|
return null;
|
|
}
|
|
|
|
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
|
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
|
|
|
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('shared-resource')) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
scope: 'shared',
|
|
tokenId: managedResource.token.id,
|
|
} satisfies SharedResourceTokenAccessContext;
|
|
}
|
|
|
|
function sendAccessDenied(
|
|
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
|
|
) {
|
|
reply.code(403).send({
|
|
message: '권한 토큰 또는 shared-resource 관리 권한이 있는 공유 링크에서만 공유 리소스 관리를 사용할 수 있습니다.',
|
|
});
|
|
}
|
|
|
|
function isAllowedSharedTokenTarget(accessContext: SharedResourceTokenAccessContext, tokenId: string) {
|
|
return accessContext.scope === 'full' || accessContext.tokenId === tokenId;
|
|
}
|
|
|
|
export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
|
app.get('/api/shared-resource-tokens', async (request, reply) => {
|
|
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
|
if (!accessContext) {
|
|
sendAccessDenied(reply);
|
|
return;
|
|
}
|
|
|
|
const sharedTokenDetail =
|
|
accessContext.scope === 'shared' ? await getSharedResourceTokenDetail(accessContext.tokenId) : null;
|
|
const items =
|
|
accessContext.scope === 'full'
|
|
? await listSharedResourceTokens()
|
|
: sharedTokenDetail?.token
|
|
? [sharedTokenDetail.token]
|
|
: [];
|
|
|
|
return {
|
|
ok: true,
|
|
items,
|
|
};
|
|
});
|
|
|
|
app.get('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
|
|
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
|
if (!accessContext) {
|
|
sendAccessDenied(reply);
|
|
return;
|
|
}
|
|
|
|
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
|
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
|
return reply.code(403).send({
|
|
message: '현재 공유 링크로는 이 공유 토큰 상세를 열 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
const item = await getSharedResourceTokenDetail(tokenId);
|
|
|
|
if (!item) {
|
|
return reply.code(404).send({
|
|
message: '공유 리소스 토큰을 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
...item,
|
|
};
|
|
});
|
|
|
|
app.put('/api/shared-resource-tokens', async (request, reply) => {
|
|
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
|
if (!accessContext) {
|
|
sendAccessDenied(reply);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = sharedResourceTokenSchema.parse(request.body ?? {});
|
|
|
|
if (accessContext.scope === 'shared' && payload.id !== accessContext.tokenId) {
|
|
return reply.code(403).send({
|
|
message: '현재 공유 링크에서는 연결된 공유 토큰 상세만 수정할 수 있습니다.',
|
|
});
|
|
}
|
|
|
|
const saved = await upsertSharedResourceToken(payload);
|
|
return {
|
|
ok: true,
|
|
...saved,
|
|
};
|
|
} catch (error) {
|
|
return reply.code(409).send({
|
|
message: error instanceof Error ? error.message : '공유 리소스 토큰 저장에 실패했습니다.',
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/shared-resource-tokens/bulk-revoke', async (request, reply) => {
|
|
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
|
if (!accessContext) {
|
|
sendAccessDenied(reply);
|
|
return;
|
|
}
|
|
|
|
const payload = z
|
|
.object({
|
|
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
|
|
reason: z.string().trim().max(500).optional().nullable(),
|
|
})
|
|
.parse(request.body ?? {});
|
|
|
|
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
|
|
return reply.code(403).send({
|
|
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
|
|
});
|
|
}
|
|
|
|
const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason);
|
|
|
|
return {
|
|
ok: true,
|
|
...result,
|
|
};
|
|
});
|
|
|
|
app.post('/api/shared-resource-tokens/:tokenId/revoke', async (request, reply) => {
|
|
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
|
if (!accessContext) {
|
|
sendAccessDenied(reply);
|
|
return;
|
|
}
|
|
|
|
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
|
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
|
return reply.code(403).send({
|
|
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
|
|
});
|
|
}
|
|
|
|
const payload = z
|
|
.object({
|
|
reason: z.string().trim().max(500).optional().nullable(),
|
|
})
|
|
.parse(request.body ?? {});
|
|
const saved = await revokeSharedResourceToken(tokenId, payload.reason);
|
|
|
|
if (!saved) {
|
|
return reply.code(404).send({
|
|
message: '회수할 공유 리소스 토큰을 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
...saved,
|
|
};
|
|
});
|
|
|
|
app.post('/api/shared-resource-tokens/:tokenId/restore', async (request, reply) => {
|
|
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
|
if (!accessContext) {
|
|
sendAccessDenied(reply);
|
|
return;
|
|
}
|
|
|
|
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
|
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
|
return reply.code(403).send({
|
|
message: '현재 공유 링크에서는 연결된 공유 토큰만 복원할 수 있습니다.',
|
|
});
|
|
}
|
|
|
|
const saved = await restoreSharedResourceToken(tokenId);
|
|
|
|
if (!saved) {
|
|
return reply.code(404).send({
|
|
message: '복원할 공유 리소스 토큰을 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
...saved,
|
|
};
|
|
});
|
|
|
|
app.post('/api/shared-resource-tokens/:tokenId/usage', async (request, reply) => {
|
|
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
|
if (!accessContext) {
|
|
sendAccessDenied(reply);
|
|
return;
|
|
}
|
|
|
|
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
|
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
|
return reply.code(403).send({
|
|
message: '현재 공유 링크에서는 연결된 공유 토큰만 기록할 수 있습니다.',
|
|
});
|
|
}
|
|
|
|
const payload = z
|
|
.object({
|
|
actorLabel: z.string().trim().max(120).optional().nullable(),
|
|
summary: z.string().trim().max(400).optional().nullable(),
|
|
detail: z.string().trim().max(2000).optional().nullable(),
|
|
usageDelta: z.number().int().min(1).max(1_000_000).optional().nullable(),
|
|
})
|
|
.parse(request.body ?? {});
|
|
const saved = await recordSharedResourceTokenUsage(tokenId, payload);
|
|
|
|
if (!saved) {
|
|
return reply.code(404).send({
|
|
message: '사용량을 기록할 공유 리소스 토큰을 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
...saved,
|
|
};
|
|
});
|
|
|
|
app.post('/api/shared-resource-tokens/bulk-delete', async (request, reply) => {
|
|
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
|
if (!accessContext) {
|
|
sendAccessDenied(reply);
|
|
return;
|
|
}
|
|
|
|
const payload = z
|
|
.object({
|
|
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
|
|
})
|
|
.parse(request.body ?? {});
|
|
|
|
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
|
|
return reply.code(403).send({
|
|
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
|
|
});
|
|
}
|
|
|
|
const result = await deleteSharedResourceTokens(payload.tokenIds);
|
|
|
|
return {
|
|
ok: true,
|
|
...result,
|
|
};
|
|
});
|
|
|
|
app.delete('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
|
|
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
|
if (!accessContext) {
|
|
sendAccessDenied(reply);
|
|
return;
|
|
}
|
|
|
|
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
|
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
|
return reply.code(403).send({
|
|
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
|
|
});
|
|
}
|
|
|
|
const deleted = await deleteSharedResourceToken(tokenId);
|
|
|
|
if (!deleted) {
|
|
return reply.code(404).send({
|
|
message: '삭제할 공유 리소스 토큰을 찾을 수 없습니다.',
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
deleted: true,
|
|
tokenId,
|
|
};
|
|
});
|
|
}
|