chore: test deploy snapshot

This commit is contained in:
2026-05-28 08:09:49 +09:00
parent e195ac8088
commit 983887dc05
30 changed files with 1730 additions and 108 deletions

View File

@@ -144,7 +144,7 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => {
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext || accessContext.scope === 'all') {
if (!accessContext) {
return reply.code(400).send({ message: '현재 접근 범위에서는 로그를 삭제할 수 없습니다.' });
}
@@ -197,23 +197,33 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext || accessContext.scope === 'all') {
if (!accessContext) {
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 수정할 수 없습니다.' });
}
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const payload = alertPayloadSchema.partial().parse(request.body ?? {});
const item = await updateBaseballTicketBayAlert(params.id, payload, {
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
});
const item = await updateBaseballTicketBayAlert(
params.id,
payload,
accessContext.scope === 'all'
? {
scope: { kind: 'all' },
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
}
: {
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
},
);
await createBaseballTicketBayLog({
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
clientId: item.clientId,
ownerType: item.ownerType,
ownerId: item.ownerId,
alertId: item.id,
alertTitle: item.title,
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
@@ -236,7 +246,7 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext || accessContext.scope === 'all') {
if (!accessContext) {
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 삭제할 수 없습니다.' });
}
@@ -248,9 +258,9 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
}
await createBaseballTicketBayLog({
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
clientId: item.clientId,
ownerType: item.ownerType,
ownerId: item.ownerId,
alertId: item.id,
alertTitle: item.title,
action: 'delete',
@@ -274,10 +284,6 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
if (accessContext.scope === 'all') {
return reply.code(403).send({ message: '전체 보기 범위에서는 즉시 실행할 수 없습니다.' });
}
const result = await runBaseballTicketBayAlert(params.id, {
ignoreTimeWindow: true,
scope: toOwnerScope(accessContext),

View File

@@ -7,6 +7,7 @@ import { env } from '../config/env.js';
import { registerResourceManagerRoutes, resolveSingleRange } from './resource-manager.js';
const fallbackResourceRoot = path.resolve(process.cwd(), '../../../resource');
const legacyPublicResourceRoot = path.resolve(process.cwd(), '../../../public/resource');
test('resolveSingleRange parses open-ended and suffix byte ranges', () => {
assert.deepEqual(resolveSingleRange('bytes=5-', 20), {
@@ -55,3 +56,61 @@ test('resource manager preview serves 206 partial content for byte ranges', asyn
await app.close();
}
});
test('resource manager preview falls back to public/resource legacy artifacts', async () => {
const app = Fastify();
await registerResourceManagerRoutes(app);
const relativePath = path.join('legacy-preview-test', `sample-${Date.now()}.html`);
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, '<!doctype html><html><body>legacy preview</body></html>', 'utf8');
try {
const response = await app.inject({
method: 'GET',
url: `/api/resource-manager/preview/${relativePath.split(path.sep).map((segment) => encodeURIComponent(segment)).join('/')}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
});
assert.equal(response.statusCode, 200);
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
assert.match(response.body, /legacy preview/);
} finally {
await fs.rm(path.join(legacyPublicResourceRoot, 'legacy-preview-test'), { recursive: true, force: true });
await app.close();
}
});
test('resource manager preview restores encoded hash fragments in the file name', async () => {
const app = Fastify();
await registerResourceManagerRoutes(app);
const relativePath = path.join('encoded-preview-test', `sample-${Date.now()}.html`);
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, '<!doctype html><html><body>encoded hash preview</body></html>', 'utf8');
try {
for (const encodedSuffix of ['%23option-a', '%2523option-a']) {
const encodedPath = relativePath
.split(path.sep)
.map((segment, index, list) =>
index === list.length - 1
? `${encodeURIComponent(segment)}${encodedSuffix}`
: encodeURIComponent(segment),
)
.join('/');
const response = await app.inject({
method: 'GET',
url: `/api/resource-manager/preview/${encodedPath}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
});
assert.equal(response.statusCode, 200);
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
assert.match(response.body, /encoded hash preview/);
}
} finally {
await fs.rm(path.join(legacyPublicResourceRoot, 'encoded-preview-test'), { recursive: true, force: true });
await app.close();
}
});

View File

@@ -2,6 +2,7 @@ import type { Knex } from 'knex';
import { z } from 'zod';
import { db } from '../db/client.js';
import { sendNotifications } from './notification-service.js';
import { getSharedResourceTokenDetail } from './shared-resource-token-service.js';
const TICKET_BAY_ORIGIN = 'https://www.ticketbay.co.kr';
const SPORTS_SEARCH_KEY = '5';
@@ -695,6 +696,7 @@ export type BaseballTicketBayAlertItem = {
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
ownerLabel?: string | null;
title: string;
eventDate: string;
team: string;
@@ -722,6 +724,7 @@ export type BaseballTicketBayAlertLogItem = {
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
ownerLabel?: string | null;
alertId: string | null;
alertTitle: string;
action: BaseballTicketBayAlertLogAction;
@@ -902,6 +905,7 @@ function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
ownerLabel: null,
title: normalizeText(row.title),
eventDate: normalizeText(row.event_date),
team: normalizeText(row.team) || '전체',
@@ -938,6 +942,7 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
ownerLabel: null,
alertId: row.alert_id ? normalizeText(row.alert_id) : null,
alertTitle: normalizeText(row.alert_title),
action: row.action,
@@ -949,6 +954,34 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
};
}
async function attachOwnerLabels<T extends { ownerType: BaseballTicketBayOwnerType; ownerId: string; ownerLabel?: string | null }>(items: T[]) {
const sharedTokenOwnerIds = Array.from(
new Set(
items
.filter((item) => item.ownerType === 'shared-token')
.map((item) => normalizeText(item.ownerId))
.filter(Boolean),
),
);
if (!sharedTokenOwnerIds.length) {
return items;
}
const labelEntries = await Promise.all(
sharedTokenOwnerIds.map(async (tokenId) => {
const detail = await getSharedResourceTokenDetail(tokenId);
return [tokenId, detail?.token.resourceLabel?.trim() || detail?.token.name?.trim() || null] as const;
}),
);
const labelMap = new Map(labelEntries);
return items.map((item) => ({
...item,
ownerLabel: item.ownerType === 'shared-token' ? labelMap.get(normalizeText(item.ownerId)) ?? null : null,
}));
}
function formatTimeInKst(date: Date) {
return new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Seoul',
@@ -1162,7 +1195,7 @@ export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerS
{ column: 'client_id', order: 'asc' },
{ column: 'created_at', order: 'desc' },
])) as BaseballTicketBayAlertRow[];
return rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row));
return attachOwnerLabels(rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row)));
}
export async function listBaseballTicketBayLogs(
@@ -1177,7 +1210,7 @@ export async function listBaseballTicketBayLogs(
}
const rows = (await query) as BaseballTicketBayLogRow[];
return rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row));
return attachOwnerLabels(rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row)));
}
export async function createBaseballTicketBayLog(args: {
@@ -1208,7 +1241,7 @@ export async function createBaseballTicketBayLog(args: {
payload_json: args.payload ? JSON.stringify(args.payload) : null,
};
await db(BASEBALL_TICKET_BAY_LOG_TABLE).insert(row);
return mapLogRow(row);
return attachOwnerLabels([mapLogRow(row)]).then(([item]) => item);
}
export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) {
@@ -1234,7 +1267,7 @@ export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballT
scope,
).delete();
return mapLogRow(existing as BaseballTicketBayLogRow);
return attachOwnerLabels([mapLogRow(existing as BaseballTicketBayLogRow)]).then(([item]) => item);
}
export async function createBaseballTicketBayAlert(
@@ -1269,20 +1302,26 @@ export async function createBaseballTicketBayAlert(
last_match_at: null,
};
await db(BASEBALL_TICKET_BAY_ALERT_TABLE).insert(row);
return mapAlertRow(row);
return attachOwnerLabels([mapAlertRow(row)]).then(([item]) => item);
}
export async function updateBaseballTicketBayAlert(
alertId: string,
payload: Partial<BaseballTicketBayAlertMutation>,
context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
context:
| { scope: BaseballTicketBayOwnerScope; appOrigin?: string; appDomain?: string }
| { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
) {
await ensureBaseballTicketBayTables();
const scope =
'scope' in context
? context.scope
: ({ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId } as const);
const current = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
scope,
).first();
if (!current) {
@@ -1309,18 +1348,15 @@ export async function updateBaseballTicketBayAlert(
if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin);
if (context.appDomain) patch.app_domain = normalizeText(context.appDomain);
await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
).update(patch);
await applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), scope).update(patch);
const updated = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
scope,
).first();
return mapAlertRow(updated as BaseballTicketBayAlertRow);
return attachOwnerLabels([mapAlertRow(updated as BaseballTicketBayAlertRow)]).then(([item]) => item);
}
export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) {
@@ -1340,7 +1376,7 @@ export async function deleteBaseballTicketBayAlert(alertId: string, scope: Baseb
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
scope,
).delete();
return mapAlertRow(row as BaseballTicketBayAlertRow);
return attachOwnerLabels([mapAlertRow(row as BaseballTicketBayAlertRow)]).then(([item]) => item);
}
async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) {

View File

@@ -105,9 +105,103 @@ function unwrapMarkdownLinkTarget(value: string) {
return matched?.[1]?.trim() ?? normalized;
}
function normalizeResourceManagerPathSegment(segment: string) {
const normalized = normalizeText(segment);
if (!normalized) {
return '';
}
try {
return encodeURIComponent(decodeURIComponent(normalized));
} catch {
return encodeURIComponent(normalized);
}
}
function normalizeUrlFragmentValue(value: string) {
const normalized = normalizeText(value).replace(/^#+/, '');
if (!normalized) {
return '';
}
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
}
function decodeRepeatedly(value: string, maxIterations = 3) {
let current = value;
for (let index = 0; index < maxIterations; index += 1) {
try {
const decoded = decodeURIComponent(current);
if (!decoded || decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}
function normalizePreviewPathHash(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
const isRootRelative = normalized.startsWith('/');
try {
const parsed = new URL(normalized, 'https://local.invalid');
const segments = parsed.pathname.split('/');
const lastSegment = segments.at(-1) ?? '';
const decodedLastSegment = decodeRepeatedly(lastSegment);
const hashIndex = decodedLastSegment.lastIndexOf('#');
if (hashIndex <= 0 || parsed.hash) {
return normalized;
}
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
return normalized;
}
segments[segments.length - 1] = encodeURIComponent(fileName);
parsed.pathname = segments.join('/');
parsed.hash = fragment;
if (isAbsoluteUrl) {
return parsed.toString();
}
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
} catch {
return normalized;
}
}
function buildResourceManagerPreviewUrl(value: string) {
const normalized = normalizeText(value).replace(/\\/g, '/');
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const normalized = normalizePreviewPathHash(normalizeText(value).replace(/\\/g, '/'));
const hashIndex = normalized.indexOf('#');
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
if (!resourcePath) {
@@ -123,10 +217,10 @@ function buildResourceManagerPreviewUrl(value: string) {
const encodedPath = relativePath
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.map((segment) => normalizeResourceManagerPathSegment(segment))
.join('/');
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
}
function normalizeUrl(value: string) {
@@ -136,6 +230,17 @@ function normalizeUrl(value: string) {
return '';
}
try {
const parsed = new URL(normalized, 'https://local.invalid');
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
return normalizePreviewPathHash(pathname);
}
} catch {
// Fall through to handle relative and embedded resource paths below.
}
const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i);
if (malformedResourceMatch?.[1]) {
return `/${malformedResourceMatch[1]}`;

View File

@@ -1416,6 +1416,96 @@ test('extractChatMessageParts keeps prompt preview payloads for image markdown h
);
});
test('extractChatMessageParts preserves resource preview hash fragments when converting resource paths', () => {
assert.deepEqual(
extractChatMessageParts(
'[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html#option-a"}}]}]]',
),
{
strippedText: '',
parts: [
{
type: 'prompt',
title: '시안 선택',
description: null,
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: false,
selectedValues: [],
resolvedBy: null,
resolvedAt: null,
resultText: null,
steps: undefined,
options: [
{
label: 'A안',
value: 'option-a',
description: null,
preview: {
type: 'resource',
url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
content: null,
alt: null,
title: null,
},
},
],
},
],
},
);
});
test('extractChatMessageParts restores encoded resource preview hash fragments', () => {
assert.deepEqual(
extractChatMessageParts(
'[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html%23option-a"}}]}]]',
),
{
strippedText: '',
parts: [
{
type: 'prompt',
title: '시안 선택',
description: null,
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: false,
selectedValues: [],
resolvedBy: null,
resolvedAt: null,
resultText: null,
steps: undefined,
options: [
{
label: 'A안',
value: 'option-a',
description: null,
preview: {
type: 'resource',
url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
content: null,
alt: null,
title: null,
},
},
],
},
],
},
);
});
test('extractChatMessageParts supports stepper prompt steps', () => {
assert.deepEqual(
extractChatMessageParts(

View File

@@ -36,6 +36,7 @@ import {
getSharedResourceTokenDetailBySharePath,
validateSharedResourceAccessPinBySharePath,
} from './shared-resource-token-service.js';
import { getChatShareTokenRoomMap } from './chat-share-room-map-service.js';
import { sendNotifications } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js';
import {
@@ -794,10 +795,18 @@ async function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL)
const sharedToken = sharedTokenDetail?.token ?? null;
const resourceContext = sharedToken?.resourceContext ?? null;
if (!resourceContext || (requestedSessionId && resourceContext.sessionId !== requestedSessionId)) {
if (!resourceContext) {
return false;
}
if (requestedSessionId && resourceContext.sessionId !== requestedSessionId) {
const requestedRoom = sharedToken?.id ? await getChatShareTokenRoomMap(sharedToken.id, requestedSessionId) : null;
if (!requestedRoom) {
return false;
}
}
if (!sharedToken || !sharedToken.enabled || sharedToken.sharePath !== sharePath) {
return false;
}

View File

@@ -84,10 +84,14 @@ type NotificationPreferenceTarget = {
function normalizeTargetDeviceIds(payload: {
targetDeviceIds?: string[];
}) {
return [...new Set((payload.targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function normalizeTargetClientIds(payload: {
targetClientIds?: string[];
}) {
const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
return [...new Set((targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
return [...new Set((payload.targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function normalizeRegistrationCleanupIds(...values: Array<string | undefined>) {
@@ -126,18 +130,21 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
}
function isAllowedTargetDeviceId(
function isAllowedTargetRecipient(
target: {
deviceId?: string;
clientId?: string;
},
targetDeviceIds: string[],
targetClientIds: string[],
) {
if (targetDeviceIds.length === 0) {
if (targetDeviceIds.length === 0 && targetClientIds.length === 0) {
return true;
}
const deviceId = String(target.deviceId ?? '').trim();
return Boolean(deviceId) && targetDeviceIds.includes(deviceId);
const clientId = String(target.clientId ?? '').trim();
return (Boolean(deviceId) && targetDeviceIds.includes(deviceId)) || (Boolean(clientId) && targetClientIds.includes(clientId));
}
function normalizeAppOrigin(value: unknown) {
@@ -917,6 +924,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const provider = await getProvider();
const targetDeviceIds = normalizeTargetDeviceIds(payload);
const targetClientIds = normalizeTargetClientIds(payload);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
@@ -952,7 +960,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
.filter(
(row) =>
row.allowed &&
isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
isAllowedTargetRecipient({ deviceId: row.deviceId }, targetDeviceIds, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
)
.map((row) => row.token);
@@ -1002,6 +1010,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
async function sendWebPushNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const targetDeviceIds = normalizeTargetDeviceIds(payload);
const targetClientIds = normalizeTargetClientIds(payload);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
if (!ensureWebPushConfigured(env)) {
@@ -1031,7 +1040,7 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
).filter(
(row) =>
row.allowed &&
isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
isAllowedTargetRecipient({ deviceId: row.deviceId, clientId: row.clientId }, targetDeviceIds, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
);
const matchedSubscriptions = subscriptions.map((row) => ({

View File

@@ -63,6 +63,7 @@ class ResourceManagerError extends Error {
const RESOURCE_MANAGER_ROOT_DIR = 'resource';
const RESOURCE_MANAGER_ROOT_LABEL = 'resource';
const LEGACY_PUBLIC_RESOURCE_ROOT_DIR = path.join('public', 'resource');
const TEXT_FILE_EXTENSIONS = new Set([
'.txt',
@@ -199,6 +200,52 @@ function normalizeRelativeTarget(relativePath: string | null | undefined) {
return normalized.replace(/^\/+/, '');
}
function decodeRepeatedly(value: string, maxIterations = 3) {
let current = value;
for (let index = 0; index < maxIterations; index += 1) {
try {
const decoded = decodeURIComponent(current);
if (!decoded || decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}
function normalizePreviewTargetPath(targetPath: string) {
const normalized = normalizeRelativeTarget(targetPath);
if (!normalized) {
return normalized;
}
const segments = normalized.split('/');
const lastSegment = segments.at(-1) ?? '';
const decodedLastSegment = decodeRepeatedly(lastSegment);
const hashIndex = decodedLastSegment.lastIndexOf('#');
if (hashIndex <= 0) {
return normalized;
}
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
if (!fileName || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
return normalized;
}
segments[segments.length - 1] = fileName;
return normalizeRelativeTarget(segments.join('/'));
}
function resolveRepoRoot(candidateRootPath: string) {
const candidates = [
candidateRootPath,
@@ -221,6 +268,30 @@ export function resolveResourceManagerRoot(repoRootPath: string) {
return path.join(resolveRepoRoot(repoRootPath), RESOURCE_MANAGER_ROOT_DIR);
}
function resolveLegacyPublicResourceRoot(repoRootPath: string) {
return path.join(resolveRepoRoot(repoRootPath), LEGACY_PUBLIC_RESOURCE_ROOT_DIR);
}
function resolveLegacyPublicResourcePreviewPath(repoRootPath: string, targetPath: string) {
const normalizedRelativePath = normalizeRelativeTarget(targetPath);
if (!normalizedRelativePath) {
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
}
const rootPath = resolveLegacyPublicResourceRoot(repoRootPath);
const absolutePath = path.resolve(rootPath, normalizedRelativePath);
if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) {
throw new ResourceManagerError('허용되지 않은 경로입니다.', 403);
}
return {
absolutePath,
relativePath: normalizedRelativePath,
};
}
function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: string) {
const rootPath = resolveResourceManagerRoot(repoRootPath);
const normalizedRelativePath = normalizeRelativeTarget(relativePath);
@@ -604,7 +675,12 @@ export async function deleteResourceManagerItem(repoRootPath: string, targetPath
export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) {
return withResourceManagerError(async () => {
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
const normalizedTargetPath = normalizePreviewTargetPath(targetPath);
const primaryTarget = resolveResourceManagerTargetPath(repoRootPath, normalizedTargetPath);
const legacyTarget = resolveLegacyPublicResourcePreviewPath(repoRootPath, normalizedTargetPath);
const absolutePath = existsSync(primaryTarget.absolutePath)
? primaryTarget.absolutePath
: legacyTarget.absolutePath;
if (!existsSync(absolutePath)) {
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);