@sleep2agi/commhub-server 0.9.0-preview.24 → 0.9.0-preview.26

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/src/uploads.ts CHANGED
@@ -94,10 +94,47 @@ export function sanitizeExt(filename: string | undefined | null): string {
94
94
  return match ? "." + match[1].toLowerCase() : "";
95
95
  }
96
96
 
97
+ // YYYY-MM-DD bucket string regex — matches what getDateBucket produces
98
+ // and what an index entry's `date_bucket` field is validated against.
99
+ const DATE_BUCKET_REGEX = /^\d{4}-\d{2}-\d{2}$/;
100
+
101
+ /**
102
+ * Validate a date_bucket string against BOTH shape (YYYY-MM-DD regex)
103
+ * AND real-calendar semantics: rejects "2026-02-30", "2026-13-01",
104
+ * "2026-00-15", leap-day-on-non-leap-year, etc. Shape-only check is not
105
+ * enough because the regex accepts any digit combination in each
106
+ * position.
107
+ *
108
+ * Used as a pre-check by the download handler before it calls into
109
+ * pathForExistingBlob — a non-calendar bucket is a 404 (invalid input
110
+ * rejected at the boundary), NOT a 500 (uncaught throw from deep in
111
+ * the read path). Distinguishing these matters: 500 says "server
112
+ * failed"; 404 says "we know your request is bad, refused early".
113
+ */
114
+ export function isValidCalendarBucket(bucket: unknown): bucket is string {
115
+ if (typeof bucket !== "string") return false;
116
+ if (!DATE_BUCKET_REGEX.test(bucket)) return false;
117
+ // Round-trip via Date to reject non-calendar shapes like "2026-02-30"
118
+ // or "2026-13-01". `Date.UTC` coerces overflow ("2026-02-30" becomes
119
+ // "2026-03-02") — if the ISO round-trip doesn't equal the input, the
120
+ // caller supplied a non-calendar bucket.
121
+ const [yStr, mStr, dStr] = bucket.split("-");
122
+ const y = Number(yStr), m = Number(mStr), d = Number(dStr);
123
+ if (m < 1 || m > 12) return false;
124
+ if (d < 1 || d > 31) return false;
125
+ const roundtrip = new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
126
+ return roundtrip === bucket;
127
+ }
128
+
97
129
  /**
98
- * Build the per-upload storage path. The file_id and ext are validated
99
- * against strict regexes; any path-separator or `..` segment from a
100
- * malicious client gets rejected at the regex layer before any I/O.
130
+ * Build the storage path for a NEW upload. Computes today's date bucket
131
+ * from the runtime clock (or `opts.now` for tests). Use ONLY for creating
132
+ * a fresh blob; for reading an existing blob whose bucket is already
133
+ * recorded in the index, use pathForExistingBlob() instead.
134
+ *
135
+ * The file_id and ext are validated against strict regexes; any
136
+ * path-separator or `..` segment from a malicious client gets rejected
137
+ * at the regex layer before any I/O.
101
138
  */
102
139
  export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
103
140
  if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
@@ -109,6 +146,32 @@ export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoo
109
146
  return { fileId, ext, dateBucket, relativePath, absolutePath };
110
147
  }
111
148
 
149
+ /**
150
+ * Build the storage path for an EXISTING blob, using the caller-supplied
151
+ * `dateBucket` (typically read from the index entry). Does NOT compute
152
+ * a date from the runtime clock — that is the write-side concern.
153
+ *
154
+ * This split exists because sharing one date-computing function between
155
+ * write and read semantics caused #509: yesterday's file downloaded
156
+ * today would look under today's bucket → blob_missing → 404, even
157
+ * though the blob was still sitting in yesterday's directory.
158
+ *
159
+ * The file_id, ext and dateBucket are validated against strict regexes
160
+ * before any I/O; a poisoned index bucket (e.g. `../etc`) is rejected
161
+ * here just as file_id path-escape attempts are.
162
+ */
163
+ export function pathForExistingBlob(dateBucket: string, fileId: string, ext: string, opts: { uploadsRoot?: string } = {}): GeneratedFile {
164
+ if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
165
+ if (ext && !/^\.[A-Za-z0-9]{1,16}$/.test(ext)) throw new Error(`ext "${ext}" is invalid`);
166
+ if (typeof dateBucket !== "string" || !DATE_BUCKET_REGEX.test(dateBucket)) {
167
+ throw new Error(`date_bucket "${dateBucket}" is invalid`);
168
+ }
169
+ const root = opts.uploadsRoot ?? getUploadsRoot();
170
+ const relativePath = join(dateBucket, fileId + ext);
171
+ const absolutePath = join(root, relativePath);
172
+ return { fileId, ext, dateBucket, relativePath, absolutePath };
173
+ }
174
+
112
175
  export type UploadIndexEntry = {
113
176
  file_id: string;
114
177
  date_bucket: string; // YYYY-MM-DD
@@ -117,6 +180,15 @@ export type UploadIndexEntry = {
117
180
  mime: string; // best-effort mime from the multipart Content-Type
118
181
  size: number; // byte length of the binary
119
182
  owner?: string; // upload token id / username, for traceability
183
+ // Written by the upload handler as `authCtx?.userId ?? null`, so `null`
184
+ // is a real on-disk value — the type must say so rather than pretend
185
+ // the field is `string | undefined`.
186
+ owner_id?: string | null;
187
+ // #503 — network the upload is attributed to. The key is OMITTED (not
188
+ // written as null) when there is no attribution, so "absent" and
189
+ // "unattributed" are the same on-disk shape and legacy entries written
190
+ // before #503 are indistinguishable from new unattributed ones.
191
+ network_id?: string;
120
192
  uploaded_at: string; // ISO 8601
121
193
  };
122
194
 
@@ -135,6 +207,12 @@ export function indexEntryPath(fileId: string, opts: { uploadsRoot?: string } =
135
207
  export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
136
208
  if (!entry || typeof entry !== "object") return false;
137
209
  const e: any = entry;
210
+ // #503 — if the network_id key is present it must be a non-empty string.
211
+ // An absent key is valid (legacy entries + unattributed uploads); that
212
+ // keeps this in sync with the writer, which omits the key entirely
213
+ // rather than writing null. owner_id is deliberately NOT constrained
214
+ // here — existing entries legitimately carry owner_id=null.
215
+ if ("network_id" in e && !(typeof e.network_id === "string" && e.network_id.length > 0)) return false;
138
216
  return (
139
217
  typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
140
218
  typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&