chore: test deploy snapshot
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]}`;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user