@aiquants/daily-report 0.6.2 → 0.8.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 +13 -6
- package/dist/client.d.mts +443 -23
- package/dist/client.d.ts +443 -23
- package/dist/client.js +5 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +77 -3
- package/dist/index.d.ts +77 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +436 -6
- package/dist/server.d.ts +436 -6
- package/dist/server.js +8 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +8 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
- package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
- package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
- package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
- package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
- package/src/client/components/daily-report-attachment-list.tsx +112 -0
- package/src/client/components/daily-report-detail-list.tsx +14 -7
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
- package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
- package/src/client/components/daily-report-list.tsx +19 -11
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +56 -68
- package/src/client/components/daily-report-resolved-content.tsx +31 -7
- package/src/client/components/report-views.spec.tsx +340 -0
- package/src/client/config-context.tsx +8 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
- package/src/client/contexts/daily-report-action-context.tsx +151 -52
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -0
- package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
- package/src/client/hooks/use-responsive-layout.ts +56 -0
- package/src/client/route-helpers.spec.ts +100 -0
- package/src/client/route-helpers.ts +20 -41
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
- package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
- package/src/client/utils/constants.spec.ts +96 -0
- package/src/client/utils/constants.ts +47 -2
- package/src/client.ts +6 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.attachment.spec.ts +470 -0
- package/src/server/handlers.ids-stream.spec.ts +362 -0
- package/src/server/handlers.ts +671 -6
- package/src/server/ports.ts +74 -0
- package/src/server/schema.ts +80 -5
- package/src/server/service.spec.ts +280 -14
- package/src/server/service.ts +348 -25
- package/src/server/test-helpers/handlers-config.ts +181 -0
- package/src/server.ts +16 -1
- package/src/shared/ids-stream.ts +92 -0
- package/src/shared/sse-schema.spec.ts +50 -0
- package/src/shared/sse-schema.ts +27 -0
- package/src/shared/types.ts +31 -0
package/src/server/handlers.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* - sse.loader: Redis Streams ベースのリアルタイム更新 SSE エンドポイント
|
|
8
8
|
*/
|
|
9
9
|
import { normalizeBusinessDateKey } from "../shared/business-date"
|
|
10
|
+
import { type DailyReportIdsStreamChunkLine, type DailyReportIdsStreamCursor, findIdsStreamResumeIndex } from "../shared/ids-stream"
|
|
10
11
|
import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
|
|
11
12
|
import { dailyReportSseMessageSchema } from "../shared/sse-schema"
|
|
12
|
-
import type {
|
|
13
|
+
import type { DailyReportItem } from "../shared/types"
|
|
14
|
+
import type { DailyReportAttachmentFailure, DailyReportAuthenticate, DailyReportEncodeUserId, DailyReportIdCodec, DailyReportReadAttachment, DailyReportRedisProvider } from "./ports"
|
|
13
15
|
import { jsonResponseWithETag } from "./response"
|
|
14
16
|
import type { DailyReportService } from "./service"
|
|
15
17
|
import type { DailyReportSseReader, StreamEntry } from "./sse-reader"
|
|
@@ -24,6 +26,421 @@ const jsonData = (payload: unknown, init?: { status?: number }): Response =>
|
|
|
24
26
|
headers: { "Content-Type": "application/json" },
|
|
25
27
|
})
|
|
26
28
|
|
|
29
|
+
// ---------------- ids NDJSON ストリームの定数とヘルパー (純粋・モジュールスコープ) ----------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* First-chunk size, tuned for time-to-first-render.
|
|
33
|
+
* 初回チャンクのアイテム数。最初の描画までの時間を優先して小さく保つ。
|
|
34
|
+
*
|
|
35
|
+
* 一覧の可視行 + オーバースキャンを一度に満たせる件数であればよい。
|
|
36
|
+
*/
|
|
37
|
+
const IDS_STREAM_FIRST_CHUNK_SIZE = 200
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 2 チャンク目以降のアイテム数。1 アイテムが数十バイト (id/日付/種別のみ) と軽いため
|
|
41
|
+
* 大きめに運ぶ。実データは 20 万件超に達するため、小さすぎると行数 (= pull 往復と
|
|
42
|
+
* クライアント側 publish 回数) が肥大する (4000 件 ≈ 240 KB/行、20 万件で約 50 行)。
|
|
43
|
+
*/
|
|
44
|
+
const IDS_STREAM_NEXT_CHUNK_SIZE = 4000
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sanitized message for mid-stream failures (no internal details).
|
|
48
|
+
* ストリーム送出中の失敗をクライアントへ伝える定型文 (内部情報は載せない)。
|
|
49
|
+
*/
|
|
50
|
+
const IDS_STREAM_ERROR_CLIENT_MESSAGE = "stream interrupted by server error"
|
|
51
|
+
|
|
52
|
+
/** 再開カーソルの営業日として受け付ける唯一の形式 (正規化済み YYYY-MM-DD)。 */
|
|
53
|
+
const IDS_STREAM_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parses and validates resume-cursor query params (fail-closed).
|
|
57
|
+
* 再開カーソルのクエリパラメータを検証付きで解釈する処理 (不正は fail-closed)。
|
|
58
|
+
*
|
|
59
|
+
* - `cursor_report_hub_id` 単独 = 「営業日なし領域」のカーソル (businessDate: null)。
|
|
60
|
+
* - `cursor_business_date` は `cursor_report_hub_id` 無しでは意味を持たないため 400。
|
|
61
|
+
*
|
|
62
|
+
* @param url Request URL. リクエスト URL。
|
|
63
|
+
* @returns Cursor (null = 先頭から) か、検証失敗の印。
|
|
64
|
+
*/
|
|
65
|
+
const parseIdsStreamCursor = (url: URL): { cursor: DailyReportIdsStreamCursor | null } | { invalid: true } => {
|
|
66
|
+
const rawId = url.searchParams.get("cursor_report_hub_id")
|
|
67
|
+
const rawDate = url.searchParams.get("cursor_business_date")
|
|
68
|
+
|
|
69
|
+
if (rawId === null) {
|
|
70
|
+
// 営業日だけのカーソルは並び順上の位置を決められない
|
|
71
|
+
return rawDate === null ? { cursor: null } : { invalid: true }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const reportHubId = Number.parseInt(rawId, 10)
|
|
75
|
+
if (!Number.isSafeInteger(reportHubId) || String(reportHubId) !== rawId || reportHubId <= 0) {
|
|
76
|
+
return { invalid: true }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (rawDate === null) {
|
|
80
|
+
return { cursor: { businessDate: null, reportHubId } }
|
|
81
|
+
}
|
|
82
|
+
if (!IDS_STREAM_DATE_PATTERN.test(rawDate)) {
|
|
83
|
+
return { invalid: true }
|
|
84
|
+
}
|
|
85
|
+
return { cursor: { businessDate: rawDate, reportHubId } }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Emits a finalized id array as chunked NDJSON lines from a resume position.
|
|
90
|
+
* 確定済み ID 配列を再開位置からチャンク行として送出するジェネレーター。
|
|
91
|
+
*
|
|
92
|
+
* `total` は最初に yield する行へ載せる (= 接続内で全量が確定した最初の行。
|
|
93
|
+
* キャッシュヒットの新規接続と再開接続では先頭行、高速先頭ページ経路では 2 行目)。
|
|
94
|
+
*
|
|
95
|
+
* @param items Stream-ordered full array. ストリーム順の全量配列。
|
|
96
|
+
* @param resumeKey Resume position (null = 先頭から)。
|
|
97
|
+
* @param firstChunkSize このジェネレーターの最初のチャンクサイズ。
|
|
98
|
+
*/
|
|
99
|
+
async function* streamLinesFromFullArray(items: DailyReportItem[], resumeKey: DailyReportIdsStreamCursor | null, firstChunkSize = IDS_STREAM_FIRST_CHUNK_SIZE): AsyncGenerator<DailyReportIdsStreamChunkLine> {
|
|
100
|
+
let position = resumeKey ? findIdsStreamResumeIndex(items, resumeKey) : 0
|
|
101
|
+
let isFirstYield = true
|
|
102
|
+
while (true) {
|
|
103
|
+
const chunkItems = items.slice(position, position + (isFirstYield ? firstChunkSize : IDS_STREAM_NEXT_CHUNK_SIZE))
|
|
104
|
+
position += chunkItems.length
|
|
105
|
+
const isLast = position >= items.length
|
|
106
|
+
const lastItem = chunkItems[chunkItems.length - 1]
|
|
107
|
+
yield {
|
|
108
|
+
items: chunkItems,
|
|
109
|
+
nextCursor: isLast || !lastItem ? null : { businessDate: lastItem.businessDate, reportHubId: lastItem.reportHubId },
|
|
110
|
+
isLast,
|
|
111
|
+
...(isFirstYield ? { total: items.length } : {}),
|
|
112
|
+
}
|
|
113
|
+
isFirstYield = false
|
|
114
|
+
if (isLast) return
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Wraps a line iterator into a pull-based NDJSON streaming Response.
|
|
120
|
+
* 行イテレーターを pull 駆動の NDJSON ストリーミング Response へ包む処理。
|
|
121
|
+
*
|
|
122
|
+
* - 切断検知時はイテレーターを畳んで (`return()`) DB 待ちを解放し、close する
|
|
123
|
+
* (素の return は desiredSize > 0 のまま pull が再帰する)。
|
|
124
|
+
* - イテレーターの throw は内部情報を含まない定型エラー行 + 正常クローズへ倒す
|
|
125
|
+
* (クライアントは「isLast 無し終端」をカーソル再開のシグナルとして扱う)。
|
|
126
|
+
*/
|
|
127
|
+
const buildIdsStreamResponse = (cookie: string | null, lines: AsyncGenerator<DailyReportIdsStreamChunkLine>, request: Request, logger: DailyReportLogger): Response => {
|
|
128
|
+
const encoder = new TextEncoder()
|
|
129
|
+
// cancel() の lines.return() は「pull が DB 待ちの next() を保留中」だと先取りできない。
|
|
130
|
+
// 待ちが明けた後の enqueue が閉じたストリームへ落ちて catch に入るため、切断は
|
|
131
|
+
// フラグで決定的に識別し、通常イベントとして静かに畳む (ERROR ログにしない —
|
|
132
|
+
// 全量クエリ中の離脱は日常イベントで、本物の送出失敗が雑音に埋もれる)
|
|
133
|
+
let cancelled = false
|
|
134
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
135
|
+
async pull(controller) {
|
|
136
|
+
if (cancelled || request.signal.aborted) {
|
|
137
|
+
await lines.return(undefined).catch(() => {})
|
|
138
|
+
try {
|
|
139
|
+
controller.close()
|
|
140
|
+
} catch {
|
|
141
|
+
// cancel 済みコントローラーの close は失敗してよい
|
|
142
|
+
}
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const { value, done } = await lines.next()
|
|
147
|
+
if (done) {
|
|
148
|
+
controller.close()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
// 保留中の next() の間に切断された場合はここで検知して静かに終える
|
|
152
|
+
if (cancelled || request.signal.aborted) {
|
|
153
|
+
await lines.return(undefined).catch(() => {})
|
|
154
|
+
try {
|
|
155
|
+
controller.close()
|
|
156
|
+
} catch {
|
|
157
|
+
// cancel 済みコントローラーの close は失敗してよい
|
|
158
|
+
}
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`))
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (cancelled || request.signal.aborted) {
|
|
164
|
+
// 切断起因の enqueue/close 失敗はエラーではない
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
logger.error(`ids-stream emission failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
168
|
+
try {
|
|
169
|
+
controller.enqueue(encoder.encode(`${JSON.stringify({ error: IDS_STREAM_ERROR_CLIENT_MESSAGE })}\n`))
|
|
170
|
+
controller.close()
|
|
171
|
+
} catch {
|
|
172
|
+
// 既に切断済みなら何もできない
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
cancel() {
|
|
177
|
+
cancelled = true
|
|
178
|
+
void lines.return(undefined).catch(() => {})
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const headers = new Headers({
|
|
183
|
+
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
184
|
+
// no-transform: compression ミドルウェア/中間プロキシによるバッファリングを禁止する (SSE と同じ理由)
|
|
185
|
+
"Cache-Control": "private, no-cache, no-transform",
|
|
186
|
+
"X-Content-Type-Options": "nosniff",
|
|
187
|
+
})
|
|
188
|
+
if (cookie) {
|
|
189
|
+
headers.append("Set-Cookie", cookie)
|
|
190
|
+
}
|
|
191
|
+
return new Response(stream, { status: 200, headers })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------- 添付配信の定数とヘルパー (純粋・モジュールスコープ) ----------------
|
|
195
|
+
|
|
196
|
+
/** 添付 1 件あたりの既定上限。サービス側のワイヤ上限 (既定 64 MiB) より必ず低く保つ。 */
|
|
197
|
+
const DEFAULT_ATTACHMENT_MAX_BYTES = 32 * 1024 * 1024
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Default per-user rate limit (calls per minute), enforced per process.
|
|
201
|
+
* ユーザー単位の既定レート上限 (1 分あたり)。ただし**プロセス単位**で計上する。
|
|
202
|
+
*
|
|
203
|
+
* バケットはハンドラーのクロージャに置く素の `Map` であり、プロセス間で共有されない。
|
|
204
|
+
* 消費アプリが Node Cluster 等で複数ワーカーを起動する場合、コンテナ全体の実効上限は
|
|
205
|
+
* この値のワーカー数倍になる。全体で厳密に効かせたいなら共有ストア (Redis 等) が要る。
|
|
206
|
+
*/
|
|
207
|
+
const DEFAULT_ATTACHMENT_RATE_LIMIT_PER_MINUTE = 60
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Default simultaneous-read limit, enforced per process.
|
|
211
|
+
* 既定の同時実行上限。**プロセス単位**で計上する。
|
|
212
|
+
*
|
|
213
|
+
* 1 リクエストが上限バイト数をヒープへ載せるため小さく保つ。ヒープ見積りは
|
|
214
|
+
* 「この値 × `attachmentMaxBytes` × ワーカー数」であり、ワーカー数を掛け忘れると
|
|
215
|
+
* コンテナのメモリ上限を実際の数倍で見誤る。消費アプリはワーカー数を織り込んだ値を
|
|
216
|
+
* `attachmentConcurrency` で明示注入すること。
|
|
217
|
+
*
|
|
218
|
+
* スロットは**本文をクライアントへ送出し終えるまで**保持する。読み取り完了時点で
|
|
219
|
+
* 解放すると、前の応答のバイト列がヒープに載ったまま次の読み取りが始まるため、
|
|
220
|
+
* 上の見積りが成立しなくなる (実際の同時保持数は無制限になる)。
|
|
221
|
+
*/
|
|
222
|
+
const DEFAULT_ATTACHMENT_CONCURRENCY = 4
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Backstop for releasing a concurrency slot when the client never drains the body.
|
|
226
|
+
* クライアントが本文を読み切らない場合に同時実行スロットを解放するバックストップ。
|
|
227
|
+
*
|
|
228
|
+
* スロットを送出完了まで保持する以上、接続を張ったまま読み止めたクライアントが
|
|
229
|
+
* スロットを永久に占有できてしまう。これは締め切りではなく枯渇防止の保険であり、
|
|
230
|
+
* 超過しても応答は中断しない (単にゲートの会計上、保持されていない扱いにするだけ)。
|
|
231
|
+
*/
|
|
232
|
+
const ATTACHMENT_BODY_FLUSH_TIMEOUT_MS = 120_000
|
|
233
|
+
|
|
234
|
+
/** レート制限テーブルの上限件数。無制限 Map はプロセス寿命の間だけ単調増加するリークになる。 */
|
|
235
|
+
const RATE_LIMITER_MAX_ENTRIES = 1024
|
|
236
|
+
|
|
237
|
+
/** レート上限超過時に提示する再試行間隔 (秒)。 */
|
|
238
|
+
const RATE_LIMIT_RETRY_AFTER_SECONDS = 60
|
|
239
|
+
|
|
240
|
+
/** 同時実行上限に達したときに提示する再試行間隔 (秒)。 */
|
|
241
|
+
const CONCURRENCY_RETRY_AFTER_SECONDS = 5
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Media types allowed to render inline in the browser.
|
|
245
|
+
* ブラウザーへインライン表示してよい media type。
|
|
246
|
+
*
|
|
247
|
+
* これ以外は必ず添付ダウンロードにし、Content-Type も `application/octet-stream` へ落とす。
|
|
248
|
+
* `file_type` は外部システム由来の未検証値であり、`text/html` や `image/svg+xml` を
|
|
249
|
+
* 自オリジンでインライン配信するとセッション Cookie を持つ文脈でスクリプトが動く。
|
|
250
|
+
* `X-Content-Type-Options: nosniff` は「宣言型から外れた推測」を止めるだけで、
|
|
251
|
+
* 宣言された型の実行は止めない。
|
|
252
|
+
*/
|
|
253
|
+
const INLINE_SAFE_MEDIA_TYPES: ReadonlySet<string> = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain"])
|
|
254
|
+
|
|
255
|
+
/** ヘッダー値として安全で、かつ MIME 型の形をしている値だけを通す。 */
|
|
256
|
+
const HEADER_SAFE_PATTERN = /^[\x20-\x7E]+$/
|
|
257
|
+
const MEDIA_TYPE_PATTERN = /^[\w.+-]+\/[\w.+-]+/
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Accepts a media type only when it is header-safe and well-formed.
|
|
261
|
+
* ヘッダー安全かつ MIME 型の形をしている場合のみ採用する処理。
|
|
262
|
+
*
|
|
263
|
+
* CR/LF や 0x00-0xFF 外の文字が混ざった値を `new Headers()` へ渡すと TypeError になり、
|
|
264
|
+
* 認可済みのダウンロードが 500 になる。
|
|
265
|
+
*
|
|
266
|
+
* **これは第 2 層である。** 現在の配信経路では、注入値はインライン許可リストに完全一致しないため
|
|
267
|
+
* 必ず `application/octet-stream` へ落ち、ヘッダーには到達しない。つまり本関数を外しても
|
|
268
|
+
* 現状の挙動は変わらない (ミューテーションテストで確認済み)。
|
|
269
|
+
* 許可判定を前方一致や「DB の型を信じる」形へ変えた瞬間に効き始める防御なので、
|
|
270
|
+
* 到達不能なまま放置せず **直接の単体テストで固定する**目的でエクスポートしている。
|
|
271
|
+
*
|
|
272
|
+
* @param value Candidate media type. 候補となる media type。
|
|
273
|
+
* @returns The value when acceptable, otherwise null. 採用可なら値、それ以外は null。
|
|
274
|
+
*/
|
|
275
|
+
export const sanitizeMediaType = (value: string | null | undefined): string | null => (value && HEADER_SAFE_PATTERN.test(value) && MEDIA_TYPE_PATTERN.test(value) ? value : null)
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Percent-encodes a file name for the RFC 8187 `filename*` parameter.
|
|
279
|
+
* RFC 8187 の `filename*` 用にファイル名をパーセントエンコードする処理。
|
|
280
|
+
*
|
|
281
|
+
* `encodeURIComponent` は `'` `(` `)` `*` を残すが、いずれも RFC 8187 の attr-char ではない。
|
|
282
|
+
*
|
|
283
|
+
* @param name Raw file name. 生のファイル名。
|
|
284
|
+
* @returns Encoded value. エンコード済みの値。
|
|
285
|
+
*/
|
|
286
|
+
export const encodeRfc8187 = (name: string): string => encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Builds the ASCII fallback used by the plain `filename` parameter.
|
|
290
|
+
* 素の `filename` パラメータ用の ASCII フォールバックを組み立てる処理。
|
|
291
|
+
*
|
|
292
|
+
* @param name Raw file name. 生のファイル名。
|
|
293
|
+
* @returns ASCII-only, quote-free name. ASCII のみで引用符を含まない名前。
|
|
294
|
+
*/
|
|
295
|
+
export const asciiFallbackFileName = (name: string): string => name.replace(/[^\x20-\x7E]/g, "_").replace(/["\\]/g, "_")
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Maps a storage read failure onto the HTTP status the endpoint returns.
|
|
299
|
+
* ストレージ読み取りの失敗種別を、エンドポイントが返す HTTP ステータスへ写像する処理。
|
|
300
|
+
*
|
|
301
|
+
* `not_found` と `denied` はどちらも 404 にする。認可判定は既に SQL 側で完了しているため
|
|
302
|
+
* 横断的な情報漏洩は無いが、実体の有無を 403/404 で区別すると存在オラクルになる。
|
|
303
|
+
*
|
|
304
|
+
* @param reason Failure kind reported by the port. ポートが報告した失敗種別。
|
|
305
|
+
* @returns HTTP status code. HTTP ステータスコード。
|
|
306
|
+
*/
|
|
307
|
+
const attachmentFailureToStatus = (reason: DailyReportAttachmentFailure): number => {
|
|
308
|
+
switch (reason) {
|
|
309
|
+
case "not_found":
|
|
310
|
+
case "denied":
|
|
311
|
+
case "invalid_path":
|
|
312
|
+
return 404
|
|
313
|
+
case "too_large":
|
|
314
|
+
return 413
|
|
315
|
+
default:
|
|
316
|
+
return 502
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Client-facing messages per failure status.
|
|
322
|
+
* 失敗ステータスごとのクライアント向けメッセージ。
|
|
323
|
+
*
|
|
324
|
+
* 404 は「存在しない」「認可されない」「実体が消えた」の合流点なので、
|
|
325
|
+
* どの経路から来ても同一の文面にする (差分が存在オラクルになるため)。
|
|
326
|
+
*/
|
|
327
|
+
const ATTACHMENT_FAILURE_MESSAGES: Readonly<Record<number, string>> = {
|
|
328
|
+
404: "Attachment not found",
|
|
329
|
+
413: "Attachment too large",
|
|
330
|
+
502: "Attachment storage unavailable",
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** レート制限 1 ユーザー分の状態。 */
|
|
334
|
+
type RateBucket = { tokens: number; lastRefillMs: number }
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Creates a bounded per-user token-bucket rate limiter (process-local).
|
|
338
|
+
* ユーザー単位のトークンバケット方式レート制限を、件数上限付きで生成する処理 (プロセス内限定)。
|
|
339
|
+
*
|
|
340
|
+
* 上限を設ける理由: 素の `Map` で保持するとプロセス寿命の間だけ単調増加し、
|
|
341
|
+
* 「サーバー側の状態は上限を持つ」という設計方針 (SqlResultCache の >500 GC) に反する。
|
|
342
|
+
* 上限到達時は挿入順が最も古いエントリから落とす。
|
|
343
|
+
*
|
|
344
|
+
* 状態はプロセス内に閉じるため、マルチワーカー配備では実効上限がワーカー数倍になる
|
|
345
|
+
* (`DEFAULT_ATTACHMENT_RATE_LIMIT_PER_MINUTE` 参照)。
|
|
346
|
+
*
|
|
347
|
+
* @param limitPerMinute Allowed calls per minute. 1 分あたりの許可回数。
|
|
348
|
+
* @returns A function that consumes one token and reports whether it was allowed. トークンを 1 つ消費し可否を返す関数。
|
|
349
|
+
*/
|
|
350
|
+
const createRateLimiter = (limitPerMinute: number) => {
|
|
351
|
+
const buckets = new Map<number, RateBucket>()
|
|
352
|
+
return (userId: number, nowMs: number): boolean => {
|
|
353
|
+
const existing = buckets.get(userId)
|
|
354
|
+
// アクセスのたびに再挿入して LRU 順序にする (Map は挿入順を保つ)
|
|
355
|
+
if (existing) buckets.delete(userId)
|
|
356
|
+
|
|
357
|
+
const bucket = existing ?? { tokens: limitPerMinute, lastRefillMs: nowMs }
|
|
358
|
+
const elapsedMs = Math.max(0, nowMs - bucket.lastRefillMs)
|
|
359
|
+
if (elapsedMs > 0) {
|
|
360
|
+
bucket.tokens = Math.min(limitPerMinute, bucket.tokens + (elapsedMs * limitPerMinute) / 60_000)
|
|
361
|
+
bucket.lastRefillMs = nowMs
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const allowed = bucket.tokens >= 1
|
|
365
|
+
if (allowed) bucket.tokens -= 1
|
|
366
|
+
|
|
367
|
+
buckets.set(userId, bucket)
|
|
368
|
+
while (buckets.size > RATE_LIMITER_MAX_ENTRIES) {
|
|
369
|
+
const oldest = buckets.keys().next()
|
|
370
|
+
if (oldest.done) break
|
|
371
|
+
buckets.delete(oldest.value)
|
|
372
|
+
}
|
|
373
|
+
return allowed
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Creates a fail-fast concurrency gate (no queueing, process-local).
|
|
379
|
+
* 待ち行列を持たない即時失敗型の同時実行ゲートを生成する処理 (プロセス内限定)。
|
|
380
|
+
*
|
|
381
|
+
* 待たせるとリクエストを掴んだまま滞留するだけなので、上限超過は即座に 503 を返す。
|
|
382
|
+
* カウンタはプロセス内に閉じるため、マルチワーカー配備ではコンテナ全体の同時読み取り数が
|
|
383
|
+
* 上限のワーカー数倍になる (`DEFAULT_ATTACHMENT_CONCURRENCY` 参照)。
|
|
384
|
+
*
|
|
385
|
+
* @param limit Maximum simultaneous holders. 同時保持数の上限。
|
|
386
|
+
* @returns Acquire/release pair. 取得と解放の組。
|
|
387
|
+
*/
|
|
388
|
+
const createConcurrencyGate = (limit: number) => {
|
|
389
|
+
let active = 0
|
|
390
|
+
return {
|
|
391
|
+
tryAcquire: (): boolean => {
|
|
392
|
+
if (active >= limit) return false
|
|
393
|
+
active += 1
|
|
394
|
+
return true
|
|
395
|
+
},
|
|
396
|
+
release: (): void => {
|
|
397
|
+
if (active > 0) active -= 1
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Streams already-materialized bytes and releases the held slot once the body is drained.
|
|
404
|
+
* materialize 済みのバイト列をストリームとして送出し、送出完了時に保持中のスロットを解放する処理。
|
|
405
|
+
*
|
|
406
|
+
* 読み取り完了時点で解放してはならない。その時点でバイト列はまだヒープ上にあり、
|
|
407
|
+
* 応答が流し切られるまで参照が保持されるため、「同時実行上限 × 上限バイト数」という
|
|
408
|
+
* ヒープ見積りが成立しなくなる。ここで送出完了まで保持することで見積りを実際に成立させる。
|
|
409
|
+
*
|
|
410
|
+
* 解放は必ず 1 回だけ行う (正常終了・キャンセル・バックストップのいずれか最初の 1 回)。
|
|
411
|
+
*
|
|
412
|
+
* @param bytes Body bytes to send. 送出する本文のバイト列。
|
|
413
|
+
* @param release Idempotent slot release. 冪等なスロット解放関数。
|
|
414
|
+
* @param timeoutMs Backstop before force-releasing. 強制解放までのバックストップ時間。
|
|
415
|
+
* @returns A stream that emits the bytes once. バイト列を 1 度だけ流すストリーム。
|
|
416
|
+
*/
|
|
417
|
+
const streamAndRelease = (bytes: Uint8Array<ArrayBuffer>, release: () => void, timeoutMs: number): ReadableStream<Uint8Array> => {
|
|
418
|
+
const timer: ReturnType<typeof setTimeout> = setTimeout(release, timeoutMs)
|
|
419
|
+
// タイマーがプロセス終了を待たせないようにする (Node 以外では unref を持たない)
|
|
420
|
+
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
421
|
+
const finish = () => {
|
|
422
|
+
clearTimeout(timer)
|
|
423
|
+
release()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let sent = false
|
|
427
|
+
return new ReadableStream<Uint8Array>({
|
|
428
|
+
pull(controller) {
|
|
429
|
+
// 1 回目の pull で本文を積み、消費された後の 2 回目の pull で閉じて解放する
|
|
430
|
+
if (sent) {
|
|
431
|
+
controller.close()
|
|
432
|
+
finish()
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
sent = true
|
|
436
|
+
controller.enqueue(bytes)
|
|
437
|
+
},
|
|
438
|
+
cancel() {
|
|
439
|
+
finish()
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
27
444
|
export type DailyReportHandlersConfig = {
|
|
28
445
|
/** リクエスト認証ポート。 */
|
|
29
446
|
authenticate: DailyReportAuthenticate
|
|
@@ -31,6 +448,19 @@ export type DailyReportHandlersConfig = {
|
|
|
31
448
|
service: DailyReportService
|
|
32
449
|
/** 内部数値 ID の難読化ポート。 */
|
|
33
450
|
encodeUserId: DailyReportEncodeUserId
|
|
451
|
+
/**
|
|
452
|
+
* 添付 ID の難読化コーデック。未注入なら添付エンドポイントは常に 404。
|
|
453
|
+
* ユーザー ID 用とは別インスタンスを渡すこと (ID 空間の分離)。
|
|
454
|
+
*/
|
|
455
|
+
attachmentIdCodec?: DailyReportIdCodec
|
|
456
|
+
/** 添付ファイル読み取りポート。未注入なら添付エンドポイントは常に 404。 */
|
|
457
|
+
readAttachment?: DailyReportReadAttachment
|
|
458
|
+
/** 添付ファイルの最大バイト数 (既定 32 MiB)。サービス側のワイヤ上限より低く保つこと。 */
|
|
459
|
+
attachmentMaxBytes?: number
|
|
460
|
+
/** 添付エンドポイントの 1 分あたり呼び出し上限 (ユーザー単位・既定 60)。 */
|
|
461
|
+
attachmentRateLimitPerMinute?: number
|
|
462
|
+
/** 添付読み取りの同時実行上限 (プロセス単位・既定 4)。 */
|
|
463
|
+
attachmentConcurrency?: number
|
|
34
464
|
/** SSE 用 redis プロバイダー (catch-up の xRange / xRevRange に使用)。 */
|
|
35
465
|
redis?: DailyReportRedisProvider
|
|
36
466
|
/** SSE Fan-Out 共有リーダー。 */
|
|
@@ -52,6 +482,12 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
52
482
|
const loginRedirectPath = config.loginRedirectPath ?? "/auth/login"
|
|
53
483
|
const apiLogger = config.logger ?? createLogger(LogLevel.ERROR, "[DailyReportAPI]")
|
|
54
484
|
const sseLogger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportSSE]")
|
|
485
|
+
const attachmentLogger = config.logger ?? createLogger(LogLevel.INFO, "[DailyReportAttachment]")
|
|
486
|
+
|
|
487
|
+
// 添付配信のプロセス内状態。ファクトリ 1 インスタンスにつき 1 組。
|
|
488
|
+
const attachmentMaxBytes = config.attachmentMaxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES
|
|
489
|
+
const consumeAttachmentRateToken = createRateLimiter(config.attachmentRateLimitPerMinute ?? DEFAULT_ATTACHMENT_RATE_LIMIT_PER_MINUTE)
|
|
490
|
+
const attachmentGate = createConcurrencyGate(config.attachmentConcurrency ?? DEFAULT_ATTACHMENT_CONCURRENCY)
|
|
55
491
|
|
|
56
492
|
// ---------------- index (画面ルート) ----------------
|
|
57
493
|
|
|
@@ -101,13 +537,86 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
101
537
|
return jsonResponseWithETag(request, cookie, { businessDate: normalizedBusinessDate, reports }, 200)
|
|
102
538
|
},
|
|
103
539
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
540
|
+
* Streams the full daily report id list as NDJSON chunks.
|
|
541
|
+
* 日報 ID 一覧全件を NDJSON チャンクとして逐次配信する。
|
|
542
|
+
*
|
|
543
|
+
* 全量クエリ (`SqlResultCache` の TTL / Redis epoch / in-flight コアレッシングを
|
|
544
|
+
* 素通しで維持) は常に即時に開始する。全量確定を待ってから送出するのは再開接続のみで、
|
|
545
|
+
* その場合の DB エラーはストリーム開始前に throw されて apiLoader の catch が
|
|
546
|
+
* 500 JSON を返す。新規接続はキャッシュ無しの TOP 200 高速先頭ページと競争させ、
|
|
547
|
+
* キャッシュミス時は全量確定前に最初のチャンクを送出する (最初の描画までの時間が
|
|
548
|
+
* 全体データ量に依存しない)。キャッシュヒット時は全量が即勝ちし、従来どおり
|
|
549
|
+
* 確定済み配列のメモリスライス送出になる。
|
|
106
550
|
*/
|
|
107
|
-
ids: async (url, cookie, request, user) => {
|
|
551
|
+
"ids-stream": async (url, cookie, request, user) => {
|
|
552
|
+
const parsed = parseIdsStreamCursor(url)
|
|
553
|
+
if ("invalid" in parsed) {
|
|
554
|
+
return jsonResponseWithETag(request, cookie, { error: { message: "Invalid cursor" } }, 400)
|
|
555
|
+
}
|
|
108
556
|
const forceRefresh = url.searchParams.get("forceRefresh") === "true"
|
|
109
|
-
|
|
110
|
-
|
|
557
|
+
|
|
558
|
+
// 全量 (SqlResultCache / epoch / コアレッシング維持) は常に即時に走らせ始める
|
|
559
|
+
const fullArrayPromise = service.getDailyReportIdsByExternalId(user.id, { forceRefresh })
|
|
560
|
+
// 拒否はイテレーターの await 側で扱う (ここで未処理拒否警告を出さない)
|
|
561
|
+
fullArrayPromise.catch(() => {})
|
|
562
|
+
|
|
563
|
+
if (parsed.cursor) {
|
|
564
|
+
// 再開接続: クライアントは高速先頭を受信済みなので全量確定を待つ (エラーは 500)
|
|
565
|
+
return buildIdsStreamResponse(cookie, streamLinesFromFullArray(await fullArrayPromise, parsed.cursor), request, apiLogger)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 新規接続: 「高速先頭ページ (TOP n・キャッシュ無し)」と「全量 (キャッシュ経由)」を
|
|
570
|
+
* 競争させ、先に確定した方で最初のチャンクを出す。キャッシュヒット時は全量が即勝ち
|
|
571
|
+
* (従来どおり先頭行に total)。キャッシュミス時は先頭ページが先に届き、最初の描画は
|
|
572
|
+
* 全量クエリの確定を待たない。total は全量確定後の最初の行に載る。
|
|
573
|
+
*/
|
|
574
|
+
// キャッシュヒット時もこの TOP 200 クエリは走って結果が捨てられる (二重クエリ)。
|
|
575
|
+
// インデックス順の部分走査で ms 級と実測見積もりされており、ヒット判定のための
|
|
576
|
+
// キャッシュ同期 peek API を足す複雑さに見合わないため意図的に許容する
|
|
577
|
+
const firstPagePromise = service.getDailyReportIdsFirstPageByExternalId(user.id, IDS_STREAM_FIRST_CHUNK_SIZE)
|
|
578
|
+
firstPagePromise.catch(() => {})
|
|
579
|
+
|
|
580
|
+
const lineIterator = (async function* (): AsyncGenerator<DailyReportIdsStreamChunkLine> {
|
|
581
|
+
const winner = await Promise.race([
|
|
582
|
+
fullArrayPromise.then(
|
|
583
|
+
(items) => ({ kind: "full" as const, items }),
|
|
584
|
+
(error) => ({ kind: "full-error" as const, error }),
|
|
585
|
+
),
|
|
586
|
+
firstPagePromise.then(
|
|
587
|
+
(items) => ({ kind: "page" as const, items }),
|
|
588
|
+
() => ({ kind: "page-error" as const }),
|
|
589
|
+
),
|
|
590
|
+
])
|
|
591
|
+
|
|
592
|
+
let resumeKey: DailyReportIdsStreamCursor | null = null
|
|
593
|
+
let fullItems: DailyReportItem[] | null = null
|
|
594
|
+
|
|
595
|
+
if (winner.kind === "full") {
|
|
596
|
+
fullItems = winner.items
|
|
597
|
+
} else if (winner.kind === "page" || winner.kind === "full-error") {
|
|
598
|
+
// 全量が先に死んでいても先頭ページが生きていれば、まずそれを届ける
|
|
599
|
+
const pageItems = winner.kind === "page" ? winner.items : await firstPagePromise
|
|
600
|
+
if (pageItems.length < IDS_STREAM_FIRST_CHUNK_SIZE) {
|
|
601
|
+
// 先頭ページが上限未満 = データセット全量。全量クエリを待たずに完結する
|
|
602
|
+
yield { items: pageItems, nextCursor: null, isLast: true, total: pageItems.length }
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
const lastItem = pageItems[pageItems.length - 1]
|
|
606
|
+
resumeKey = { businessDate: lastItem.businessDate, reportHubId: lastItem.reportHubId }
|
|
607
|
+
// total は未知 (全量確定後の行に載せる)
|
|
608
|
+
yield { items: pageItems, nextCursor: resumeKey, isLast: false }
|
|
609
|
+
}
|
|
610
|
+
// page-error は全量へフォールバック。full-error は上の分岐でページを出してから、ここで throw する
|
|
611
|
+
if (fullItems === null) {
|
|
612
|
+
fullItems = await fullArrayPromise
|
|
613
|
+
}
|
|
614
|
+
// 高速先頭ページ経路 (resumeKey あり) はクライアントが既に 200 件を描画済み
|
|
615
|
+
// なので、続きは大きいチャンクで運ぶ。キャッシュヒット経路は従来どおり 200 件先頭
|
|
616
|
+
yield* streamLinesFromFullArray(fullItems, resumeKey, resumeKey ? IDS_STREAM_NEXT_CHUNK_SIZE : IDS_STREAM_FIRST_CHUNK_SIZE)
|
|
617
|
+
})()
|
|
618
|
+
|
|
619
|
+
return buildIdsStreamResponse(cookie, lineIterator, request, apiLogger)
|
|
111
620
|
},
|
|
112
621
|
/**
|
|
113
622
|
* Retrieves details for a specific daily report.
|
|
@@ -540,9 +1049,165 @@ export function createDailyReportHandlers(config: DailyReportHandlersConfig) {
|
|
|
540
1049
|
})
|
|
541
1050
|
}
|
|
542
1051
|
|
|
1052
|
+
// ---------------- attachment (認可済みバイト配信) ----------------
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Streams an authorized attachment's bytes to the client.
|
|
1056
|
+
* 認可済みの添付ファイルをバイト列としてクライアントへ返すローダー。
|
|
1057
|
+
*
|
|
1058
|
+
* 認可は `service.getAttachmentForUser` の SQL 述語で完結させる。ストレージ側の
|
|
1059
|
+
* 資格情報はサービス共通のものであり、利用者間の分離を一切提供しない。
|
|
1060
|
+
* したがってこの述語が唯一の認可境界であり、ここに欠陥があれば全テナント横断の読み取りになる。
|
|
1061
|
+
*
|
|
1062
|
+
* 「存在しない」と「認可されない」は区別せず 404 にする (存在オラクル回避)。
|
|
1063
|
+
*
|
|
1064
|
+
* 本体は必ず try/catch で包む。ここで漏らした例外はフレームワークの最終防衛線に落ち、
|
|
1065
|
+
* JSON ではないプレーンテキストの 500 になるうえ `Set-Cookie` (セッション延長) も失われる。
|
|
1066
|
+
* **認証ポートの呼び出しも try の内側**に置くこと。認証実装はセッション復号や warmup の
|
|
1067
|
+
* 失敗で素の例外を投げうるため、外に置くと同じ穴が残る。ただしリダイレクトは `Response` を
|
|
1068
|
+
* throw する正常な制御フローなので、catch では素通しする。
|
|
1069
|
+
*/
|
|
1070
|
+
const attachmentLoader = async ({ request, params }: LoaderArgs) => {
|
|
1071
|
+
let sanitizedCookie: string | null = null
|
|
1072
|
+
|
|
1073
|
+
/** エラー応答。`Set-Cookie` はすべての分岐で伝播させる。 */
|
|
1074
|
+
const fail = (status: number, message: string, retryAfterSeconds?: number): Response => {
|
|
1075
|
+
const headers = new Headers({ "Content-Type": "application/json" })
|
|
1076
|
+
if (retryAfterSeconds !== undefined) headers.set("Retry-After", String(retryAfterSeconds))
|
|
1077
|
+
if (sanitizedCookie) headers.append("Set-Cookie", sanitizedCookie)
|
|
1078
|
+
return new Response(JSON.stringify({ error: { message } }), { status, headers })
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
try {
|
|
1082
|
+
const { user, cookie } = await authenticate(request, { failureRedirect: null })
|
|
1083
|
+
sanitizedCookie = cookie ?? null
|
|
1084
|
+
|
|
1085
|
+
if (!user) return fail(401, "Unauthorized")
|
|
1086
|
+
if (request.method !== "GET" && request.method !== "HEAD") return fail(405, "Method not allowed")
|
|
1087
|
+
|
|
1088
|
+
const codec = config.attachmentIdCodec
|
|
1089
|
+
const readAttachment = config.readAttachment
|
|
1090
|
+
// ポート未注入は「この配備に添付機能が無い」であり、認可失敗と区別しない
|
|
1091
|
+
if (!(codec && readAttachment)) return fail(404, "Attachment not found")
|
|
1092
|
+
|
|
1093
|
+
const token = params.token ?? ""
|
|
1094
|
+
const attachmentId = codec.decode(token)
|
|
1095
|
+
// `Number.isSafeInteger` であること。可逆難読化は入力トークンの正準性を検証しないため、
|
|
1096
|
+
// 正規トークンと同じ長さの入力から 2^53 を超える値が復号されうる。それを SQL パラメータへ
|
|
1097
|
+
// 渡すとドライバーの範囲検証が TypeError を投げ、400 のはずが 500 になる。
|
|
1098
|
+
if (attachmentId === null || !Number.isSafeInteger(attachmentId) || attachmentId <= 0) {
|
|
1099
|
+
return fail(400, "Invalid attachment token")
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const internalUserId = await service.getUserIdByExternalId(user.id)
|
|
1103
|
+
if (!internalUserId) return fail(403, "Forbidden")
|
|
1104
|
+
|
|
1105
|
+
// レート制限。トークンは列挙可能なので、これが実効的な唯一の緩和策
|
|
1106
|
+
if (!consumeAttachmentRateToken(internalUserId, Date.now())) {
|
|
1107
|
+
attachmentLogger.error(`429 attachment=${attachmentId} viewer=${internalUserId} reason=rate_limit`)
|
|
1108
|
+
return fail(429, "Too many requests", RATE_LIMIT_RETRY_AFTER_SECONDS)
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// 認可判定 (SQL 側で完結)。null は「存在しない」「認可されない」「親が論理削除済み」の合流
|
|
1112
|
+
const attachment = await service.getAttachmentForUser(attachmentId, internalUserId)
|
|
1113
|
+
if (!attachment) {
|
|
1114
|
+
attachmentLogger.error(`404 attachment=${attachmentId} viewer=${internalUserId} reason=not_visible`)
|
|
1115
|
+
return fail(404, "Attachment not found")
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// DB が既知サイズを持つ場合は読み取り前に弾く (サイズを報告しない取込元の行はここを通過する)
|
|
1119
|
+
if (attachment.fileSize != null && attachment.fileSize > attachmentMaxBytes) {
|
|
1120
|
+
attachmentLogger.error(`413 attachment=${attachmentId} viewer=${internalUserId} reason=db_size`)
|
|
1121
|
+
return fail(413, "Attachment too large")
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (!attachmentGate.tryAcquire()) {
|
|
1125
|
+
attachmentLogger.error(`503 attachment=${attachmentId} viewer=${internalUserId} reason=concurrency`)
|
|
1126
|
+
return fail(503, "Too many concurrent downloads", CONCURRENCY_RETRY_AFTER_SECONDS)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// スロットの解放は冪等にする。早期 return・例外・送出完了のどの経路からでも
|
|
1130
|
+
// ちょうど 1 回だけ返す必要がある
|
|
1131
|
+
let gateReleased = false
|
|
1132
|
+
const releaseGate = () => {
|
|
1133
|
+
if (gateReleased) return
|
|
1134
|
+
gateReleased = true
|
|
1135
|
+
attachmentGate.release()
|
|
1136
|
+
}
|
|
1137
|
+
// 本文の送出完了まで保持する経路に入ったかどうか (入った場合のみ finally での解放を見送る)
|
|
1138
|
+
let releaseDeferredToStream = false
|
|
1139
|
+
|
|
1140
|
+
try {
|
|
1141
|
+
const isHead = request.method === "HEAD"
|
|
1142
|
+
const result = await readAttachment(attachment.filePath, { maxBytes: attachmentMaxBytes, head: isHead, principal: encodeUserId(internalUserId) })
|
|
1143
|
+
|
|
1144
|
+
if (!result.ok) {
|
|
1145
|
+
const status = attachmentFailureToStatus(result.reason)
|
|
1146
|
+
// 実体消失は次回描画へ反映する。記録の失敗は配信結果に影響させない
|
|
1147
|
+
// (await しないので、拒否を捕まえないと未処理 Promise 拒否になる)
|
|
1148
|
+
if (result.reason === "not_found") {
|
|
1149
|
+
void service.markAttachmentMissing(attachmentId).catch((error: unknown) => {
|
|
1150
|
+
attachmentLogger.error(`markAttachmentMissing failed attachment=${attachmentId} message=${error instanceof Error ? error.message : "unknown"}`)
|
|
1151
|
+
})
|
|
1152
|
+
}
|
|
1153
|
+
// file_path / URI は絶対にログへ出さない (内部パス非露出の方針と整合させる)
|
|
1154
|
+
attachmentLogger.error(`${status} attachment=${attachmentId} viewer=${internalUserId} reason=${result.reason} code=${result.code ?? "-"}`)
|
|
1155
|
+
return fail(status, ATTACHMENT_FAILURE_MESSAGES[status] ?? "Attachment storage unavailable")
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// インライン許可リスト外は宣言型ごと octet-stream へ落とす。構文検証だけでは
|
|
1159
|
+
// text/html や image/svg+xml の自オリジン実行を止められない
|
|
1160
|
+
const declared = sanitizeMediaType(attachment.fileType) ?? sanitizeMediaType(result.contentType) ?? "application/octet-stream"
|
|
1161
|
+
const inlineSafe = INLINE_SAFE_MEDIA_TYPES.has(declared)
|
|
1162
|
+
const wantsDownload = new URL(request.url).searchParams.get("download") === "1"
|
|
1163
|
+
const contentType = inlineSafe ? declared : "application/octet-stream"
|
|
1164
|
+
const disposition = inlineSafe && !wantsDownload ? "inline" : "attachment"
|
|
1165
|
+
|
|
1166
|
+
// HEAD は本体を読まないので `bytes` が空になる。RFC 9110 §9.3.2 が要求する
|
|
1167
|
+
// 「GET と同じ Content-Length」を満たすため、ポートが申告した実サイズを優先する
|
|
1168
|
+
const contentLength = result.size ?? result.bytes.byteLength
|
|
1169
|
+
const headers = new Headers({
|
|
1170
|
+
"Content-Type": contentType,
|
|
1171
|
+
"Content-Length": String(contentLength),
|
|
1172
|
+
"Content-Disposition": `${disposition}; filename="${asciiFallbackFileName(attachment.fileName)}"; filename*=UTF-8''${encodeRfc8187(attachment.fileName)}`,
|
|
1173
|
+
"Cache-Control": "private, no-store",
|
|
1174
|
+
"X-Content-Type-Options": "nosniff",
|
|
1175
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
1176
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
1177
|
+
})
|
|
1178
|
+
if (sanitizedCookie) headers.append("Set-Cookie", sanitizedCookie)
|
|
1179
|
+
|
|
1180
|
+
// 実体を確認できたので消失記録を解除する。これが唯一の自動回復経路であり、
|
|
1181
|
+
// 片方向のままだと一時障害で立った absent を利用者が自力で戻せない
|
|
1182
|
+
// (UI は absent の添付にリンクを描画しないため再取得の手段が消える)
|
|
1183
|
+
void service.markAttachmentPresent(attachmentId).catch((error: unknown) => {
|
|
1184
|
+
attachmentLogger.error(`markAttachmentPresent failed attachment=${attachmentId} message=${error instanceof Error ? error.message : "unknown"}`)
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
attachmentLogger.info(`200 attachment=${attachmentId} viewer=${internalUserId} type=${contentType} disposition=${disposition} bytes=${contentLength}`)
|
|
1188
|
+
// HEAD は本文を持たないので即座に解放する。GET は送出完了まで保持し、
|
|
1189
|
+
// 「同時実行上限 × 上限バイト数」というヒープ見積りを実際に成立させる
|
|
1190
|
+
if (isHead) {
|
|
1191
|
+
return new Response(null, { status: 200, headers })
|
|
1192
|
+
}
|
|
1193
|
+
releaseDeferredToStream = true
|
|
1194
|
+
return new Response(streamAndRelease(result.bytes, releaseGate, ATTACHMENT_BODY_FLUSH_TIMEOUT_MS), { status: 200, headers })
|
|
1195
|
+
} finally {
|
|
1196
|
+
// 送出へ引き渡した場合を除き、早期 return でも例外でも必ずここで返す
|
|
1197
|
+
if (!releaseDeferredToStream) releaseGate()
|
|
1198
|
+
}
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
// 認証ポートはリダイレクトを Response として throw する。これは正常な制御フローなので素通しする
|
|
1201
|
+
if (error instanceof Response) throw error
|
|
1202
|
+
attachmentLogger.error(`500 attachment=? viewer=? reason=unexpected message=${error instanceof Error ? error.message : "unknown"}`)
|
|
1203
|
+
return fail(500, "Attachment request failed")
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
543
1207
|
return {
|
|
544
1208
|
index: { loader: indexLoader },
|
|
545
1209
|
api: { loader: apiLoader, action: apiAction },
|
|
546
1210
|
sse: { loader: sseLoader },
|
|
1211
|
+
attachment: { loader: attachmentLoader },
|
|
547
1212
|
}
|
|
548
1213
|
}
|