@aiquants/daily-report 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +229 -8
- package/dist/client.d.mts +201 -739
- package/dist/client.d.ts +201 -739
- package/dist/client.js +5 -6
- package/dist/client.mjs +5 -6
- package/dist/index.d.mts +5 -197
- package/dist/index.d.ts +5 -197
- package/dist/index.js +1 -2
- package/dist/index.mjs +1 -2
- package/dist/logger-D3krZrNK.d.mts +0 -14
- package/dist/logger-D3krZrNK.d.ts +0 -14
- package/dist/server.d.mts +114 -744
- package/dist/server.d.ts +114 -744
- package/dist/server.js +1 -2
- package/dist/server.mjs +1 -2
- package/dist/{sse-schema-ChSFm-4q.d.mts → sse-schema-DCxpdETM.d.mts} +1106 -1090
- package/dist/{sse-schema-BdUGThiz.d.ts → sse-schema-DoQw8_om.d.ts} +1106 -1090
- package/dist/types-CqgYQgbH.d.mts +0 -34
- package/dist/types-CqgYQgbH.d.ts +0 -34
- package/package.json +9 -10
- package/dist/client.js.map +0 -1
- package/dist/client.mjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/server.mjs.map +0 -1
- package/src/client/components/business-day-thumb-overlay.tsx +0 -19
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +0 -51
- package/src/client/components/daily-report-attachment-indicator.tsx +0 -45
- package/src/client/components/daily-report-attachment-list.spec.tsx +0 -94
- package/src/client/components/daily-report-attachment-list.tsx +0 -112
- package/src/client/components/daily-report-comment-item.tsx +0 -89
- package/src/client/components/daily-report-comment-section.tsx +0 -166
- package/src/client/components/daily-report-detail-list.tsx +0 -842
- package/src/client/components/daily-report-edit-form.tsx +0 -87
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +0 -93
- package/src/client/components/daily-report-ids-stream-status.tsx +0 -67
- package/src/client/components/daily-report-list.tsx +0 -1232
- package/src/client/components/daily-report-page.spec.tsx +0 -119
- package/src/client/components/daily-report-page.tsx +0 -140
- package/src/client/components/daily-report-resize-panels.spec.tsx +0 -307
- package/src/client/components/daily-report-resolved-content.tsx +0 -207
- package/src/client/components/daily-report-scroll-restoration.spec.tsx +0 -422
- package/src/client/components/report-views.spec.tsx +0 -375
- package/src/client/components/unread-indicator.tsx +0 -13
- package/src/client/config-context.tsx +0 -184
- package/src/client/contexts/daily-report-action-context.spec.tsx +0 -433
- package/src/client/contexts/daily-report-action-context.tsx +0 -1147
- package/src/client/contexts/daily-report-error-context.spec.tsx +0 -66
- package/src/client/contexts/daily-report-error-context.tsx +0 -98
- package/src/client/hooks/use-animated-number.spec.ts +0 -103
- package/src/client/hooks/use-animated-number.ts +0 -69
- package/src/client/hooks/use-daily-report-comments.ts +0 -73
- package/src/client/hooks/use-daily-report-sse-connection.ts +0 -86
- package/src/client/hooks/use-daily-report.spec.ts +0 -546
- package/src/client/hooks/use-daily-report.ts +0 -549
- package/src/client/hooks/use-dynamic-viewport-height.ts +0 -127
- package/src/client/hooks/use-responsive-layout.spec.ts +0 -74
- package/src/client/hooks/use-responsive-layout.ts +0 -63
- package/src/client/route-helpers.spec.ts +0 -100
- package/src/client/route-helpers.ts +0 -61
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +0 -910
- package/src/client/streaming/daily-report-ids-stream-client.ts +0 -730
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +0 -401
- package/src/client/streaming/daily-report-ids-stream-session.ts +0 -319
- package/src/client/ui/button.tsx +0 -42
- package/src/client/ui/cn.ts +0 -14
- package/src/client/ui/input.tsx +0 -21
- package/src/client/ui/label.tsx +0 -16
- package/src/client/ui/switch.tsx +0 -19
- package/src/client/ui/tabs.tsx +0 -40
- package/src/client/ui/textarea.tsx +0 -19
- package/src/client/utils/constants.spec.ts +0 -96
- package/src/client/utils/constants.ts +0 -84
- package/src/client.ts +0 -28
- package/src/index.ts +0 -11
- package/src/lucide-icons.d.ts +0 -6
- package/src/server/authz.spec.ts +0 -57
- package/src/server/authz.ts +0 -99
- package/src/server/cache.spec.ts +0 -556
- package/src/server/cache.ts +0 -303
- package/src/server/etag.ts +0 -18
- package/src/server/external-source.ts +0 -60
- package/src/server/handlers.action.spec.ts +0 -44
- package/src/server/handlers.attachment.spec.ts +0 -470
- package/src/server/handlers.ids-stream.spec.ts +0 -618
- package/src/server/handlers.ts +0 -1638
- package/src/server/handlers.unauthorized-cookie.spec.ts +0 -187
- package/src/server/handlers.visibility.spec.ts +0 -774
- package/src/server/ports.ts +0 -222
- package/src/server/response.ts +0 -37
- package/src/server/schema.spec.ts +0 -30
- package/src/server/schema.ts +0 -344
- package/src/server/service.spec.ts +0 -1130
- package/src/server/service.ts +0 -2045
- package/src/server/sse-reader.spec.ts +0 -302
- package/src/server/sse-reader.ts +0 -267
- package/src/server/test-helpers/handlers-config.ts +0 -321
- package/src/server/transaction-retry.spec.ts +0 -63
- package/src/server/visibility.spec.ts +0 -403
- package/src/server/visibility.ts +0 -373
- package/src/server.spec.ts +0 -160
- package/src/server.ts +0 -131
- package/src/shared/business-date.spec.ts +0 -82
- package/src/shared/business-date.ts +0 -104
- package/src/shared/comment-adapter.spec.ts +0 -38
- package/src/shared/comment-adapter.ts +0 -80
- package/src/shared/ids-stream.spec.ts +0 -154
- package/src/shared/ids-stream.ts +0 -279
- package/src/shared/logger.ts +0 -47
- package/src/shared/sse-schema.spec.ts +0 -50
- package/src/shared/sse-schema.ts +0 -198
- package/src/shared/text-utils.spec.ts +0 -42
- package/src/shared/text-utils.ts +0 -65
- package/src/shared/types.ts +0 -109
- package/src/styles/standalone.entry.css +0 -51
package/src/server/handlers.ts
DELETED
|
@@ -1,1638 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* React Router loader/action factories for the daily-report HTTP surface.
|
|
3
|
-
* 日報 HTTP サーフェス向けの React Router loader / action 工場。
|
|
4
|
-
*
|
|
5
|
-
* - index.loader: 画面ルートの認証 + 難読化ユーザー ID 解決
|
|
6
|
-
* - api.loader / api.action: `:endpoint` パラメータ式の認証付き API ルーター
|
|
7
|
-
* - sse.loader: Redis Streams ベースのリアルタイム更新 SSE エンドポイント
|
|
8
|
-
*/
|
|
9
|
-
import { normalizeBusinessDateKey } from "../shared/business-date"
|
|
10
|
-
import { computeIdsStreamSnapshotToken, type DailyReportIdsStreamChunkLine, type DailyReportIdsStreamCursor, type DailyReportIdsStreamErrorLine, findIdsStreamResumeIndex } from "../shared/ids-stream"
|
|
11
|
-
import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
|
|
12
|
-
import { type DailyReportSseMessage, dailyReportSseMessageSchema } from "../shared/sse-schema"
|
|
13
|
-
import type { DailyReportDetail, DailyReportItem } from "../shared/types"
|
|
14
|
-
import type { DailyReportAttachmentFailure, DailyReportAuthenticate, DailyReportEncodeUserId, DailyReportIdCodec, DailyReportReadAttachment, DailyReportRedisProvider, DailyReportResolveVisibleSourceTypes, DailyReportVisibilityResolveReason } from "./ports"
|
|
15
|
-
import { jsonResponseWithETag } from "./response"
|
|
16
|
-
import type { DailyReportService } from "./service"
|
|
17
|
-
import type { DailyReportSseReader, StreamEntry } from "./sse-reader"
|
|
18
|
-
import { isStreamIdLte } from "./sse-reader"
|
|
19
|
-
import { DailyReportViewerScope, isSourceTypeVisible, type VisibleSourceTypeSet } from "./visibility"
|
|
20
|
-
|
|
21
|
-
type LoaderArgs = { request: Request; params: Record<string, string | undefined> }
|
|
22
|
-
|
|
23
|
-
/** `data()` 相当の JSON レスポンス生成 (react-router 非依存)。`setCookie` 指定時は Set-Cookie を付与する。 */
|
|
24
|
-
const jsonData = (payload: unknown, init?: { status?: number; setCookie?: string }): Response => {
|
|
25
|
-
const headers = new Headers({ "Content-Type": "application/json" })
|
|
26
|
-
// セッション破棄の Set-Cookie は未認証応答にも載せる (捨てると無効セッションがブラウザに残る)
|
|
27
|
-
if (init?.setCookie) {
|
|
28
|
-
headers.append("Set-Cookie", init.setCookie)
|
|
29
|
-
}
|
|
30
|
-
return new Response(JSON.stringify(payload), { status: init?.status ?? 200, headers })
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ---------------- ids NDJSON ストリームの定数とヘルパー (純粋・モジュールスコープ) ----------------
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* First-chunk size, tuned for time-to-first-render.
|
|
37
|
-
* 初回チャンクのアイテム数。最初の描画までの時間を優先して小さく保つ。
|
|
38
|
-
*
|
|
39
|
-
* 一覧の可視行 + オーバースキャンを一度に満たせる件数であればよい。
|
|
40
|
-
*/
|
|
41
|
-
const IDS_STREAM_FIRST_CHUNK_SIZE = 30
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* 2 チャンク目以降のアイテム数。1 アイテムが数十バイト (id/日付/種別のみ) と軽いため
|
|
45
|
-
* 大きめに運ぶ。実データは 20 万件超に達するため、小さすぎると行数 (= pull 往復と
|
|
46
|
-
* クライアント側 publish 回数) が肥大する (4000 件 ≈ 240 KB/行、20 万件で約 50 行)。
|
|
47
|
-
*/
|
|
48
|
-
const IDS_STREAM_NEXT_CHUNK_SIZE = 4000
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Sanitized message for mid-stream failures (no internal details).
|
|
52
|
-
* ストリーム送出中の失敗をクライアントへ伝える定型文 (内部情報は載せない)。
|
|
53
|
-
*/
|
|
54
|
-
const IDS_STREAM_ERROR_CLIENT_MESSAGE = "stream interrupted by server error"
|
|
55
|
-
|
|
56
|
-
/** 再開カーソルの営業日として受け付ける唯一の形式 (正規化済み YYYY-MM-DD)。 */
|
|
57
|
-
const IDS_STREAM_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Heartbeat interval while the next chunk is pending (ms).
|
|
61
|
-
* 次チャンク待ちの間に空行ハートビートを送る間隔 (ms)。
|
|
62
|
-
*
|
|
63
|
-
* 全量クエリ (20 万件超) の確定待ちで無音が続くと、クライアントの stall ウォッチドッグ
|
|
64
|
-
* (既定 30s) が生きた接続を half-open と誤認する。それより十分短い間隔で空行を送り、
|
|
65
|
-
* read を解決させて誤検知を防ぐ。空行はクライアント側で読み飛ばされる (`trim() === ""`)。
|
|
66
|
-
*/
|
|
67
|
-
const IDS_STREAM_HEARTBEAT_MS = 15_000
|
|
68
|
-
|
|
69
|
-
/** next() 待ちがハートビート間隔に負けたことを表す番兵。 */
|
|
70
|
-
const IDS_STREAM_HEARTBEAT = Symbol("ids-stream-heartbeat")
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Keep-alive ticks between SSE visibility re-resolutions.
|
|
74
|
-
* SSE の可視集合を再解決する間隔 (keep-alive tick 数)。
|
|
75
|
-
*
|
|
76
|
-
* keep-alive は 5 秒間隔なので 12 tick = 約 60 秒。読み取り経路がリクエストごとに再解決するのに対し
|
|
77
|
-
* SSE は接続が長寿命なため、この間隔が権限失効の反映遅延の上限になる。
|
|
78
|
-
*/
|
|
79
|
-
const SSE_VISIBILITY_REFRESH_TICKS = 12
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Parses and validates resume-cursor query params (fail-closed).
|
|
83
|
-
* 再開カーソルのクエリパラメータを検証付きで解釈する処理 (不正は fail-closed)。
|
|
84
|
-
*
|
|
85
|
-
* - `cursor_report_hub_id` 単独 = 「営業日なし領域」のカーソル (businessDate: null)。
|
|
86
|
-
* - `cursor_business_date` は `cursor_report_hub_id` 無しでは意味を持たないため 400。
|
|
87
|
-
*
|
|
88
|
-
* @param url Request URL. リクエスト URL。
|
|
89
|
-
* @returns Cursor (null = 先頭から) か、検証失敗の印。
|
|
90
|
-
*/
|
|
91
|
-
/** スナップショットトークンの形式 (computeIdsStreamSnapshotToken の出力域)。 */
|
|
92
|
-
const IDS_STREAM_SNAPSHOT_PATTERN = /^[0-9a-f]{1,16}$/
|
|
93
|
-
|
|
94
|
-
const parseIdsStreamCursor = (url: URL): { cursor: DailyReportIdsStreamCursor | null; snapshot: string | null } | { invalid: true } => {
|
|
95
|
-
const rawId = url.searchParams.get("cursor_report_hub_id")
|
|
96
|
-
const rawDate = url.searchParams.get("cursor_business_date")
|
|
97
|
-
const rawSnapshot = url.searchParams.get("cursor_snapshot")
|
|
98
|
-
// ❗ トークンの形式違反は 400 にしない。「トークン無し」へ落とせば先頭からの再送で
|
|
99
|
-
// 回復できる — 再開の失敗はデータ喪失ではなく、接続ごと落とすより常に安全側
|
|
100
|
-
const snapshot = rawSnapshot !== null && IDS_STREAM_SNAPSHOT_PATTERN.test(rawSnapshot) ? rawSnapshot : null
|
|
101
|
-
|
|
102
|
-
if (rawId === null) {
|
|
103
|
-
// 営業日だけのカーソルは並び順上の位置を決められない
|
|
104
|
-
return rawDate === null ? { cursor: null, snapshot } : { invalid: true }
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const reportHubId = Number.parseInt(rawId, 10)
|
|
108
|
-
if (!Number.isSafeInteger(reportHubId) || String(reportHubId) !== rawId || reportHubId <= 0) {
|
|
109
|
-
return { invalid: true }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (rawDate === null) {
|
|
113
|
-
return { cursor: { businessDate: null, reportHubId }, snapshot }
|
|
114
|
-
}
|
|
115
|
-
if (!IDS_STREAM_DATE_PATTERN.test(rawDate)) {
|
|
116
|
-
return { invalid: true }
|
|
117
|
-
}
|
|
118
|
-
return { cursor: { businessDate: rawDate, reportHubId }, snapshot }
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Emits a finalized id array as chunked NDJSON lines from a resume position.
|
|
123
|
-
* 確定済み ID 配列を再開位置からチャンク行として送出するジェネレーター。
|
|
124
|
-
*
|
|
125
|
-
* `total` は最初に yield する行へ載せる (= 接続内で全量が確定した最初の行。
|
|
126
|
-
* キャッシュヒットの新規接続と再開接続では先頭行、高速先頭ページ経路では 2 行目)。
|
|
127
|
-
*
|
|
128
|
-
* @param items Stream-ordered full array. ストリーム順の全量配列。
|
|
129
|
-
* @param resumeKey Resume position (null = 先頭から)。
|
|
130
|
-
* @param firstChunkSize このジェネレーターの最初のチャンクサイズ。
|
|
131
|
-
*/
|
|
132
|
-
async function* streamLinesFromFullArray(items: DailyReportItem[], resumeKey: DailyReportIdsStreamCursor | null, firstChunkSize: number, options: { snapshot: string; restarted?: boolean }): AsyncGenerator<DailyReportIdsStreamChunkLine> {
|
|
133
|
-
let position = resumeKey ? findIdsStreamResumeIndex(items, resumeKey) : 0
|
|
134
|
-
let isFirstYield = true
|
|
135
|
-
while (true) {
|
|
136
|
-
const chunkItems = items.slice(position, position + (isFirstYield ? firstChunkSize : IDS_STREAM_NEXT_CHUNK_SIZE))
|
|
137
|
-
position += chunkItems.length
|
|
138
|
-
const isLast = position >= items.length
|
|
139
|
-
const lastItem = chunkItems[chunkItems.length - 1]
|
|
140
|
-
yield {
|
|
141
|
-
kind: "chunk",
|
|
142
|
-
items: chunkItems,
|
|
143
|
-
nextCursor: isLast || !lastItem ? null : { businessDate: lastItem.businessDate, reportHubId: lastItem.reportHubId },
|
|
144
|
-
isLast,
|
|
145
|
-
...(isFirstYield ? { total: items.length } : {}),
|
|
146
|
-
// 権威行は必ずトークンを運ぶ (resume 経路も含む)。載せ忘れると最初の再開成功で
|
|
147
|
-
// クライアントのトークンが失われ、2 度目以降の切断が全量再送へ退化する
|
|
148
|
-
snapshot: options.snapshot,
|
|
149
|
-
// restarted は先頭行にのみ載せる。全行に載せるとクライアントが行ごとに初期化し、
|
|
150
|
-
// 直前の行で受けたデータを捨てる
|
|
151
|
-
...(isFirstYield && options.restarted ? { restarted: true } : {}),
|
|
152
|
-
}
|
|
153
|
-
isFirstYield = false
|
|
154
|
-
if (isLast) return
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Wraps a line iterator into a pull-based NDJSON streaming Response.
|
|
160
|
-
* 行イテレーターを pull 駆動の NDJSON ストリーミング Response へ包む処理。
|
|
161
|
-
*
|
|
162
|
-
* - 切断検知時はイテレーターを畳んで (`return()`) DB 待ちを解放し、close する
|
|
163
|
-
* (素の return は desiredSize > 0 のまま pull が再帰する)。
|
|
164
|
-
* - イテレーターの throw は内部情報を含まない定型エラー行 + 正常クローズへ倒す
|
|
165
|
-
* (クライアントは「isLast 無し終端」をカーソル再開のシグナルとして扱う)。
|
|
166
|
-
*/
|
|
167
|
-
const buildIdsStreamResponse = (cookie: string | null, lines: AsyncGenerator<DailyReportIdsStreamChunkLine>, request: Request, logger: DailyReportLogger): Response => {
|
|
168
|
-
const encoder = new TextEncoder()
|
|
169
|
-
// cancel() の lines.return() は「pull が DB 待ちの next() を保留中」だと先取りできない。
|
|
170
|
-
// 待ちが明けた後の enqueue が閉じたストリームへ落ちて catch に入るため、切断は
|
|
171
|
-
// フラグで決定的に識別し、通常イベントとして静かに畳む (ERROR ログにしない —
|
|
172
|
-
// 全量クエリ中の離脱は日常イベントで、本物の送出失敗が雑音に埋もれる)
|
|
173
|
-
let cancelled = false
|
|
174
|
-
// next() の解決はハートビートを跨いで保持する。ハートビートが先着したら空行を出し、
|
|
175
|
-
// 同じ next() を次の pull で await し直す (毎回 next() を呼び直すと二重取得になる)。
|
|
176
|
-
let pendingNext: Promise<IteratorResult<DailyReportIdsStreamChunkLine>> | null = null
|
|
177
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
178
|
-
async pull(controller) {
|
|
179
|
-
if (cancelled || request.signal.aborted) {
|
|
180
|
-
await lines.return(undefined).catch(() => {})
|
|
181
|
-
try {
|
|
182
|
-
controller.close()
|
|
183
|
-
} catch {
|
|
184
|
-
// cancel 済みコントローラーの close は失敗してよい
|
|
185
|
-
}
|
|
186
|
-
return
|
|
187
|
-
}
|
|
188
|
-
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
|
|
189
|
-
try {
|
|
190
|
-
if (pendingNext === null) {
|
|
191
|
-
pendingNext = lines.next()
|
|
192
|
-
}
|
|
193
|
-
// 次チャンクの確定待ちが長引く間、接続の生存を示す空行を送る
|
|
194
|
-
const heartbeatPromise = new Promise<typeof IDS_STREAM_HEARTBEAT>((resolve) => {
|
|
195
|
-
heartbeatTimer = setTimeout(() => resolve(IDS_STREAM_HEARTBEAT), IDS_STREAM_HEARTBEAT_MS)
|
|
196
|
-
})
|
|
197
|
-
const raced = await Promise.race([pendingNext, heartbeatPromise])
|
|
198
|
-
if (heartbeatTimer !== null) {
|
|
199
|
-
clearTimeout(heartbeatTimer)
|
|
200
|
-
heartbeatTimer = null
|
|
201
|
-
}
|
|
202
|
-
if (raced === IDS_STREAM_HEARTBEAT) {
|
|
203
|
-
// next() はまだ保留中。空行だけ送って pending は次の pull へ持ち越す
|
|
204
|
-
if (cancelled || request.signal.aborted) {
|
|
205
|
-
await lines.return(undefined).catch(() => {})
|
|
206
|
-
try {
|
|
207
|
-
controller.close()
|
|
208
|
-
} catch {
|
|
209
|
-
// cancel 済みコントローラーの close は失敗してよい
|
|
210
|
-
}
|
|
211
|
-
return
|
|
212
|
-
}
|
|
213
|
-
controller.enqueue(encoder.encode("\n"))
|
|
214
|
-
return
|
|
215
|
-
}
|
|
216
|
-
pendingNext = null
|
|
217
|
-
const { value, done } = raced
|
|
218
|
-
if (done) {
|
|
219
|
-
controller.close()
|
|
220
|
-
return
|
|
221
|
-
}
|
|
222
|
-
// 保留中の next() の間に切断された場合はここで検知して静かに終える
|
|
223
|
-
if (cancelled || request.signal.aborted) {
|
|
224
|
-
await lines.return(undefined).catch(() => {})
|
|
225
|
-
try {
|
|
226
|
-
controller.close()
|
|
227
|
-
} catch {
|
|
228
|
-
// cancel 済みコントローラーの close は失敗してよい
|
|
229
|
-
}
|
|
230
|
-
return
|
|
231
|
-
}
|
|
232
|
-
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`))
|
|
233
|
-
} catch (error) {
|
|
234
|
-
if (heartbeatTimer !== null) {
|
|
235
|
-
clearTimeout(heartbeatTimer)
|
|
236
|
-
}
|
|
237
|
-
// next() が throw した = このイテレーターは畳む。保留参照を残さない
|
|
238
|
-
pendingNext = null
|
|
239
|
-
if (cancelled || request.signal.aborted) {
|
|
240
|
-
// 切断起因の enqueue/close 失敗はエラーではない
|
|
241
|
-
return
|
|
242
|
-
}
|
|
243
|
-
logger.error(`ids-stream emission failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
244
|
-
try {
|
|
245
|
-
// ❗ 素のリテラルを stringify に渡さない。JSON.stringify は any を受けるため、
|
|
246
|
-
// 型を束縛しないとワイヤユニオンの `kind` 要求がここだけ素通りする
|
|
247
|
-
const errorLine: DailyReportIdsStreamErrorLine = { kind: "error", error: IDS_STREAM_ERROR_CLIENT_MESSAGE }
|
|
248
|
-
controller.enqueue(encoder.encode(`${JSON.stringify(errorLine)}\n`))
|
|
249
|
-
controller.close()
|
|
250
|
-
} catch {
|
|
251
|
-
// 既に切断済みなら何もできない
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
},
|
|
255
|
-
cancel() {
|
|
256
|
-
cancelled = true
|
|
257
|
-
void lines.return(undefined).catch(() => {})
|
|
258
|
-
},
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
const headers = new Headers({
|
|
262
|
-
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
263
|
-
// no-transform: compression ミドルウェア/中間プロキシによるバッファリングを禁止する (SSE と同じ理由)
|
|
264
|
-
"Cache-Control": "private, no-cache, no-transform",
|
|
265
|
-
"X-Content-Type-Options": "nosniff",
|
|
266
|
-
})
|
|
267
|
-
if (cookie) {
|
|
268
|
-
headers.append("Set-Cookie", cookie)
|
|
269
|
-
}
|
|
270
|
-
return new Response(stream, { status: 200, headers })
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ---------------- 添付配信の定数とヘルパー (純粋・モジュールスコープ) ----------------
|
|
274
|
-
|
|
275
|
-
/** 添付 1 件あたりの既定上限。サービス側のワイヤ上限 (既定 64 MiB) より必ず低く保つ。 */
|
|
276
|
-
const DEFAULT_ATTACHMENT_MAX_BYTES = 32 * 1024 * 1024
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Default per-user rate limit (calls per minute), enforced per process.
|
|
280
|
-
* ユーザー単位の既定レート上限 (1 分あたり)。ただし**プロセス単位**で計上する。
|
|
281
|
-
*
|
|
282
|
-
* バケットはハンドラーのクロージャに置く素の `Map` であり、プロセス間で共有されない。
|
|
283
|
-
* 消費アプリが Node Cluster 等で複数ワーカーを起動する場合、コンテナ全体の実効上限は
|
|
284
|
-
* この値のワーカー数倍になる。全体で厳密に効かせたいなら共有ストア (Redis 等) が要る。
|
|
285
|
-
*/
|
|
286
|
-
const DEFAULT_ATTACHMENT_RATE_LIMIT_PER_MINUTE = 60
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Default simultaneous-read limit, enforced per process.
|
|
290
|
-
* 既定の同時実行上限。**プロセス単位**で計上する。
|
|
291
|
-
*
|
|
292
|
-
* 1 リクエストが上限バイト数をヒープへ載せるため小さく保つ。ヒープ見積りは
|
|
293
|
-
* 「この値 × `attachmentMaxBytes` × ワーカー数」であり、ワーカー数を掛け忘れると
|
|
294
|
-
* コンテナのメモリ上限を実際の数倍で見誤る。消費アプリはワーカー数を織り込んだ値を
|
|
295
|
-
* `attachmentConcurrency` で明示注入すること。
|
|
296
|
-
*
|
|
297
|
-
* スロットは**本文をクライアントへ送出し終えるまで**保持する。読み取り完了時点で
|
|
298
|
-
* 解放すると、前の応答のバイト列がヒープに載ったまま次の読み取りが始まるため、
|
|
299
|
-
* 上の見積りが成立しなくなる (実際の同時保持数は無制限になる)。
|
|
300
|
-
*/
|
|
301
|
-
const DEFAULT_ATTACHMENT_CONCURRENCY = 4
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Backstop for releasing a concurrency slot when the client never drains the body.
|
|
305
|
-
* クライアントが本文を読み切らない場合に同時実行スロットを解放するバックストップ。
|
|
306
|
-
*
|
|
307
|
-
* スロットを送出完了まで保持する以上、接続を張ったまま読み止めたクライアントが
|
|
308
|
-
* スロットを永久に占有できてしまう。これは締め切りではなく枯渇防止の保険であり、
|
|
309
|
-
* 超過しても応答は中断しない (単にゲートの会計上、保持されていない扱いにするだけ)。
|
|
310
|
-
*/
|
|
311
|
-
const ATTACHMENT_BODY_FLUSH_TIMEOUT_MS = 120_000
|
|
312
|
-
|
|
313
|
-
/** レート制限テーブルの上限件数。無制限 Map はプロセス寿命の間だけ単調増加するリークになる。 */
|
|
314
|
-
const RATE_LIMITER_MAX_ENTRIES = 1024
|
|
315
|
-
|
|
316
|
-
/** レート上限超過時に提示する再試行間隔 (秒)。 */
|
|
317
|
-
const RATE_LIMIT_RETRY_AFTER_SECONDS = 60
|
|
318
|
-
|
|
319
|
-
/** 同時実行上限に達したときに提示する再試行間隔 (秒)。 */
|
|
320
|
-
const CONCURRENCY_RETRY_AFTER_SECONDS = 5
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Media types allowed to render inline in the browser.
|
|
324
|
-
* ブラウザーへインライン表示してよい media type。
|
|
325
|
-
*
|
|
326
|
-
* これ以外は必ず添付ダウンロードにし、Content-Type も `application/octet-stream` へ落とす。
|
|
327
|
-
* `file_type` は外部システム由来の未検証値であり、`text/html` や `image/svg+xml` を
|
|
328
|
-
* 自オリジンでインライン配信するとセッション Cookie を持つ文脈でスクリプトが動く。
|
|
329
|
-
* `X-Content-Type-Options: nosniff` は「宣言型から外れた推測」を止めるだけで、
|
|
330
|
-
* 宣言された型の実行は止めない。
|
|
331
|
-
*/
|
|
332
|
-
const INLINE_SAFE_MEDIA_TYPES: ReadonlySet<string> = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain"])
|
|
333
|
-
|
|
334
|
-
/** ヘッダー値として安全で、かつ MIME 型の形をしている値だけを通す。 */
|
|
335
|
-
const HEADER_SAFE_PATTERN = /^[\x20-\x7E]+$/
|
|
336
|
-
const MEDIA_TYPE_PATTERN = /^[\w.+-]+\/[\w.+-]+/
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Accepts a media type only when it is header-safe and well-formed.
|
|
340
|
-
* ヘッダー安全かつ MIME 型の形をしている場合のみ採用する処理。
|
|
341
|
-
*
|
|
342
|
-
* CR/LF や 0x00-0xFF 外の文字が混ざった値を `new Headers()` へ渡すと TypeError になり、
|
|
343
|
-
* 認可済みのダウンロードが 500 になる。
|
|
344
|
-
*
|
|
345
|
-
* **これは第 2 層である。** 現在の配信経路では、注入値はインライン許可リストに完全一致しないため
|
|
346
|
-
* 必ず `application/octet-stream` へ落ち、ヘッダーには到達しない。つまり本関数を外しても
|
|
347
|
-
* 現状の挙動は変わらない (ミューテーションテストで確認済み)。
|
|
348
|
-
* 許可判定を前方一致や「DB の型を信じる」形へ変えた瞬間に効き始める防御なので、
|
|
349
|
-
* 到達不能なまま放置せず **直接の単体テストで固定する**目的でエクスポートしている。
|
|
350
|
-
*
|
|
351
|
-
* @param value Candidate media type. 候補となる media type。
|
|
352
|
-
* @returns The value when acceptable, otherwise null. 採用可なら値、それ以外は null。
|
|
353
|
-
*/
|
|
354
|
-
export const sanitizeMediaType = (value: string | null | undefined): string | null => (value && HEADER_SAFE_PATTERN.test(value) && MEDIA_TYPE_PATTERN.test(value) ? value : null)
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Percent-encodes a file name for the RFC 8187 `filename*` parameter.
|
|
358
|
-
* RFC 8187 の `filename*` 用にファイル名をパーセントエンコードする処理。
|
|
359
|
-
*
|
|
360
|
-
* `encodeURIComponent` は `'` `(` `)` `*` を残すが、いずれも RFC 8187 の attr-char ではない。
|
|
361
|
-
*
|
|
362
|
-
* @param name Raw file name. 生のファイル名。
|
|
363
|
-
* @returns Encoded value. エンコード済みの値。
|
|
364
|
-
*/
|
|
365
|
-
export const encodeRfc8187 = (name: string): string => encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Builds the ASCII fallback used by the plain `filename` parameter.
|
|
369
|
-
* 素の `filename` パラメータ用の ASCII フォールバックを組み立てる処理。
|
|
370
|
-
*
|
|
371
|
-
* @param name Raw file name. 生のファイル名。
|
|
372
|
-
* @returns ASCII-only, quote-free name. ASCII のみで引用符を含まない名前。
|
|
373
|
-
*/
|
|
374
|
-
export const asciiFallbackFileName = (name: string): string => name.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Maps a storage read failure onto the HTTP status the endpoint returns.
|
|
378
|
-
* ストレージ読み取りの失敗種別を、エンドポイントが返す HTTP ステータスへ写像する処理。
|
|
379
|
-
*
|
|
380
|
-
* `not_found` と `denied` はどちらも 404 にする。認可判定は既に SQL 側で完了しているため
|
|
381
|
-
* 横断的な情報漏洩は無いが、実体の有無を 403/404 で区別すると存在オラクルになる。
|
|
382
|
-
*
|
|
383
|
-
* @param reason Failure kind reported by the port. ポートが報告した失敗種別。
|
|
384
|
-
* @returns HTTP status code. HTTP ステータスコード。
|
|
385
|
-
*/
|
|
386
|
-
const attachmentFailureToStatus = (reason: DailyReportAttachmentFailure): number => {
|
|
387
|
-
switch (reason) {
|
|
388
|
-
case "not_found":
|
|
389
|
-
case "denied":
|
|
390
|
-
case "invalid_path":
|
|
391
|
-
return 404
|
|
392
|
-
case "too_large":
|
|
393
|
-
return 413
|
|
394
|
-
default:
|
|
395
|
-
return 502
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Client-facing messages per failure status.
|
|
401
|
-
* 失敗ステータスごとのクライアント向けメッセージ。
|
|
402
|
-
*
|
|
403
|
-
* 404 は「存在しない」「認可されない」「実体が消えた」の合流点なので、
|
|
404
|
-
* どの経路から来ても同一の文面にする (差分が存在オラクルになるため)。
|
|
405
|
-
*/
|
|
406
|
-
const ATTACHMENT_FAILURE_MESSAGES: Readonly<Record<number, string>> = {
|
|
407
|
-
404: "Attachment not found",
|
|
408
|
-
413: "Attachment too large",
|
|
409
|
-
502: "Attachment storage unavailable",
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/** レート制限 1 ユーザー分の状態。 */
|
|
413
|
-
type RateBucket = { tokens: number; lastRefillMs: number }
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Creates a bounded per-user token-bucket rate limiter (process-local).
|
|
417
|
-
* ユーザー単位のトークンバケット方式レート制限を、件数上限付きで生成する処理 (プロセス内限定)。
|
|
418
|
-
*
|
|
419
|
-
* 上限を設ける理由: 素の `Map` で保持するとプロセス寿命の間だけ単調増加し、
|
|
420
|
-
* 「サーバー側の状態は上限を持つ」という設計方針 (SqlResultCache の >500 GC) に反する。
|
|
421
|
-
* 上限到達時は挿入順が最も古いエントリから落とす。
|
|
422
|
-
*
|
|
423
|
-
* 状態はプロセス内に閉じるため、マルチワーカー配備では実効上限がワーカー数倍になる
|
|
424
|
-
* (`DEFAULT_ATTACHMENT_RATE_LIMIT_PER_MINUTE` 参照)。
|
|
425
|
-
*
|
|
426
|
-
* @param limitPerMinute Allowed calls per minute. 1 分あたりの許可回数。
|
|
427
|
-
* @returns A function that consumes one token and reports whether it was allowed. トークンを 1 つ消費し可否を返す関数。
|
|
428
|
-
*/
|
|
429
|
-
const createRateLimiter = (limitPerMinute: number) => {
|
|
430
|
-
const buckets = new Map<number, RateBucket>()
|
|
431
|
-
return (userId: number, nowMs: number): boolean => {
|
|
432
|
-
const existing = buckets.get(userId)
|
|
433
|
-
// アクセスのたびに再挿入して LRU 順序にする (Map は挿入順を保つ)
|
|
434
|
-
if (existing) buckets.delete(userId)
|
|
435
|
-
|
|
436
|
-
const bucket = existing ?? { tokens: limitPerMinute, lastRefillMs: nowMs }
|
|
437
|
-
const elapsedMs = Math.max(0, nowMs - bucket.lastRefillMs)
|
|
438
|
-
if (elapsedMs > 0) {
|
|
439
|
-
bucket.tokens = Math.min(limitPerMinute, bucket.tokens + (elapsedMs * limitPerMinute) / 60_000)
|
|
440
|
-
bucket.lastRefillMs = nowMs
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const allowed = bucket.tokens >= 1
|
|
444
|
-
if (allowed) bucket.tokens -= 1
|
|
445
|
-
|
|
446
|
-
buckets.set(userId, bucket)
|
|
447
|
-
while (buckets.size > RATE_LIMITER_MAX_ENTRIES) {
|
|
448
|
-
const oldest = buckets.keys().next()
|
|
449
|
-
if (oldest.done) break
|
|
450
|
-
buckets.delete(oldest.value)
|
|
451
|
-
}
|
|
452
|
-
return allowed
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* Creates a fail-fast concurrency gate (no queueing, process-local).
|
|
458
|
-
* 待ち行列を持たない即時失敗型の同時実行ゲートを生成する処理 (プロセス内限定)。
|
|
459
|
-
*
|
|
460
|
-
* 待たせるとリクエストを掴んだまま滞留するだけなので、上限超過は即座に 503 を返す。
|
|
461
|
-
* カウンタはプロセス内に閉じるため、マルチワーカー配備ではコンテナ全体の同時読み取り数が
|
|
462
|
-
* 上限のワーカー数倍になる (`DEFAULT_ATTACHMENT_CONCURRENCY` 参照)。
|
|
463
|
-
*
|
|
464
|
-
* @param limit Maximum simultaneous holders. 同時保持数の上限。
|
|
465
|
-
* @returns Acquire/release pair. 取得と解放の組。
|
|
466
|
-
*/
|
|
467
|
-
const createConcurrencyGate = (limit: number) => {
|
|
468
|
-
let active = 0
|
|
469
|
-
return {
|
|
470
|
-
tryAcquire: (): boolean => {
|
|
471
|
-
if (active >= limit) return false
|
|
472
|
-
active += 1
|
|
473
|
-
return true
|
|
474
|
-
},
|
|
475
|
-
release: (): void => {
|
|
476
|
-
if (active > 0) active -= 1
|
|
477
|
-
},
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Streams already-materialized bytes and releases the held slot once the body is drained.
|
|
483
|
-
* materialize 済みのバイト列をストリームとして送出し、送出完了時に保持中のスロットを解放する処理。
|
|
484
|
-
*
|
|
485
|
-
* 読み取り完了時点で解放してはならない。その時点でバイト列はまだヒープ上にあり、
|
|
486
|
-
* 応答が流し切られるまで参照が保持されるため、「同時実行上限 × 上限バイト数」という
|
|
487
|
-
* ヒープ見積りが成立しなくなる。ここで送出完了まで保持することで見積りを実際に成立させる。
|
|
488
|
-
*
|
|
489
|
-
* 解放は必ず 1 回だけ行う (正常終了・キャンセル・バックストップのいずれか最初の 1 回)。
|
|
490
|
-
*
|
|
491
|
-
* @param bytes Body bytes to send. 送出する本文のバイト列。
|
|
492
|
-
* @param release Idempotent slot release. 冪等なスロット解放関数。
|
|
493
|
-
* @param timeoutMs Backstop before force-releasing. 強制解放までのバックストップ時間。
|
|
494
|
-
* @returns A stream that emits the bytes once. バイト列を 1 度だけ流すストリーム。
|
|
495
|
-
*/
|
|
496
|
-
const streamAndRelease = (bytes: Uint8Array<ArrayBuffer>, release: () => void, timeoutMs: number): ReadableStream<Uint8Array> => {
|
|
497
|
-
const timer: ReturnType<typeof setTimeout> = setTimeout(release, timeoutMs)
|
|
498
|
-
// タイマーがプロセス終了を待たせないようにする (Node 以外では unref を持たない)
|
|
499
|
-
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
500
|
-
const finish = () => {
|
|
501
|
-
clearTimeout(timer)
|
|
502
|
-
release()
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
let sent = false
|
|
506
|
-
return new ReadableStream<Uint8Array>({
|
|
507
|
-
pull(controller) {
|
|
508
|
-
// 1 回目の pull で本文を積み、消費された後の 2 回目の pull で閉じて解放する
|
|
509
|
-
if (sent) {
|
|
510
|
-
controller.close()
|
|
511
|
-
finish()
|
|
512
|
-
return
|
|
513
|
-
}
|
|
514
|
-
sent = true
|
|
515
|
-
controller.enqueue(bytes)
|
|
516
|
-
},
|
|
517
|
-
cancel() {
|
|
518
|
-
finish()
|
|
519
|
-
},
|
|
520
|
-
})
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* Decides whether an SSE message is visible under the viewer's source-type set.
|
|
525
|
-
* SSE メッセージが閲覧者の可視 sourceType 集合において可視かを判定する処理。
|
|
526
|
-
*
|
|
527
|
-
* - `connected` / `status-update`: 常に可視。前段で本人限定済みであり、区分も本文も載らない。
|
|
528
|
-
* - `report-create` / `report-update` / `report-publish`: 埋め込み `report.sourceType` で判定する。
|
|
529
|
-
* 値が欠落していれば `isSourceTypeVisible` の規則により制限閲覧者には不可視 (fail-close)。
|
|
530
|
-
* - `comment-add` / `comment-delete`: 新規コメントは `addComment` の Internal 限定ゲートを通るため
|
|
531
|
-
* 構造上 Internal のみ。明示 `sourceType` があればそれを使い、無い旧エントリは `"Internal"` として扱う。
|
|
532
|
-
* - `report-delete`: 明示 `sourceType` で判定し、値の無い旧エントリは fail-close で落とす
|
|
533
|
-
* (削除の取りこぼしは次回取得で自己修復する良性の劣化)。
|
|
534
|
-
*
|
|
535
|
-
* ❗ `comment-add` / `comment-delete` は**コメント次元**で判定する。本文次元で判定すると、
|
|
536
|
-
* 「本文は読めるがコメントは読めない」閲覧者へコメント本文が届く。
|
|
537
|
-
*
|
|
538
|
-
* @param parsed Validated SSE message. 検証済みの SSE メッセージ。
|
|
539
|
-
* @param raw Raw payload carrying server-only fields. サーバー専用フィールドを含む生ペイロード。
|
|
540
|
-
* @param visible Normalized visible source-type set (body dimension). 正規化済みの可視 sourceType 集合 (本文次元)。
|
|
541
|
-
* @param commentable Normalized commentable source-type set. 正規化済みのコメント可視 sourceType 集合。
|
|
542
|
-
* @returns True when the message may be delivered. 配信してよいなら true。
|
|
543
|
-
*/
|
|
544
|
-
const isSseMessageSourceTypeVisible = (parsed: DailyReportSseMessage, raw: Record<string, unknown>, visible: VisibleSourceTypeSet, commentable: VisibleSourceTypeSet): boolean => {
|
|
545
|
-
// 両次元とも無制限なら種別を問わず配信する (現行互換の高速経路)
|
|
546
|
-
if (visible === null && commentable === null) {
|
|
547
|
-
return true
|
|
548
|
-
}
|
|
549
|
-
const rawSourceType = typeof raw.sourceType === "string" ? raw.sourceType : undefined
|
|
550
|
-
switch (parsed.type) {
|
|
551
|
-
case "connected":
|
|
552
|
-
case "status-update":
|
|
553
|
-
return true
|
|
554
|
-
case "report-create":
|
|
555
|
-
case "report-update":
|
|
556
|
-
case "report-publish":
|
|
557
|
-
// 本文次元だけで配信可否を決める。コメント面はフィールド単位で落とす
|
|
558
|
-
// (メッセージごと落とすと日報更新そのものが届かなくなる)
|
|
559
|
-
return isSourceTypeVisible(visible, parsed.report.sourceType)
|
|
560
|
-
case "comment-add":
|
|
561
|
-
case "comment-delete":
|
|
562
|
-
return isSourceTypeVisible(commentable, rawSourceType ?? "Internal")
|
|
563
|
-
case "report-delete":
|
|
564
|
-
return isSourceTypeVisible(visible, rawSourceType)
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/**
|
|
569
|
-
* Empties the comment arrays of a `report-*` payload when the viewer may not read comments.
|
|
570
|
-
* コメントを読めない閲覧者向けに `report-*` ペイロードのコメント配列を空にする処理。
|
|
571
|
-
*
|
|
572
|
-
* `report-create` / `report-update` / `report-publish` は `report: dailyReportDetailSchema` を
|
|
573
|
-
* 埋め込んでおり、その detail は `externalComments` と `postedComments` を必須キーとして含む。ペイロードは
|
|
574
|
-
* 発行時に**無制限スコープ**で構築されて Redis Stream へ載る共有物であるため、閲覧者ごとの
|
|
575
|
-
* 絞り込みはこの配信直前が唯一の機会になる。
|
|
576
|
-
*
|
|
577
|
-
* ❗ キーを**省いてはならない**。必須キーの欠落は受信側の `safeParse` を失敗させ、メッセージ全体が
|
|
578
|
-
* fail-close で捨てられる (日報更新そのものが永久に届かなくなる)。必ず空配列で上書きする。
|
|
579
|
-
*
|
|
580
|
-
* ❗ `raw` は購読者ごと・エントリごとに `JSON.parse` された固有オブジェクトなので、その場で
|
|
581
|
-
* 書き換えてよい (他の購読者へ波及しない)。
|
|
582
|
-
*
|
|
583
|
-
* ❗ 戻り値は捨てないこと。配信側は元の生文字列 (`fields.data`) をそのまま送る高速経路を持って
|
|
584
|
-
* おり、秘匿したのに再直列化しないと**書き換えが無かったことになる**。
|
|
585
|
-
*
|
|
586
|
-
* @param parsed Validated SSE message. 検証済みの SSE メッセージ。
|
|
587
|
-
* @param raw Raw payload to redact in place. その場で秘匿する生ペイロード。
|
|
588
|
-
* @param commentable Normalized commentable source-type set. 正規化済みのコメント可視 sourceType 集合。
|
|
589
|
-
* @returns True when the payload was modified. ペイロードを書き換えたなら true。
|
|
590
|
-
*/
|
|
591
|
-
/**
|
|
592
|
-
* The two comment faces of a detail payload, as wire keys.
|
|
593
|
-
* 詳細ペイロードのコメント 2 面 (ワイヤキー)。
|
|
594
|
-
*
|
|
595
|
-
* ❗ 文字列キーで直接書かない。秘匿対象の生ペイロードは `Record<string, unknown>` なので、
|
|
596
|
-
* 素の文字列は改名を tsc が検知できず、秘匿が無音で no-op になる (= コメント本文が権限の
|
|
597
|
-
* 無い閲覧者へ漏洩する)。`satisfies keyof` で型から導出しておけば、次の改名はこの定数を
|
|
598
|
-
* コンパイルエラーにして追従を強制する。
|
|
599
|
-
*/
|
|
600
|
-
const COMMENT_FACE_KEYS = ["externalComments", "postedComments"] as const satisfies readonly (keyof DailyReportDetail)[]
|
|
601
|
-
|
|
602
|
-
const redactSseReportComments = (parsed: DailyReportSseMessage, raw: Record<string, unknown>, commentable: VisibleSourceTypeSet): boolean => {
|
|
603
|
-
if (commentable === null) {
|
|
604
|
-
return false
|
|
605
|
-
}
|
|
606
|
-
if (parsed.type !== "report-create" && parsed.type !== "report-update" && parsed.type !== "report-publish") {
|
|
607
|
-
return false
|
|
608
|
-
}
|
|
609
|
-
if (isSourceTypeVisible(commentable, parsed.report.sourceType)) {
|
|
610
|
-
return false
|
|
611
|
-
}
|
|
612
|
-
const report = raw.report
|
|
613
|
-
if (typeof report !== "object" || report === null) {
|
|
614
|
-
return false
|
|
615
|
-
}
|
|
616
|
-
const target = report as Record<string, unknown>
|
|
617
|
-
for (const key of COMMENT_FACE_KEYS) {
|
|
618
|
-
target[key] = []
|
|
619
|
-
}
|
|
620
|
-
return true
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
export type DailyReportHandlersConfig = {
|
|
624
|
-
/** リクエスト認証ポート。 */
|
|
625
|
-
authenticate: DailyReportAuthenticate
|
|
626
|
-
/**
|
|
627
|
-
* 可視 sourceType リゾルバ。未注入なら全件 (現行互換)。
|
|
628
|
-
* 全データ経路 (一覧 / 詳細 / コメント / 添付 / SSE) に一貫して効き、例外は全拒否として扱う。
|
|
629
|
-
*/
|
|
630
|
-
resolveVisibleSourceTypes?: DailyReportResolveVisibleSourceTypes
|
|
631
|
-
/** データアクセスサービス。 */
|
|
632
|
-
service: DailyReportService
|
|
633
|
-
/** 内部数値 ID の難読化ポート。 */
|
|
634
|
-
encodeUserId: DailyReportEncodeUserId
|
|
635
|
-
/**
|
|
636
|
-
* 添付 ID の難読化コーデック。未注入なら添付エンドポイントは常に 404。
|
|
637
|
-
* ユーザー ID 用とは別インスタンスを渡すこと (ID 空間の分離)。
|
|
638
|
-
*/
|
|
639
|
-
attachmentIdCodec?: DailyReportIdCodec
|
|
640
|
-
/** 添付ファイル読み取りポート。未注入なら添付エンドポイントは常に 404。 */
|
|
641
|
-
readAttachment?: DailyReportReadAttachment
|
|
642
|
-
/** 添付ファイルの最大バイト数 (既定 32 MiB)。サービス側のワイヤ上限より低く保つこと。 */
|
|
643
|
-
attachmentMaxBytes?: number
|
|
644
|
-
/** 添付エンドポイントの 1 分あたり呼び出し上限 (ユーザー単位・既定 60)。 */
|
|
645
|
-
attachmentRateLimitPerMinute?: number
|
|
646
|
-
/** 添付読み取りの同時実行上限 (プロセス単位・既定 4)。 */
|
|
647
|
-
attachmentConcurrency?: number
|
|
648
|
-
/** SSE 用 redis プロバイダー (catch-up の xRange / xRevRange に使用)。 */
|
|
649
|
-
redis?: DailyReportRedisProvider
|
|
650
|
-
/** SSE Fan-Out 共有リーダー。 */
|
|
651
|
-
sseReader: DailyReportSseReader
|
|
652
|
-
/** SSE Redis Stream キー。 */
|
|
653
|
-
streamKey: string
|
|
654
|
-
/** 未ログイン時のリダイレクト先 (index.loader 用、既定 "/auth/login")。 */
|
|
655
|
-
loginRedirectPath?: string
|
|
656
|
-
/**
|
|
657
|
-
* `intent=clearCache` (全サーバーキャッシュの一括破棄) を許可するか。既定 false。
|
|
658
|
-
* 開発用アフォーダンスであり、有効化すると認証済みユーザーなら誰でも全ワーカーの
|
|
659
|
-
* キャッシュを無制限に破棄できるため、本番では必ず無効のままにする
|
|
660
|
-
* (消費アプリは開発環境でのみ true を注入する)。
|
|
661
|
-
*/
|
|
662
|
-
enableDevCacheClear?: boolean
|
|
663
|
-
/** ロガー (既定は console ベース)。 */
|
|
664
|
-
logger?: DailyReportLogger
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/**
|
|
668
|
-
* Creates the daily-report loaders/actions bound to the injected dependencies.
|
|
669
|
-
* 注入依存に束縛された日報 loader / action 群を生成する処理。
|
|
670
|
-
*/
|
|
671
|
-
export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
672
|
-
const { authenticate, service, encodeUserId, redis, sseReader, streamKey } = config
|
|
673
|
-
const loginRedirectPath = config.loginRedirectPath ?? "/auth/login"
|
|
674
|
-
const apiLogger = config.logger ?? createLogger(LogLevel.ERROR, "[DailyReportAPI]")
|
|
675
|
-
const visibilityLogger = config.logger ?? createLogger(LogLevel.ERROR, "[DailyReportVisibility]")
|
|
676
|
-
const sseLogger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportSSE]")
|
|
677
|
-
const attachmentLogger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportAttachment]")
|
|
678
|
-
|
|
679
|
-
// 添付配信のプロセス内状態。ファクトリ 1 インスタンスにつき 1 組。
|
|
680
|
-
const attachmentMaxBytes = config.attachmentMaxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES
|
|
681
|
-
const consumeAttachmentRateToken = createRateLimiter(config.attachmentRateLimitPerMinute ?? DEFAULT_ATTACHMENT_RATE_LIMIT_PER_MINUTE)
|
|
682
|
-
const attachmentGate = createConcurrencyGate(config.attachmentConcurrency ?? DEFAULT_ATTACHMENT_CONCURRENCY)
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* Resolves and normalizes the visible source-type set once per request (fail-closed).
|
|
686
|
-
* リクエストごとに可視 sourceType 集合を 1 回だけ解決・正規化する処理 (fail-close)。
|
|
687
|
-
*
|
|
688
|
-
* ポート未注入なら `null` (無制限・現行互換)。ポートが例外を投げたら**空集合 (全拒否)**へ倒す。
|
|
689
|
-
* 認可ストアの障害時に無制限へ倒れないための唯一の防波堤であり、ここを緩めてはならない。
|
|
690
|
-
*
|
|
691
|
-
* @param request Incoming request. 受信したリクエスト。
|
|
692
|
-
* @param reason Why it is being resolved. 解決理由 (SSE の定期再解決は "refresh")。
|
|
693
|
-
* @returns Normalized visible set. 正規化済みの可視集合。
|
|
694
|
-
*/
|
|
695
|
-
const resolveViewerScope = async (request: Request, reason: DailyReportVisibilityResolveReason = "initial"): Promise<DailyReportViewerScope> => {
|
|
696
|
-
const port = config.resolveVisibleSourceTypes
|
|
697
|
-
if (!port) {
|
|
698
|
-
return DailyReportViewerScope.unrestricted("no visibility port injected: this deployment does not restrict by source type")
|
|
699
|
-
}
|
|
700
|
-
try {
|
|
701
|
-
const resolved = await port(request, { reason })
|
|
702
|
-
// ❗ ポートが nullish を返したら「この配備は区分で絞らない」という契約上の fail-open。
|
|
703
|
-
// restrictTo へ渡して黙って無制限化すると、監査 grep に現れない 2 つ目の
|
|
704
|
-
// 無制限生成経路になるため、ここで明示的に unrestricted として記録する
|
|
705
|
-
return resolved == null ? DailyReportViewerScope.unrestricted("visibility port returned null/undefined: this deployment opts out of source-type filtering") : DailyReportViewerScope.restrictTo(resolved)
|
|
706
|
-
} catch (e) {
|
|
707
|
-
visibilityLogger.error("resolveVisibleSourceTypes threw; failing closed (deny all)", e)
|
|
708
|
-
return DailyReportViewerScope.restrictTo([])
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// ---------------- index (画面ルート) ----------------
|
|
713
|
-
|
|
714
|
-
/**
|
|
715
|
-
* Document loader that authenticates and resolves the obfuscated internal user id.
|
|
716
|
-
* 認証と難読化済み内部ユーザー ID の解決を行うドキュメントローダー。
|
|
717
|
-
*/
|
|
718
|
-
const indexLoader = async ({ request }: LoaderArgs) => {
|
|
719
|
-
const { user, cookie } = await authenticate(request, { failureRedirect: loginRedirectPath })
|
|
720
|
-
const headers = new Headers()
|
|
721
|
-
if (cookie) {
|
|
722
|
-
headers.append("Set-Cookie", cookie)
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// 文字列モード (`failureRedirect: loginRedirectPath`) では認証失敗が redirect として
|
|
726
|
-
// throw されるため `user` は必ず存在する (`DailyReportAuthenticate` のオーバーロードも
|
|
727
|
-
// そう宣言している)。`if (user)` は到達しないので置かない
|
|
728
|
-
const userId: number | null = await service.getUserIdByExternalId(user.id)
|
|
729
|
-
|
|
730
|
-
const hashedUserId = userId ? encodeUserId(userId) : null
|
|
731
|
-
|
|
732
|
-
return { data: { user, userId: hashedUserId }, headers }
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// ---------------- api (:endpoint ルーター) ----------------
|
|
736
|
-
|
|
737
|
-
type User = { id: string }
|
|
738
|
-
type EndpointHandler = (url: URL, cookie: string | null, request: Request, user: User, scope: DailyReportViewerScope) => Promise<Response>
|
|
739
|
-
|
|
740
|
-
/**
|
|
741
|
-
* Handlers for each API endpoint.
|
|
742
|
-
* 各 API エンドポイントのハンドラー定義。
|
|
743
|
-
*/
|
|
744
|
-
const endpointHandlers: Record<string, EndpointHandler> = {
|
|
745
|
-
/**
|
|
746
|
-
* Retrieves daily reports for a specific business date.
|
|
747
|
-
* 指定された営業日の日報一覧を取得する。
|
|
748
|
-
*/
|
|
749
|
-
"business-date": async (url, cookie, request, user, scope) => {
|
|
750
|
-
const normalizedBusinessDate = normalizeBusinessDateKey(url.searchParams.get("businessDate"))
|
|
751
|
-
const forceRefresh = url.searchParams.get("forceRefresh") === "true"
|
|
752
|
-
|
|
753
|
-
if (!normalizedBusinessDate) {
|
|
754
|
-
return jsonResponseWithETag(request, cookie, { error: { message: "Invalid business date" } }, 400)
|
|
755
|
-
}
|
|
756
|
-
const reports = await service.getDailyReportsByBusinessDateByExternalId(scope, normalizedBusinessDate, user.id, { forceRefresh })
|
|
757
|
-
return jsonResponseWithETag(request, cookie, { businessDate: normalizedBusinessDate, reports }, 200)
|
|
758
|
-
},
|
|
759
|
-
/**
|
|
760
|
-
* Streams the full daily report id list as NDJSON chunks.
|
|
761
|
-
* 日報 ID 一覧全件を NDJSON チャンクとして逐次配信する。
|
|
762
|
-
*
|
|
763
|
-
* 全量クエリ (`SqlResultCache` の TTL / Redis epoch / in-flight コアレッシングを
|
|
764
|
-
* 素通しで維持) は常に即時に開始する。全量確定を待ってから送出するのは再開接続のみで、
|
|
765
|
-
* その場合の DB エラーはストリーム開始前に throw されて apiLoader の catch が
|
|
766
|
-
* 500 JSON を返す。新規接続はキャッシュ無しの TOP 200 高速先頭ページと競争させ、
|
|
767
|
-
* キャッシュミス時は全量確定前に最初のチャンクを送出する (最初の描画までの時間が
|
|
768
|
-
* 全体データ量に依存しない)。キャッシュヒット時は全量が即勝ちし、従来どおり
|
|
769
|
-
* 確定済み配列のメモリスライス送出になる。
|
|
770
|
-
*/
|
|
771
|
-
"ids-stream": async (url, cookie, request, user, scope) => {
|
|
772
|
-
const parsed = parseIdsStreamCursor(url)
|
|
773
|
-
if ("invalid" in parsed) {
|
|
774
|
-
return jsonResponseWithETag(request, cookie, { error: { message: "Invalid cursor" } }, 400)
|
|
775
|
-
}
|
|
776
|
-
const forceRefresh = url.searchParams.get("forceRefresh") === "true"
|
|
777
|
-
|
|
778
|
-
// 高速先頭ページは「本リクエストが実際に全量クエリを起動した」ときだけ走らせる。
|
|
779
|
-
// ❗ この因果が B* の健全性の土台である。バケットヒット・進行中フェッチへの相乗り・
|
|
780
|
-
// refresh-ahead が返す全量配列は本リクエストより**古くなり得る**ため、そこへ
|
|
781
|
-
// 新鮮な先頭ページを重ねると「ページに在って全量に無い行 = 削除済み」の判定が
|
|
782
|
-
// 反転し、作成直後の日報を消してしまう。合図の後に始まるページは全量より古く
|
|
783
|
-
// なり得ないので、この判定が初めて健全になる。
|
|
784
|
-
// 副次効果として、キャッシュヒット時に走って捨てられていた TOP-N クエリが消える
|
|
785
|
-
let pageQueryStarted = false
|
|
786
|
-
let resolvePage: ((items: DailyReportItem[]) => void) | null = null
|
|
787
|
-
let rejectPage: ((reason?: unknown) => void) | null = null
|
|
788
|
-
const pagePromise = new Promise<DailyReportItem[]>((resolve, reject) => {
|
|
789
|
-
resolvePage = resolve
|
|
790
|
-
rejectPage = reject
|
|
791
|
-
})
|
|
792
|
-
// 未処理拒否警告を出さない (拒否はレース側で分類する)
|
|
793
|
-
pagePromise.catch(() => {})
|
|
794
|
-
|
|
795
|
-
// 全量 (SqlResultCache / epoch / コアレッシング維持) は常に即時に走らせ始める
|
|
796
|
-
const fullArrayPromise = service.getDailyReportIdsByExternalId(scope, user.id, {
|
|
797
|
-
forceRefresh,
|
|
798
|
-
// 再開接続は先頭ページを使わない (クライアントは受信済みの続きだけを読む)
|
|
799
|
-
onFetchStart: parsed.cursor
|
|
800
|
-
? undefined
|
|
801
|
-
: () => {
|
|
802
|
-
pageQueryStarted = true
|
|
803
|
-
service.getDailyReportIdsFirstPageByExternalId(scope, user.id, IDS_STREAM_FIRST_CHUNK_SIZE).then(
|
|
804
|
-
(items) => resolvePage?.(items),
|
|
805
|
-
(error) => rejectPage?.(error),
|
|
806
|
-
)
|
|
807
|
-
},
|
|
808
|
-
})
|
|
809
|
-
// 拒否はイテレーターの await 側で扱う (ここで未処理拒否警告を出さない)
|
|
810
|
-
fullArrayPromise.catch(() => {})
|
|
811
|
-
|
|
812
|
-
if (parsed.cursor) {
|
|
813
|
-
// 再開接続: クライアントは高速先頭を受信済みなので全量確定を待つ (エラーは 500)。
|
|
814
|
-
// 全量クエリはアプリ側の requestTimeout (既定 60s) で境界づけられ、確定後は
|
|
815
|
-
// スライスだけなので、接続確立後の無音はハートビート対象にならない
|
|
816
|
-
const fullItems = await fullArrayPromise
|
|
817
|
-
const snapshot = computeIdsStreamSnapshotToken(fullItems)
|
|
818
|
-
// ❗ トークンが一致した配列にだけカーソルを信用する。別スナップショットへ当てると、
|
|
819
|
-
// カーソルより前に入った行が丸ごと飛ぶ (再接続のたびの欠損)。不一致・トークン
|
|
820
|
-
// 無し (旧クライアント) は先頭から流し直す — 配備跨ぎタブの再接続が全量再送に
|
|
821
|
-
// なる費用は受容済み (仕様書に明記)
|
|
822
|
-
const resumable = parsed.snapshot !== null && parsed.snapshot === snapshot
|
|
823
|
-
return buildIdsStreamResponse(cookie, streamLinesFromFullArray(fullItems, resumable ? parsed.cursor : null, IDS_STREAM_FIRST_CHUNK_SIZE, { snapshot, restarted: !resumable }), request, apiLogger)
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
/**
|
|
827
|
-
* 新規接続: 「高速先頭ページ (TOP n・キャッシュ無し)」と「全量 (キャッシュ経由)」を
|
|
828
|
-
* 競争させ、先に確定した方で最初のチャンクを出す。キャッシュヒット時はページ自体が
|
|
829
|
-
* 起動しないので全量が唯一の経路になる (従来どおり先頭行に total)。キャッシュミス時は
|
|
830
|
-
* 先頭ページが先に届き、最初の描画は全量クエリの確定を待たない。
|
|
831
|
-
*
|
|
832
|
-
* ❗ 先頭ページは**暫定 (provisional)** として送る。全量配列とは別スナップショットなので
|
|
833
|
-
* 「観測した」と扱うと、送出後に消えた行が完走剪定を免れて幽霊行になる。暫定行は
|
|
834
|
-
* クライアントが描画はするが観測集合へ刻まないため、権威パスに現れなかった行が
|
|
835
|
-
* 完走時に正確に落ちる。権威パスは**必ず先頭から**流し直すので、ページ送出後に
|
|
836
|
-
* 先頭領域へ入ってきた行の取りこぼし (欠損) も構造的に起きない。
|
|
837
|
-
*/
|
|
838
|
-
const lineIterator = (async function* (): AsyncGenerator<DailyReportIdsStreamChunkLine> {
|
|
839
|
-
const winner = await Promise.race([
|
|
840
|
-
fullArrayPromise.then(
|
|
841
|
-
(items) => ({ kind: "full" as const, items }),
|
|
842
|
-
(error) => ({ kind: "full-error" as const, error }),
|
|
843
|
-
),
|
|
844
|
-
pagePromise.then(
|
|
845
|
-
(items) => ({ kind: "page" as const, items }),
|
|
846
|
-
() => ({ kind: "page-error" as const }),
|
|
847
|
-
),
|
|
848
|
-
])
|
|
849
|
-
|
|
850
|
-
let fullItems: DailyReportItem[] | null = null
|
|
851
|
-
let emittedProvisionalPage = false
|
|
852
|
-
|
|
853
|
-
if (winner.kind === "full") {
|
|
854
|
-
fullItems = winner.items
|
|
855
|
-
} else if (winner.kind === "page") {
|
|
856
|
-
const pageItems = winner.items
|
|
857
|
-
if (pageItems.length < IDS_STREAM_FIRST_CHUNK_SIZE) {
|
|
858
|
-
// 先頭ページが上限未満 = データセット全量。しかもページは全量クエリより
|
|
859
|
-
// 後に始まっているので、これが最新の権威である。全量を待たずに完結する。
|
|
860
|
-
// ❗ ここを provisional にしてはならない (小規模データセットが完走剪定で全消しになる)
|
|
861
|
-
yield { kind: "chunk", items: pageItems, nextCursor: null, isLast: true, total: pageItems.length }
|
|
862
|
-
return
|
|
863
|
-
}
|
|
864
|
-
// ❗ カーソルは載せない。未確認領域を指すカーソルで再開すると、継ぎ目が
|
|
865
|
-
// 三つ目のスナップショットへ移るだけになる。失敗時は先頭からやり直す
|
|
866
|
-
yield { kind: "chunk", items: pageItems, nextCursor: null, isLast: false, provisional: true }
|
|
867
|
-
emittedProvisionalPage = true
|
|
868
|
-
} else if (winner.kind === "full-error" && pageQueryStarted) {
|
|
869
|
-
// 全量が先に死んでも、生きている先頭ページがあればまず届ける (暫定として)。
|
|
870
|
-
// 完走しない走査では剪定が走らないため、暫定行はそのまま残って画面を埋める
|
|
871
|
-
const pageItems = await pagePromise.catch(() => null)
|
|
872
|
-
if (pageItems && pageItems.length > 0) {
|
|
873
|
-
yield { kind: "chunk", items: pageItems, nextCursor: null, isLast: false, provisional: true }
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
// page-error は全量へフォールバック。full-error はここで throw されエラー行になる
|
|
877
|
-
if (fullItems === null) {
|
|
878
|
-
fullItems = await fullArrayPromise
|
|
879
|
-
}
|
|
880
|
-
// ❗ 権威パスは常に index 0 から。ページのキーを再開カーソルとして使うと、
|
|
881
|
-
// ページ送出後に先頭領域へ入った行が丸ごと飛ばされる (欠損の構造的原因)。
|
|
882
|
-
// チャンクサイズは明示指定する — 三項演算子の既定へ落とすと、ページを出した
|
|
883
|
-
// 経路で権威パスの初回チャンクが黙って先頭サイズへ縮み、行数が無駄に増える
|
|
884
|
-
yield* streamLinesFromFullArray(fullItems, null, emittedProvisionalPage ? IDS_STREAM_NEXT_CHUNK_SIZE : IDS_STREAM_FIRST_CHUNK_SIZE, { snapshot: computeIdsStreamSnapshotToken(fullItems) })
|
|
885
|
-
})()
|
|
886
|
-
|
|
887
|
-
return buildIdsStreamResponse(cookie, lineIterator, request, apiLogger)
|
|
888
|
-
},
|
|
889
|
-
/**
|
|
890
|
-
* Retrieves details for a specific daily report.
|
|
891
|
-
* 指定された日報の詳細情報を取得する。
|
|
892
|
-
*/
|
|
893
|
-
report: async (url, cookie, request, user, scope) => {
|
|
894
|
-
const param = url.searchParams.get("reportHubId")
|
|
895
|
-
const forceRefresh = url.searchParams.get("forceRefresh") === "true"
|
|
896
|
-
const parsedId = param ? Number.parseInt(param, 10) : NaN
|
|
897
|
-
|
|
898
|
-
if (!Number.isFinite(parsedId) || parsedId <= 0) {
|
|
899
|
-
return jsonResponseWithETag(request, cookie, { error: { message: "Invalid reportHubId" } }, 400)
|
|
900
|
-
}
|
|
901
|
-
const detail = await service.getDailyReportDetailByIdByExternalId(scope, parsedId, user.id, { snapshot: true, forceRefresh })
|
|
902
|
-
if (!detail) {
|
|
903
|
-
return jsonResponseWithETag(request, cookie, { error: { message: "Report not found" } }, 404)
|
|
904
|
-
}
|
|
905
|
-
return jsonResponseWithETag(request, cookie, { report: detail }, 200)
|
|
906
|
-
},
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
/**
|
|
910
|
-
* Routes authenticated daily report API requests by endpoint.
|
|
911
|
-
* エンドポイントごとに認証済みの日報 API リクエストを振り分ける。
|
|
912
|
-
*/
|
|
913
|
-
const apiLoader = async ({ request, params }: LoaderArgs) => {
|
|
914
|
-
const { user, cookie } = await authenticate(request, { failureRedirect: null })
|
|
915
|
-
const sanitizedCookie = cookie ?? null
|
|
916
|
-
|
|
917
|
-
if (!user) {
|
|
918
|
-
return jsonResponseWithETag(request, sanitizedCookie, { error: { message: "Unauthorized" } }, 401)
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
if (request.method !== "GET") {
|
|
922
|
-
return jsonResponseWithETag(request, sanitizedCookie, { error: { message: "Method not allowed" } }, 405)
|
|
923
|
-
}
|
|
924
|
-
const endpoint = params.endpoint ?? ""
|
|
925
|
-
const handler = endpointHandlers[endpoint]
|
|
926
|
-
if (!handler) {
|
|
927
|
-
return jsonResponseWithETag(request, sanitizedCookie, { error: { message: "Unknown endpoint" } }, 404)
|
|
928
|
-
}
|
|
929
|
-
const startTime = Date.now()
|
|
930
|
-
try {
|
|
931
|
-
const url = new URL(request.url)
|
|
932
|
-
// 可視集合はリクエストごとに 1 回だけ解決する (キャッシュキーが戻り値に依存する)
|
|
933
|
-
const scope = await resolveViewerScope(request)
|
|
934
|
-
return await handler(url, sanitizedCookie, request, user, scope)
|
|
935
|
-
} catch (error) {
|
|
936
|
-
const elapsed = Date.now() - startTime
|
|
937
|
-
const err = error instanceof Error ? error : new Error(String(error))
|
|
938
|
-
apiLogger.error(`500 endpoint=${endpoint} elapsed=${elapsed}ms name=${err.name} code=${"code" in err ? (err as { code: unknown }).code : "N/A"} message=${err.message}`)
|
|
939
|
-
apiLogger.error("Stack:", err.stack)
|
|
940
|
-
return jsonResponseWithETag(request, sanitizedCookie, { error: { message: "Internal Server Error" } }, 500)
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
/**
|
|
945
|
-
* Handles data mutations for daily reports.
|
|
946
|
-
* 日報データの変更操作を処理するアクション。
|
|
947
|
-
*/
|
|
948
|
-
const apiAction = async ({ request, params }: LoaderArgs) => {
|
|
949
|
-
const { user, cookie } = await authenticate(request, { failureRedirect: null })
|
|
950
|
-
|
|
951
|
-
/**
|
|
952
|
-
* Builds a JSON response that always carries the session cookie for this request.
|
|
953
|
-
* 本リクエストのセッション cookie を必ず載せた JSON レスポンスの生成。
|
|
954
|
-
*
|
|
955
|
-
* 全分岐 (成功・4xx・未認証) で転送する必要がある。トークン更新の cookie を落とすとブラウザは
|
|
956
|
-
* 期限切れトークンを持ち続け、以後のリクエストが毎回リフレッシュを試みて収束しない。
|
|
957
|
-
* 破棄 cookie を落とせば無効セッションがブラウザに残り続ける。個別の `jsonData` へ付け回すと
|
|
958
|
-
* 必ず取りこぼすため、この関数以外を本ハンドラ内で使わない。
|
|
959
|
-
*
|
|
960
|
-
* @param payload Response payload / レスポンス本文
|
|
961
|
-
* @param init Status override / ステータス指定
|
|
962
|
-
* @returns JSON response carrying the session cookie / セッション cookie を載せた JSON レスポンス
|
|
963
|
-
*/
|
|
964
|
-
const respond = (payload: unknown, init?: { status?: number }): Response => jsonData(payload, { ...init, setCookie: cookie })
|
|
965
|
-
|
|
966
|
-
if (!user) {
|
|
967
|
-
return respond({ error: "Unauthorized" }, { status: 401 })
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
// apiAction には外側 try/catch が無く、`respond` を通らない例外 (service / DB / Redis の
|
|
971
|
-
// 失敗) がハンドラの外へ抜けていた。抜けるとセッション cookie が載らないまま 500 になり、
|
|
972
|
-
// トークン更新がブラウザへ届かない。apiLoader (:930) と同じ形で包む
|
|
973
|
-
const actionStartTime = Date.now()
|
|
974
|
-
try {
|
|
975
|
-
const endpoint = params.endpoint
|
|
976
|
-
if (endpoint !== "action") {
|
|
977
|
-
return respond({ error: "Unknown endpoint" }, { status: 404 })
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
const userId = await service.getUserIdByExternalId(user.id)
|
|
981
|
-
if (!userId) {
|
|
982
|
-
return respond({ error: "User not found" }, { status: 404 })
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// 可視集合はリクエストごとに 1 回だけ解決し、他者の日報に作用する 4 種へ渡す
|
|
986
|
-
// (create / update / publish / delete / clearCache は所有者・開発者ゲートで別管理)
|
|
987
|
-
const scope = await resolveViewerScope(request)
|
|
988
|
-
|
|
989
|
-
const formData = await request.formData()
|
|
990
|
-
const intent = formData.get("intent")
|
|
991
|
-
const reportHubIdRaw = formData.get("reportHubId")
|
|
992
|
-
const reportHubId = reportHubIdRaw ? Number(reportHubIdRaw) : NaN
|
|
993
|
-
const businessDate = formData.get("businessDate") as string | null
|
|
994
|
-
const operationTimestamp = Number(formData.get("operationTimestamp"))
|
|
995
|
-
const clientTempId = formData.get("clientTempId") as string | null
|
|
996
|
-
|
|
997
|
-
if (intent === "clearCache") {
|
|
998
|
-
// 開発専用アフォーダンス。既定は封鎖 (認証済みなら誰でも全ワーカーの
|
|
999
|
-
// キャッシュを無制限破棄できてしまうため、注入で明示的に開けた環境のみ許可)
|
|
1000
|
-
if (!config.enableDevCacheClear) {
|
|
1001
|
-
return respond({ error: "Invalid intent" }, { status: 400 })
|
|
1002
|
-
}
|
|
1003
|
-
await service.clearCache()
|
|
1004
|
-
return respond({ status: "OK", intent: "clearCache" })
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
if (!clientTempId) {
|
|
1008
|
-
return respond({ error: "clientTempId required" }, { status: 400 })
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
if (intent !== "create" && (!reportHubId || Number.isNaN(reportHubId))) {
|
|
1012
|
-
return respond({ error: "Invalid reportHubId" }, { status: 400 })
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
/**
|
|
1016
|
-
* Runs a boolean user-status toggle and maps its outcome onto a response.
|
|
1017
|
-
* 真偽値のユーザー状態トグルを実行し、結果をレスポンスへ写像する処理。
|
|
1018
|
-
*
|
|
1019
|
-
* `toggleStar` と `toggleRead` は必須フィールド名と呼び出すサービスだけが異なるため、
|
|
1020
|
-
* 検証・応答整形・エラー写像をここへ集約する。
|
|
1021
|
-
*
|
|
1022
|
-
* @param intentName Intent echoed back to the client. クライアントへ返す intent 名。
|
|
1023
|
-
* @param fieldName Required boolean form field. 必須の真偽値フォームフィールド名。
|
|
1024
|
-
* @param apply Service call performing the toggle. トグルを実行するサービス呼び出し。
|
|
1025
|
-
* @returns The HTTP response. HTTP レスポンス。
|
|
1026
|
-
*/
|
|
1027
|
-
const runStatusToggle = async (intentName: "toggleStar" | "toggleRead", fieldName: "isStarred" | "isRead", apply: (value: boolean) => Promise<{ isStarred: boolean; isRead: boolean }>): Promise<Response> => {
|
|
1028
|
-
const raw = formData.get(fieldName)
|
|
1029
|
-
if (raw === null) {
|
|
1030
|
-
return respond({ error: `${fieldName} required` }, { status: 400 })
|
|
1031
|
-
}
|
|
1032
|
-
try {
|
|
1033
|
-
const updatedStatus = await apply(raw === "true")
|
|
1034
|
-
// クライアントは isStarred/isRead のみ参照する。生内部 ID (userId/created_by/updated_by) を
|
|
1035
|
-
// 含む行全体は返さず、必要なフラグだけに絞る (内部 ID の自己開示防止)。
|
|
1036
|
-
return respond({ status: "OK", intent: intentName, updatedStatus: { isStarred: updatedStatus.isStarred, isRead: updatedStatus.isRead }, operationTimestamp, reportHubId: String(reportHubId), clientTempId })
|
|
1037
|
-
} catch (e: unknown) {
|
|
1038
|
-
// 不可視区分・不存在・削除済みは 404 へ写像する (未捕捉だと 500 になる)
|
|
1039
|
-
if (e instanceof Error && e.message === "Not Found") {
|
|
1040
|
-
return respond({ error: "Report not found" }, { status: 404 })
|
|
1041
|
-
}
|
|
1042
|
-
throw e
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
switch (intent) {
|
|
1047
|
-
case "create": {
|
|
1048
|
-
if (!businessDate) {
|
|
1049
|
-
return respond({ error: "businessDate required" }, { status: 400 })
|
|
1050
|
-
}
|
|
1051
|
-
const newReport = await service.createDailyReport(userId, businessDate, clientTempId)
|
|
1052
|
-
return respond({
|
|
1053
|
-
status: "OK",
|
|
1054
|
-
intent: "create",
|
|
1055
|
-
report: newReport,
|
|
1056
|
-
reportHubId: String(newReport.reportHubId),
|
|
1057
|
-
clientTempId, // Echo back for validation
|
|
1058
|
-
})
|
|
1059
|
-
}
|
|
1060
|
-
case "update": {
|
|
1061
|
-
const title = formData.get("title") as string | undefined
|
|
1062
|
-
const content = formData.get("content") as string | undefined
|
|
1063
|
-
try {
|
|
1064
|
-
await service.updateDailyReport(reportHubId, userId, { title, content }, clientTempId)
|
|
1065
|
-
return respond({ status: "OK", intent: "update", reportHubId: String(reportHubId), clientTempId })
|
|
1066
|
-
} catch (e: unknown) {
|
|
1067
|
-
if (e instanceof Error) {
|
|
1068
|
-
if (e.message === "Unauthorized") {
|
|
1069
|
-
return respond({ error: "Unauthorized" }, { status: 403 })
|
|
1070
|
-
}
|
|
1071
|
-
if (e.message === "Not Found") {
|
|
1072
|
-
return respond({ error: "Report not found" }, { status: 404 })
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
throw e
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
case "publish": {
|
|
1079
|
-
try {
|
|
1080
|
-
const publishedReport = await service.publishDailyReport(reportHubId, userId, clientTempId)
|
|
1081
|
-
return respond({ status: "OK", intent: "publish", reportHubId: String(reportHubId), clientTempId, report: publishedReport })
|
|
1082
|
-
} catch (e: unknown) {
|
|
1083
|
-
if (e instanceof Error) {
|
|
1084
|
-
if (e.message === "Unauthorized") {
|
|
1085
|
-
return respond({ error: "Unauthorized" }, { status: 403 })
|
|
1086
|
-
}
|
|
1087
|
-
if (e.message === "Not Found") {
|
|
1088
|
-
return respond({ error: "Report not found" }, { status: 404 })
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
throw e
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
case "delete": {
|
|
1095
|
-
try {
|
|
1096
|
-
await service.deleteDailyReport(reportHubId, userId, clientTempId)
|
|
1097
|
-
return respond({
|
|
1098
|
-
status: "OK",
|
|
1099
|
-
intent: "delete",
|
|
1100
|
-
reportHubId: String(reportHubId),
|
|
1101
|
-
clientTempId, // Echo back for validation even on delete
|
|
1102
|
-
})
|
|
1103
|
-
} catch (e: unknown) {
|
|
1104
|
-
if (e instanceof Error) {
|
|
1105
|
-
if (e.message === "Unauthorized") {
|
|
1106
|
-
return respond({ error: "Unauthorized" }, { status: 403 })
|
|
1107
|
-
}
|
|
1108
|
-
if (e.message === "Not Found") {
|
|
1109
|
-
return respond({ error: "Report not found" }, { status: 404 })
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
throw e
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
case "toggleStar":
|
|
1116
|
-
return await runStatusToggle("toggleStar", "isStarred", (value) => service.setStarStatus(scope, userId, reportHubId, businessDate, value, clientTempId))
|
|
1117
|
-
case "toggleRead":
|
|
1118
|
-
return await runStatusToggle("toggleRead", "isRead", (value) => service.setReadStatus(scope, userId, reportHubId, businessDate, value, clientTempId))
|
|
1119
|
-
case "addComment": {
|
|
1120
|
-
const content = formData.get("content") as string
|
|
1121
|
-
if (!content) {
|
|
1122
|
-
return respond({ error: "Content required" }, { status: 400 })
|
|
1123
|
-
}
|
|
1124
|
-
try {
|
|
1125
|
-
const newComment = await service.addComment(scope, userId, reportHubId, content, businessDate, clientTempId)
|
|
1126
|
-
const safeComment = { ...newComment, userId: newComment.userId }
|
|
1127
|
-
return respond({ status: "OK", intent: "addComment", newComment: safeComment, reportHubId: String(reportHubId), clientTempId })
|
|
1128
|
-
} catch (e: unknown) {
|
|
1129
|
-
if (e instanceof Error) {
|
|
1130
|
-
// 削除直後の日報への投稿レース等。500 ではなく意味のある状態コードへ写像する
|
|
1131
|
-
if (e.message === "Not Found") {
|
|
1132
|
-
return respond({ error: "Report not found" }, { status: 404 })
|
|
1133
|
-
}
|
|
1134
|
-
if (e.message.startsWith("Comments are restricted")) {
|
|
1135
|
-
return respond({ error: "Comments are restricted for this report" }, { status: 403 })
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
throw e
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
case "deleteComment": {
|
|
1142
|
-
const commentId = Number(formData.get("commentId"))
|
|
1143
|
-
if (!commentId || Number.isNaN(commentId)) {
|
|
1144
|
-
return respond({ error: "Invalid commentId" }, { status: 400 })
|
|
1145
|
-
}
|
|
1146
|
-
try {
|
|
1147
|
-
await service.deleteComment(scope, userId, reportHubId, commentId, businessDate, clientTempId)
|
|
1148
|
-
return respond({ status: "OK", reportHubId: String(reportHubId), deletedCommentId: String(commentId), clientTempId })
|
|
1149
|
-
} catch (e: unknown) {
|
|
1150
|
-
if (e instanceof Error) {
|
|
1151
|
-
if (e.message === "Unauthorized") {
|
|
1152
|
-
return respond({ error: "Unauthorized" }, { status: 403 })
|
|
1153
|
-
}
|
|
1154
|
-
if (e.message === "Not Found") {
|
|
1155
|
-
return respond({ error: "Comment not found" }, { status: 404 })
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
throw e
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
default:
|
|
1162
|
-
return respond({ error: "Invalid intent" }, { status: 400 })
|
|
1163
|
-
}
|
|
1164
|
-
} catch (error) {
|
|
1165
|
-
// React Router のリダイレクト等は握り潰さず素通しする
|
|
1166
|
-
if (error instanceof Response) throw error
|
|
1167
|
-
const elapsed = Date.now() - actionStartTime
|
|
1168
|
-
const err = error instanceof Error ? error : new Error(String(error))
|
|
1169
|
-
apiLogger.error(`500 action elapsed=${elapsed}ms name=${err.name} message=${err.message}`)
|
|
1170
|
-
apiLogger.error("Stack:", err.stack)
|
|
1171
|
-
return respond({ error: "Internal Server Error" }, { status: 500 })
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
// ---------------- sse (リアルタイム更新) ----------------
|
|
1176
|
-
|
|
1177
|
-
/**
|
|
1178
|
-
* SSE endpoint for real-time daily report updates via Redis Streams.
|
|
1179
|
-
* Redis Streams を使用した日報リアルタイム更新の SSE エンドポイント。
|
|
1180
|
-
*/
|
|
1181
|
-
const sseLoader = async ({ request, params }: LoaderArgs) => {
|
|
1182
|
-
// 認証チェック
|
|
1183
|
-
const { user, cookie } = await authenticate(request, { failureRedirect: null })
|
|
1184
|
-
// セッション cookie は**この応答がどれになろうと**転送する。トークンが更新された
|
|
1185
|
-
// リクエストがたまたま SSE 接続だった場合、ここで落とすと更新後のトークンがブラウザへ
|
|
1186
|
-
// 届かず、以降のリクエストが毎回リフレッシュをやり直すことになる。破棄 cookie を
|
|
1187
|
-
// 落とした場合はさらに悪く、無効セッションがブラウザに残り続ける (apiAction と同じ理由)。
|
|
1188
|
-
// SSE は接続が長命なぶん、更新を担うリクエストになりやすい
|
|
1189
|
-
const sessionHeaders = (extra?: Record<string, string>): Record<string, string> | undefined => {
|
|
1190
|
-
if (cookie === undefined) return extra
|
|
1191
|
-
return { ...(extra ?? {}), "Set-Cookie": cookie }
|
|
1192
|
-
}
|
|
1193
|
-
if (!user) {
|
|
1194
|
-
return new Response("Unauthorized", { status: 401, headers: sessionHeaders() })
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
// 内部ユーザー ID の解決
|
|
1198
|
-
const internalUserId = await service.getUserIdByExternalId(user.id)
|
|
1199
|
-
if (!internalUserId) {
|
|
1200
|
-
return new Response("Forbidden", { status: 403, headers: sessionHeaders() })
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
// エンドポイントの検証
|
|
1204
|
-
if (params.endpoint !== "updates") {
|
|
1205
|
-
return new Response("Not Found", { status: 404, headers: sessionHeaders() })
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
// 可視集合は接続時に解決し、以降 keep-alive tick に相乗りして再解決する (D6)。
|
|
1209
|
-
// 接続生存期間で固定すると、権限失効後も本文付きイベントが届き続けてしまう。
|
|
1210
|
-
// ❗ 集合はここで 1 度だけ取り出して保持する。`scope.visible` / `scope.commentable` は
|
|
1211
|
-
// 毎回コピーを返す getter なので、メッセージごとに読むと配信 1 件あたり 2 個の Set を
|
|
1212
|
-
// 確保することになる (共有 Fan-Out の高頻度経路)
|
|
1213
|
-
const initialScope = await resolveViewerScope(request)
|
|
1214
|
-
let visibleSet = initialScope.visible
|
|
1215
|
-
let commentableSet = initialScope.commentable
|
|
1216
|
-
let keepAliveTick = 0
|
|
1217
|
-
let resolveGeneration = 0
|
|
1218
|
-
|
|
1219
|
-
// URL から lastEventId を取得 (再接続時の catch-up 用)
|
|
1220
|
-
// クエリパラメータ優先、EventSource 自動再接続時の Last-Event-ID ヘッダーにフォールバック
|
|
1221
|
-
const url = new URL(request.url)
|
|
1222
|
-
const lastEventId = url.searchParams.get("lastEventId") || request.headers.get("Last-Event-ID")
|
|
1223
|
-
|
|
1224
|
-
const encoder = new TextEncoder()
|
|
1225
|
-
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
1226
|
-
let keepAliveInterval: ReturnType<typeof setInterval> | null = null
|
|
1227
|
-
let unsubscribe: (() => void) | null = null
|
|
1228
|
-
let isCleaningUp = false
|
|
1229
|
-
|
|
1230
|
-
// クリーンアップ処理
|
|
1231
|
-
const cleanup = () => {
|
|
1232
|
-
if (isCleaningUp) return
|
|
1233
|
-
isCleaningUp = true
|
|
1234
|
-
|
|
1235
|
-
// keep-alive タイマー停止
|
|
1236
|
-
if (keepAliveInterval) {
|
|
1237
|
-
clearInterval(keepAliveInterval)
|
|
1238
|
-
keepAliveInterval = null
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
// Fan-Out 購読解除 (entry / error 両リスナーを解除)
|
|
1242
|
-
if (unsubscribe) {
|
|
1243
|
-
unsubscribe()
|
|
1244
|
-
unsubscribe = null
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
// SSE ストリームの終了
|
|
1248
|
-
if (controllerRef) {
|
|
1249
|
-
try {
|
|
1250
|
-
if (controllerRef.desiredSize !== null) {
|
|
1251
|
-
controllerRef.close()
|
|
1252
|
-
}
|
|
1253
|
-
} catch (_e) {
|
|
1254
|
-
// ストリームが既に閉じている場合は無視
|
|
1255
|
-
} finally {
|
|
1256
|
-
controllerRef = null
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
isCleaningUp = false
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
/**
|
|
1264
|
-
* Processes a single Redis Stream entry and sends it to the SSE client.
|
|
1265
|
-
* Redis Stream のエントリを処理し、SSE クライアントに送信する。
|
|
1266
|
-
*/
|
|
1267
|
-
/**
|
|
1268
|
-
* Emits a content-free frame that only advances the client's resume anchor.
|
|
1269
|
-
* 内容を伴わず、クライアントの再開アンカーだけを前進させるフレームを送出する処理。
|
|
1270
|
-
*
|
|
1271
|
-
* 種別は `connected` を使う。クライアントは既にこの種別を無視しつつ `lastEventId` を
|
|
1272
|
-
* 更新するため、フィルタで落としたエントリの ID を安全に伝播できる。
|
|
1273
|
-
*
|
|
1274
|
-
* @param entryId Redis Stream entry id. Redis Stream のエントリ ID。
|
|
1275
|
-
* @returns Nothing. なし。
|
|
1276
|
-
*/
|
|
1277
|
-
const emitAnchorOnly = (entryId: string): void => {
|
|
1278
|
-
if (controllerRef && controllerRef.desiredSize !== null) {
|
|
1279
|
-
controllerRef.enqueue(encoder.encode(`id: ${entryId}\ndata: ${JSON.stringify({ type: "connected" })}\n\n`))
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
const processEntry = (entryId: string, fields: Record<string, string>) => {
|
|
1284
|
-
// data フィールドの存在チェック
|
|
1285
|
-
if (!fields.data) return
|
|
1286
|
-
|
|
1287
|
-
try {
|
|
1288
|
-
const raw = JSON.parse(fields.data)
|
|
1289
|
-
const result = dailyReportSseMessageSchema.safeParse(raw)
|
|
1290
|
-
if (!result.success) {
|
|
1291
|
-
sseLogger.error("SSE message validation failed:", result.error.format())
|
|
1292
|
-
return // Fail-Closed: 不正なメッセージは送信しない
|
|
1293
|
-
}
|
|
1294
|
-
const parsed = result.data
|
|
1295
|
-
|
|
1296
|
-
// recipientRawUserId によるフィルタリング
|
|
1297
|
-
const recipientRawUserId = typeof raw.recipientRawUserId === "number" ? raw.recipientRawUserId : undefined
|
|
1298
|
-
|
|
1299
|
-
if (parsed.type === "status-update") {
|
|
1300
|
-
if (recipientRawUserId === undefined || recipientRawUserId !== internalUserId) {
|
|
1301
|
-
return
|
|
1302
|
-
}
|
|
1303
|
-
} else if (recipientRawUserId !== undefined && recipientRawUserId !== internalUserId) {
|
|
1304
|
-
return
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
// 区分の可視性フィルタ (宛先フィルタを通過した後に適用する)
|
|
1308
|
-
if (!isSseMessageSourceTypeVisible(parsed, raw, visibleSet, commentableSet)) {
|
|
1309
|
-
// ❗ 内容は送らないが、再開アンカーだけは前へ進める。クライアントの
|
|
1310
|
-
// `lastEventId` は配信されたイベントでしか更新されないため、落とし続けると
|
|
1311
|
-
// アンカーが古いまま固定され、再接続のたびに同じ範囲を読み直す
|
|
1312
|
-
// (かつ catch-up の COUNT 上限を超えた分を取りこぼしうる)。
|
|
1313
|
-
// `connected` はクライアントが既に無視する種別なので、内容は漏れない
|
|
1314
|
-
emitAnchorOnly(entryId)
|
|
1315
|
-
return
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
// コメント次元が拒否する区分なら、埋め込み detail のコメント面だけを空にする
|
|
1319
|
-
const redacted = redactSseReportComments(parsed, raw, commentableSet)
|
|
1320
|
-
|
|
1321
|
-
// recipientRawUserId をクライアントに送信しない (内部 ID 漏洩防止)
|
|
1322
|
-
let sanitizedMessage: string
|
|
1323
|
-
if ("recipientRawUserId" in raw) {
|
|
1324
|
-
const { recipientRawUserId: _, ...rest } = raw
|
|
1325
|
-
sanitizedMessage = JSON.stringify(rest)
|
|
1326
|
-
} else if (redacted) {
|
|
1327
|
-
// ❗ 秘匿したら必ず再直列化する。元の生文字列を送る高速経路のままだと
|
|
1328
|
-
// 書き換えが捨てられ、コメント本文がそのまま流れる
|
|
1329
|
-
sanitizedMessage = JSON.stringify(raw)
|
|
1330
|
-
} else {
|
|
1331
|
-
sanitizedMessage = fields.data
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
// SSE フォーマットで送信 (id フィールド付き)
|
|
1335
|
-
if (controllerRef && controllerRef.desiredSize !== null) {
|
|
1336
|
-
controllerRef.enqueue(encoder.encode(`id: ${entryId}\ndata: ${sanitizedMessage}\n\n`))
|
|
1337
|
-
}
|
|
1338
|
-
} catch (e) {
|
|
1339
|
-
sseLogger.error(`[SSE:${internalUserId}] processEntry error:`, e)
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
const stream = new ReadableStream({
|
|
1344
|
-
async start(controller) {
|
|
1345
|
-
// 1. controllerRef 設定
|
|
1346
|
-
controllerRef = controller
|
|
1347
|
-
|
|
1348
|
-
// 2. Keep-Alive: 5 秒ごとにコメントを送信して接続維持
|
|
1349
|
-
keepAliveInterval = setInterval(() => {
|
|
1350
|
-
try {
|
|
1351
|
-
if (controllerRef && controllerRef.desiredSize !== null) {
|
|
1352
|
-
controllerRef.enqueue(encoder.encode(": keep-alive\n\n"))
|
|
1353
|
-
// 約 60 秒ごと (12 tick) に可視集合を再解決し、権限失効を有界化する。
|
|
1354
|
-
// 世代ガード: 順序が逆転した古い解決結果で最新を上書きしない
|
|
1355
|
-
// (上書きすると広い集合へ戻り fail-open 方向に倒れる)。
|
|
1356
|
-
// resolveVisibleSet 自身が fail-close なので catch は不要
|
|
1357
|
-
if (++keepAliveTick % SSE_VISIBILITY_REFRESH_TICKS === 0) {
|
|
1358
|
-
const generation = ++resolveGeneration
|
|
1359
|
-
resolveViewerScope(request, "refresh").then((next) => {
|
|
1360
|
-
if (generation === resolveGeneration) {
|
|
1361
|
-
// 両次元の集合を同一スコープから同時に差し替える。
|
|
1362
|
-
// 片方だけ更新すると次元間で世代がずれ、失効が半分しか効かない
|
|
1363
|
-
visibleSet = next.visible
|
|
1364
|
-
commentableSet = next.commentable
|
|
1365
|
-
}
|
|
1366
|
-
})
|
|
1367
|
-
}
|
|
1368
|
-
} else {
|
|
1369
|
-
cleanup()
|
|
1370
|
-
}
|
|
1371
|
-
} catch (_e) {
|
|
1372
|
-
cleanup()
|
|
1373
|
-
}
|
|
1374
|
-
}, 5000)
|
|
1375
|
-
|
|
1376
|
-
// 3. クライアント切断時のクリーンアップ (全 await の前に登録)
|
|
1377
|
-
request.signal.addEventListener("abort", () => {
|
|
1378
|
-
cleanup()
|
|
1379
|
-
})
|
|
1380
|
-
|
|
1381
|
-
// catch-up の基準 ID (Fan-Out エントリとの重複排除に使用)
|
|
1382
|
-
let lastProcessedId = lastEventId || "0-0"
|
|
1383
|
-
|
|
1384
|
-
/**
|
|
1385
|
-
* Fan-Out がライブ配信した最初のエントリ ID。catch-up の重複排除に使う。
|
|
1386
|
-
*
|
|
1387
|
-
* ❗ catch-up の判定に `lastProcessedId` を使ってはならない。`xRange` を待つ間に
|
|
1388
|
-
* ライブエントリが 1 件でも届くと共有の高水位が catch-up 範囲の先まで進み、
|
|
1389
|
-
* 「既に処理済み」と誤判定して**取得した範囲を丸ごと捨てて**しまう。
|
|
1390
|
-
* ライブで配ったのは「この ID 以降」だけなので、その境界で判定する。
|
|
1391
|
-
*/
|
|
1392
|
-
let firstLiveEntryId: string | null = null
|
|
1393
|
-
|
|
1394
|
-
// 4. Fan-Out 共有リーダーを購読 (catch-up より先に登録して取りこぼしを防止)
|
|
1395
|
-
unsubscribe = sseReader.subscribe(
|
|
1396
|
-
(entry: StreamEntry) => {
|
|
1397
|
-
// catch-up 済みエントリはスキップ (重複排除)
|
|
1398
|
-
if (isStreamIdLte(entry.id, lastProcessedId)) return
|
|
1399
|
-
if (firstLiveEntryId === null) {
|
|
1400
|
-
firstLiveEntryId = entry.id
|
|
1401
|
-
}
|
|
1402
|
-
lastProcessedId = entry.id
|
|
1403
|
-
processEntry(entry.id, entry.message)
|
|
1404
|
-
},
|
|
1405
|
-
(err: Error) => {
|
|
1406
|
-
sseLogger.error(`[SSE:${internalUserId}] Fan-Out reader error:`, err)
|
|
1407
|
-
cleanup()
|
|
1408
|
-
},
|
|
1409
|
-
)
|
|
1410
|
-
|
|
1411
|
-
// 5. 初回接続時: 最新 Stream ID を取得して connected の id: に使用
|
|
1412
|
-
// (再接続時は lastEventId が既にあるためスキップ)
|
|
1413
|
-
if (!lastEventId) {
|
|
1414
|
-
try {
|
|
1415
|
-
const client = await redis?.getClient()
|
|
1416
|
-
if (client) {
|
|
1417
|
-
const latest = await client.xRevRange(streamKey, "+", "-", { COUNT: 1 })
|
|
1418
|
-
// Fan-Out が既に lastProcessedId を進めている場合は巻き戻さない
|
|
1419
|
-
if (latest.length > 0 && !isStreamIdLte(latest[0].id, lastProcessedId)) {
|
|
1420
|
-
lastProcessedId = latest[0].id
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
} catch {
|
|
1424
|
-
/* 取得失敗時は現在の lastProcessedId を維持 */
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
// 6. 接続完了通知 (id: 付きで再接続時の catch-up アンカーを提供)
|
|
1429
|
-
if (controllerRef && controllerRef.desiredSize !== null) {
|
|
1430
|
-
const connectedPayload = `data: ${JSON.stringify({ type: "connected" })}\n\n`
|
|
1431
|
-
const sseMessage = lastProcessedId !== "0-0" ? `id: ${lastProcessedId}\n${connectedPayload}` : connectedPayload
|
|
1432
|
-
controllerRef.enqueue(encoder.encode(sseMessage))
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
// 7. catch-up: lastEventId が指定されている場合、それ以降のエントリを一括取得
|
|
1436
|
-
if (lastEventId) {
|
|
1437
|
-
try {
|
|
1438
|
-
const client = await redis?.getClient()
|
|
1439
|
-
if (client) {
|
|
1440
|
-
const catchUpEntries = await client.xRange(streamKey, lastEventId, "+", { COUNT: 1000 })
|
|
1441
|
-
for (const entry of catchUpEntries) {
|
|
1442
|
-
// xRange は inclusive なので lastEventId 自身はスキップ
|
|
1443
|
-
if (entry.id === lastEventId) continue
|
|
1444
|
-
// Fan-Out がライブ配信した境界以降は重複するのでスキップ。
|
|
1445
|
-
// 共有の高水位ではなくこの境界で判定することで、await 中に届いた
|
|
1446
|
-
// ライブ 1 件が catch-up 範囲全体を捨てさせる事故を防ぐ
|
|
1447
|
-
if (firstLiveEntryId !== null && isStreamIdLte(firstLiveEntryId, entry.id)) continue
|
|
1448
|
-
// 高水位は後退させない (ライブが先行している場合がある)
|
|
1449
|
-
if (!isStreamIdLte(entry.id, lastProcessedId)) {
|
|
1450
|
-
lastProcessedId = entry.id
|
|
1451
|
-
}
|
|
1452
|
-
processEntry(entry.id, entry.message as Record<string, string>)
|
|
1453
|
-
}
|
|
1454
|
-
}
|
|
1455
|
-
} catch (e) {
|
|
1456
|
-
sseLogger.error(`[SSE:${internalUserId}] catch-up xRange error:`, e)
|
|
1457
|
-
// catch-up 失敗時は Fan-Out のみで継続
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
},
|
|
1461
|
-
cancel() {
|
|
1462
|
-
cleanup()
|
|
1463
|
-
},
|
|
1464
|
-
})
|
|
1465
|
-
|
|
1466
|
-
return new Response(stream, {
|
|
1467
|
-
headers: sessionHeaders({
|
|
1468
|
-
"Content-Type": "text/event-stream",
|
|
1469
|
-
"Cache-Control": "no-cache, no-transform",
|
|
1470
|
-
Connection: "keep-alive",
|
|
1471
|
-
}),
|
|
1472
|
-
})
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
// ---------------- attachment (認可済みバイト配信) ----------------
|
|
1476
|
-
|
|
1477
|
-
/**
|
|
1478
|
-
* Streams an authorized attachment's bytes to the client.
|
|
1479
|
-
* 認可済みの添付ファイルをバイト列としてクライアントへ返すローダー。
|
|
1480
|
-
*
|
|
1481
|
-
* 認可は `service.getAttachmentForUser` の SQL 述語で完結させる。ストレージ側の
|
|
1482
|
-
* 資格情報はサービス共通のものであり、利用者間の分離を一切提供しない。
|
|
1483
|
-
* したがってこの述語が唯一の認可境界であり、ここに欠陥があれば全テナント横断の読み取りになる。
|
|
1484
|
-
*
|
|
1485
|
-
* 「存在しない」と「認可されない」は区別せず 404 にする (存在オラクル回避)。
|
|
1486
|
-
*
|
|
1487
|
-
* 本体は必ず try/catch で包む。ここで漏らした例外はフレームワークの最終防衛線に落ち、
|
|
1488
|
-
* JSON ではないプレーンテキストの 500 になるうえ `Set-Cookie` (セッション延長) も失われる。
|
|
1489
|
-
* **認証ポートの呼び出しも try の内側**に置くこと。認証実装はセッション復号や warmup の
|
|
1490
|
-
* 失敗で素の例外を投げうるため、外に置くと同じ穴が残る。ただしリダイレクトは `Response` を
|
|
1491
|
-
* throw する正常な制御フローなので、catch では素通しする。
|
|
1492
|
-
*/
|
|
1493
|
-
const attachmentLoader = async ({ request, params }: LoaderArgs) => {
|
|
1494
|
-
let sanitizedCookie: string | null = null
|
|
1495
|
-
|
|
1496
|
-
/** エラー応答。`Set-Cookie` はすべての分岐で伝播させる。 */
|
|
1497
|
-
const fail = (status: number, message: string, retryAfterSeconds?: number): Response => {
|
|
1498
|
-
const headers = new Headers({ "Content-Type": "application/json" })
|
|
1499
|
-
if (retryAfterSeconds !== undefined) headers.set("Retry-After", String(retryAfterSeconds))
|
|
1500
|
-
if (sanitizedCookie) headers.append("Set-Cookie", sanitizedCookie)
|
|
1501
|
-
return new Response(JSON.stringify({ error: { message } }), { status, headers })
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
try {
|
|
1505
|
-
const { user, cookie } = await authenticate(request, { failureRedirect: null })
|
|
1506
|
-
sanitizedCookie = cookie ?? null
|
|
1507
|
-
|
|
1508
|
-
if (!user) return fail(401, "Unauthorized")
|
|
1509
|
-
if (request.method !== "GET" && request.method !== "HEAD") return fail(405, "Method not allowed")
|
|
1510
|
-
|
|
1511
|
-
const codec = config.attachmentIdCodec
|
|
1512
|
-
const readAttachment = config.readAttachment
|
|
1513
|
-
// ポート未注入は「この配備に添付機能が無い」であり、認可失敗と区別しない
|
|
1514
|
-
if (!(codec && readAttachment)) return fail(404, "Attachment not found")
|
|
1515
|
-
|
|
1516
|
-
const token = params.token ?? ""
|
|
1517
|
-
const attachmentId = codec.decode(token)
|
|
1518
|
-
// `Number.isSafeInteger` であること。可逆難読化は入力トークンの正準性を検証しないため、
|
|
1519
|
-
// 正規トークンと同じ長さの入力から 2^53 を超える値が復号されうる。それを SQL パラメータへ
|
|
1520
|
-
// 渡すとドライバーの範囲検証が TypeError を投げ、400 のはずが 500 になる。
|
|
1521
|
-
if (attachmentId === null || !Number.isSafeInteger(attachmentId) || attachmentId <= 0) {
|
|
1522
|
-
return fail(400, "Invalid attachment token")
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
const internalUserId = await service.getUserIdByExternalId(user.id)
|
|
1526
|
-
if (!internalUserId) return fail(403, "Forbidden")
|
|
1527
|
-
|
|
1528
|
-
// レート制限。トークンは列挙可能なので、これが実効的な唯一の緩和策
|
|
1529
|
-
if (!consumeAttachmentRateToken(internalUserId, Date.now())) {
|
|
1530
|
-
attachmentLogger.error(`429 attachment=${attachmentId} viewer=${internalUserId} reason=rate_limit`)
|
|
1531
|
-
return fail(429, "Too many requests", RATE_LIMIT_RETRY_AFTER_SECONDS)
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
// 認可判定 (SQL 側で完結)。null は「存在しない」「認可されない」「親が論理削除済み」の合流
|
|
1535
|
-
// 可視集合はこの try の内側で解決する (例外封じ込め規約の維持)
|
|
1536
|
-
const scope = await resolveViewerScope(request)
|
|
1537
|
-
const attachment = await service.getAttachmentForUser(scope, attachmentId, internalUserId)
|
|
1538
|
-
if (!attachment) {
|
|
1539
|
-
attachmentLogger.error(`404 attachment=${attachmentId} viewer=${internalUserId} reason=not_visible`)
|
|
1540
|
-
return fail(404, "Attachment not found")
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
// DB が既知サイズを持つ場合は読み取り前に弾く (サイズを報告しない取込元の行はここを通過する)
|
|
1544
|
-
if (attachment.fileSize != null && attachment.fileSize > attachmentMaxBytes) {
|
|
1545
|
-
attachmentLogger.error(`413 attachment=${attachmentId} viewer=${internalUserId} reason=db_size`)
|
|
1546
|
-
return fail(413, "Attachment too large")
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
if (!attachmentGate.tryAcquire()) {
|
|
1550
|
-
attachmentLogger.error(`503 attachment=${attachmentId} viewer=${internalUserId} reason=concurrency`)
|
|
1551
|
-
return fail(503, "Too many concurrent downloads", CONCURRENCY_RETRY_AFTER_SECONDS)
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
// スロットの解放は冪等にする。早期 return・例外・送出完了のどの経路からでも
|
|
1555
|
-
// ちょうど 1 回だけ返す必要がある
|
|
1556
|
-
let gateReleased = false
|
|
1557
|
-
const releaseGate = () => {
|
|
1558
|
-
if (gateReleased) return
|
|
1559
|
-
gateReleased = true
|
|
1560
|
-
attachmentGate.release()
|
|
1561
|
-
}
|
|
1562
|
-
// 本文の送出完了まで保持する経路に入ったかどうか (入った場合のみ finally での解放を見送る)
|
|
1563
|
-
let releaseDeferredToStream = false
|
|
1564
|
-
|
|
1565
|
-
try {
|
|
1566
|
-
const isHead = request.method === "HEAD"
|
|
1567
|
-
const result = await readAttachment(attachment.filePath, { maxBytes: attachmentMaxBytes, head: isHead, principal: encodeUserId(internalUserId) })
|
|
1568
|
-
|
|
1569
|
-
if (!result.ok) {
|
|
1570
|
-
const status = attachmentFailureToStatus(result.reason)
|
|
1571
|
-
// 実体消失は次回描画へ反映する。記録の失敗は配信結果に影響させない
|
|
1572
|
-
// (await しないので、拒否を捕まえないと未処理 Promise 拒否になる)
|
|
1573
|
-
if (result.reason === "not_found") {
|
|
1574
|
-
void service.markAttachmentMissing(attachmentId).catch((error: unknown) => {
|
|
1575
|
-
attachmentLogger.error(`markAttachmentMissing failed attachment=${attachmentId} message=${error instanceof Error ? error.message : "unknown"}`)
|
|
1576
|
-
})
|
|
1577
|
-
}
|
|
1578
|
-
// file_path / URI は絶対にログへ出さない (内部パス非露出の方針と整合させる)
|
|
1579
|
-
attachmentLogger.error(`${status} attachment=${attachmentId} viewer=${internalUserId} reason=${result.reason} code=${result.code ?? "-"}`)
|
|
1580
|
-
return fail(status, ATTACHMENT_FAILURE_MESSAGES[status] ?? "Attachment storage unavailable")
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
// インライン許可リスト外は宣言型ごと octet-stream へ落とす。構文検証だけでは
|
|
1584
|
-
// text/html や image/svg+xml の自オリジン実行を止められない
|
|
1585
|
-
const declared = sanitizeMediaType(attachment.fileType) ?? sanitizeMediaType(result.contentType) ?? "application/octet-stream"
|
|
1586
|
-
const inlineSafe = INLINE_SAFE_MEDIA_TYPES.has(declared)
|
|
1587
|
-
const wantsDownload = new URL(request.url).searchParams.get("download") === "1"
|
|
1588
|
-
const contentType = inlineSafe ? declared : "application/octet-stream"
|
|
1589
|
-
const disposition = inlineSafe && !wantsDownload ? "inline" : "attachment"
|
|
1590
|
-
|
|
1591
|
-
// HEAD は本体を読まないので `bytes` が空になる。RFC 9110 §9.3.2 が要求する
|
|
1592
|
-
// 「GET と同じ Content-Length」を満たすため、ポートが申告した実サイズを優先する
|
|
1593
|
-
const contentLength = result.size ?? result.bytes.byteLength
|
|
1594
|
-
const headers = new Headers({
|
|
1595
|
-
"Content-Type": contentType,
|
|
1596
|
-
"Content-Length": String(contentLength),
|
|
1597
|
-
"Content-Disposition": `${disposition}; filename="${asciiFallbackFileName(attachment.fileName)}"; filename*=UTF-8''${encodeRfc8187(attachment.fileName)}`,
|
|
1598
|
-
"Cache-Control": "private, no-store",
|
|
1599
|
-
"X-Content-Type-Options": "nosniff",
|
|
1600
|
-
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
1601
|
-
"X-Frame-Options": "SAMEORIGIN",
|
|
1602
|
-
})
|
|
1603
|
-
if (sanitizedCookie) headers.append("Set-Cookie", sanitizedCookie)
|
|
1604
|
-
|
|
1605
|
-
// 実体を確認できたので消失記録を解除する。これが唯一の自動回復経路であり、
|
|
1606
|
-
// 片方向のままだと一時障害で立った absent を利用者が自力で戻せない
|
|
1607
|
-
// (UI は absent の添付にリンクを描画しないため再取得の手段が消える)
|
|
1608
|
-
void service.markAttachmentPresent(attachmentId).catch((error: unknown) => {
|
|
1609
|
-
attachmentLogger.error(`markAttachmentPresent failed attachment=${attachmentId} message=${error instanceof Error ? error.message : "unknown"}`)
|
|
1610
|
-
})
|
|
1611
|
-
|
|
1612
|
-
attachmentLogger.info(`200 attachment=${attachmentId} viewer=${internalUserId} type=${contentType} disposition=${disposition} bytes=${contentLength}`)
|
|
1613
|
-
// HEAD は本文を持たないので即座に解放する。GET は送出完了まで保持し、
|
|
1614
|
-
// 「同時実行上限 × 上限バイト数」というヒープ見積りを実際に成立させる
|
|
1615
|
-
if (isHead) {
|
|
1616
|
-
return new Response(null, { status: 200, headers })
|
|
1617
|
-
}
|
|
1618
|
-
releaseDeferredToStream = true
|
|
1619
|
-
return new Response(streamAndRelease(result.bytes, releaseGate, ATTACHMENT_BODY_FLUSH_TIMEOUT_MS), { status: 200, headers })
|
|
1620
|
-
} finally {
|
|
1621
|
-
// 送出へ引き渡した場合を除き、早期 return でも例外でも必ずここで返す
|
|
1622
|
-
if (!releaseDeferredToStream) releaseGate()
|
|
1623
|
-
}
|
|
1624
|
-
} catch (error) {
|
|
1625
|
-
// 認証ポートはリダイレクトを Response として throw する。これは正常な制御フローなので素通しする
|
|
1626
|
-
if (error instanceof Response) throw error
|
|
1627
|
-
attachmentLogger.error(`500 attachment=? viewer=? reason=unexpected message=${error instanceof Error ? error.message : "unknown"}`)
|
|
1628
|
-
return fail(500, "Attachment request failed")
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
return {
|
|
1633
|
-
index: { loader: indexLoader },
|
|
1634
|
-
api: { loader: apiLoader, action: apiAction },
|
|
1635
|
-
sse: { loader: sseLoader },
|
|
1636
|
-
attachment: { loader: attachmentLoader },
|
|
1637
|
-
}
|
|
1638
|
-
}
|