chore: sync backend and deployment changes
This commit is contained in:
337
etc/servers/work-server/src/routes/shared-resource-token.ts
Normal file
337
etc/servers/work-server/src/routes/shared-resource-token.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user