feat: per-type attachment metadata (image dims, video/audio/doc fields)
- AttachmentMetadata discriminated union (image/video/audio/document/file) in zod/openapi; attachments table gains metadata + metadata_kind (0003) - create/PATCH accept client-supplied metadata; finalize auto-extracts image width/height/format from R2 head bytes via image-size (no client override) - serializeAttachment surfaces parsed metadata in all responses Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
abortMultipartUpload,
|
||||
completeMultipartUpload,
|
||||
createMultipartUpload,
|
||||
extractImageMetadata,
|
||||
getObjectOrNull,
|
||||
objectKeyFor,
|
||||
putObject,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
markUploadSessionAborted,
|
||||
markUploadSessionCompleted,
|
||||
searchAttachments,
|
||||
setAttachmentMetadata,
|
||||
setAttachmentTagIds,
|
||||
softDeleteAttachment,
|
||||
updateAttachmentFields,
|
||||
@@ -52,6 +54,7 @@ import {
|
||||
type TagRecord,
|
||||
type UploadSessionRecord,
|
||||
} from "./attachment-store";
|
||||
import type { AttachmentMetadata } from "./schemas";
|
||||
|
||||
const DEFAULT_MAX_UPLOAD_BYTES = 52_428_800;
|
||||
const MIN_PART_BYTES = 5 * 1024 * 1024;
|
||||
@@ -80,9 +83,12 @@ function validateVisibility(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
export type AttachmentView = AttachmentRecord & { public_url: string | null };
|
||||
export type AttachmentView = Omit<AttachmentRecord, "metadata"> & {
|
||||
metadata: AttachmentMetadata | null;
|
||||
public_url: string | null;
|
||||
};
|
||||
|
||||
/** 按需计算公开静态 URL:仅 available 且 visibility∈{public,shared} 才有。 */
|
||||
/** 按需计算公开静态 URL:仅 available 且 visibility∈{public,shared} 才有。metadata 解析回对象。 */
|
||||
export function serializeAttachment(record: AttachmentRecord, env: Env): AttachmentView {
|
||||
const publicUrl =
|
||||
record.status === "available" &&
|
||||
@@ -90,7 +96,15 @@ export function serializeAttachment(record: AttachmentRecord, env: Env): Attachm
|
||||
env.PUBLIC_BASE_URL
|
||||
? `${env.PUBLIC_BASE_URL}/files/${record.id}`
|
||||
: null;
|
||||
return { ...record, public_url: publicUrl };
|
||||
let metadata: AttachmentMetadata | null = null;
|
||||
if (record.metadata) {
|
||||
try {
|
||||
metadata = JSON.parse(record.metadata) as AttachmentMetadata;
|
||||
} catch {
|
||||
metadata = null;
|
||||
}
|
||||
}
|
||||
return { ...record, metadata, public_url: publicUrl };
|
||||
}
|
||||
|
||||
/** /files 公开路由:命中 public/shared + available 才返回流,否则返回 null(由路由转 404)。 */
|
||||
@@ -155,6 +169,9 @@ export async function createAttachment(
|
||||
const visibility = validateVisibility(optionalString(body, "visibility") ?? "private");
|
||||
const description = optionalString(body, "description") ?? null;
|
||||
const category_id = optionalString(body, "categoryId") ?? optionalString(body, "category_id") ?? null;
|
||||
const metadataInput = body.metadata;
|
||||
const metadata = metadataInput ? JSON.stringify(metadataInput) : null;
|
||||
const metadata_kind = metadataInput && typeof metadataInput === "object" ? String((metadataInput as { kind?: unknown }).kind ?? "") || null : null;
|
||||
const now = nowIso();
|
||||
const id = newId("att");
|
||||
|
||||
@@ -177,6 +194,8 @@ export async function createAttachment(
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
deleted_at: null,
|
||||
metadata,
|
||||
metadata_kind: metadata_kind || null,
|
||||
};
|
||||
|
||||
await insertAttachment(env.DB, record);
|
||||
@@ -225,28 +244,59 @@ export async function finalizeAttachment(env: Env, user: CurrentUser, id: string
|
||||
}
|
||||
|
||||
const session = await findActiveUploadSession(env.DB, user.id, id);
|
||||
let didMultipart = false;
|
||||
if (session) {
|
||||
if (record.status === "upload_pending") {
|
||||
await finalizeMultipart(env, user, record, session);
|
||||
const refreshed = await findAttachmentForUser(env.DB, user.id, id);
|
||||
return refreshed ?? record;
|
||||
didMultipart = true;
|
||||
} else {
|
||||
// 已单次上传却又残留活动会话:防御性 abort,落到下面的单次路径
|
||||
await safeAbortSession(env, session);
|
||||
}
|
||||
// 已单次上传却又残留活动会话:防御性 abort,落到下面的单次路径
|
||||
await safeAbortSession(env, session);
|
||||
}
|
||||
|
||||
if (record.status !== "uploaded") {
|
||||
throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not ready to finalize", {
|
||||
status: record.status,
|
||||
});
|
||||
if (!didMultipart) {
|
||||
if (record.status !== "uploaded") {
|
||||
throw new HttpProblem(409, "attachment-state-conflict", "Attachment is not ready to finalize", {
|
||||
status: record.status,
|
||||
});
|
||||
}
|
||||
await markAvailable(env.DB, user.id, id);
|
||||
}
|
||||
|
||||
await markAvailable(env.DB, user.id, id);
|
||||
await recordAudit(env, user, id, "attachment.finalized", {});
|
||||
await maybeExtractImageMetadata(env, user.id, id);
|
||||
const refreshed = await findAttachmentForUser(env.DB, user.id, id);
|
||||
return refreshed ?? record;
|
||||
}
|
||||
|
||||
/** finalize 后:对 image/* 附件,若尚无图片元信息,从 R2 头部解析宽高/格式回填(不覆盖客户端已给)。 */
|
||||
async function maybeExtractImageMetadata(env: Env, userId: string, id: string): Promise<void> {
|
||||
const record = await findAttachmentForUser(env.DB, userId, id);
|
||||
if (!record || record.status !== "available" || !record.object_key) {
|
||||
return;
|
||||
}
|
||||
if (!record.content_type.toLowerCase().startsWith("image/")) {
|
||||
return;
|
||||
}
|
||||
let existingKind: string | null = null;
|
||||
if (record.metadata) {
|
||||
try {
|
||||
existingKind = (JSON.parse(record.metadata) as { kind?: string }).kind ?? null;
|
||||
} catch {
|
||||
existingKind = null;
|
||||
}
|
||||
}
|
||||
if (existingKind === "image") {
|
||||
return;
|
||||
}
|
||||
const extracted = await extractImageMetadata(env.ATTACHMENTS, record.object_key);
|
||||
if (!extracted) {
|
||||
return;
|
||||
}
|
||||
await setAttachmentMetadata(env.DB, userId, id, JSON.stringify(extracted), "image", nowIso());
|
||||
}
|
||||
|
||||
async function finalizeMultipart(
|
||||
env: Env,
|
||||
user: CurrentUser,
|
||||
@@ -526,6 +576,22 @@ export async function updateAttachment(
|
||||
await setAttachmentTagIds(env.DB, user.id, id, tagIds);
|
||||
}
|
||||
|
||||
if (body.metadata !== undefined) {
|
||||
if (body.metadata === null) {
|
||||
await setAttachmentMetadata(env.DB, user.id, id, null, null, nowIso());
|
||||
} else {
|
||||
const value = body.metadata as { kind?: unknown };
|
||||
await setAttachmentMetadata(
|
||||
env.DB,
|
||||
user.id,
|
||||
id,
|
||||
JSON.stringify(body.metadata),
|
||||
typeof value.kind === "string" ? value.kind : null,
|
||||
nowIso(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(env, user, id, "attachment.updated", {});
|
||||
const refreshed = await findAttachmentForUser(env.DB, user.id, id);
|
||||
return refreshed ?? record;
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface AttachmentRecord {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
metadata: string | null;
|
||||
metadata_kind: string | null;
|
||||
}
|
||||
|
||||
export interface CategoryRecord {
|
||||
@@ -77,10 +79,10 @@ export interface UploadPartRecord {
|
||||
}
|
||||
|
||||
const ATTACHMENT_COLUMNS =
|
||||
"id, user_id, status, filename, content_type, byte_size, checksum_sha256, visibility, category_id, description, object_key, etag, created_by_user_id, updated_by_user_id, deleted_by_user_id, created_at, updated_at, deleted_at";
|
||||
"id, user_id, status, filename, content_type, byte_size, checksum_sha256, visibility, category_id, description, object_key, etag, created_by_user_id, updated_by_user_id, deleted_by_user_id, created_at, updated_at, deleted_at, metadata, metadata_kind";
|
||||
|
||||
const ATTACHMENT_COLUMNS_ALIASED =
|
||||
"a.id, a.user_id, a.status, a.filename, a.content_type, a.byte_size, a.checksum_sha256, a.visibility, a.category_id, a.description, a.object_key, a.etag, a.created_by_user_id, a.updated_by_user_id, a.deleted_by_user_id, a.created_at, a.updated_at, a.deleted_at";
|
||||
"a.id, a.user_id, a.status, a.filename, a.content_type, a.byte_size, a.checksum_sha256, a.visibility, a.category_id, a.description, a.object_key, a.etag, a.created_by_user_id, a.updated_by_user_id, a.deleted_by_user_id, a.created_at, a.updated_at, a.deleted_at, a.metadata, a.metadata_kind";
|
||||
|
||||
const SEARCH_WHERE = `
|
||||
WHERE a.user_id = ?
|
||||
@@ -109,7 +111,7 @@ function searchBindings(query: AttachmentSearchQuery): unknown[] {
|
||||
|
||||
export async function insertAttachment(db: D1Database, record: AttachmentRecord): Promise<void> {
|
||||
await db
|
||||
.prepare(`INSERT INTO attachments (${ATTACHMENT_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.prepare(`INSERT INTO attachments (${ATTACHMENT_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.bind(
|
||||
record.id,
|
||||
record.user_id,
|
||||
@@ -129,6 +131,8 @@ export async function insertAttachment(db: D1Database, record: AttachmentRecord)
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
record.deleted_at,
|
||||
record.metadata,
|
||||
record.metadata_kind,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
@@ -571,6 +575,24 @@ export async function markAvailableWithObject(
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function setAttachmentMetadata(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string,
|
||||
metadataJson: string | null,
|
||||
metadataKind: string | null,
|
||||
updatedAt: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE attachments
|
||||
SET metadata = ?, metadata_kind = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
|
||||
)
|
||||
.bind(metadataJson, metadataKind, updatedAt, id, userId)
|
||||
.run();
|
||||
}
|
||||
|
||||
// --- 公开静态资源(/files)按 id 查询,SQL 内做 visibility/status/未删 gate ---
|
||||
|
||||
export async function findPublicAttachmentById(db: D1Database, id: string): Promise<AttachmentRecord | null> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { imageSize } from "image-size";
|
||||
|
||||
export function objectKeyFor(userId: string, attachmentId: string): string {
|
||||
const date = new Date();
|
||||
const yyyy = String(date.getUTCFullYear());
|
||||
@@ -23,6 +25,60 @@ export async function getObjectOrNull(bucket: R2Bucket, key: string): Promise<R2
|
||||
return bucket.get(key);
|
||||
}
|
||||
|
||||
/** 读取对象头部字节(用于解析图片尺寸);优先 R2 range get,失败回退全量取前 length 字节。 */
|
||||
export async function getImageHeadBytes(bucket: R2Bucket, key: string, length = 32_768): Promise<Uint8Array | null> {
|
||||
let body: R2ObjectBody | null = null;
|
||||
try {
|
||||
body = await bucket.get(key, { range: { offset: 0, length } });
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (!body) {
|
||||
body = await bucket.get(key);
|
||||
}
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
const buffer = await body.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
return bytes.length > length ? bytes.slice(0, length) : bytes;
|
||||
}
|
||||
|
||||
export interface ImageMetadataResult {
|
||||
kind: "image";
|
||||
width: number;
|
||||
height: number;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象头部字节解析图片宽高/格式(image-size,纯 JS)。
|
||||
* 解析失败或对象缺失时返回 null(不阻断上传流程)。
|
||||
*/
|
||||
export async function extractImageMetadata(
|
||||
bucket: R2Bucket,
|
||||
key: string,
|
||||
): Promise<ImageMetadataResult | null> {
|
||||
const head = await getImageHeadBytes(bucket, key);
|
||||
if (!head || head.byteLength === 0) {
|
||||
return null;
|
||||
}
|
||||
let parsed: { width?: number; height?: number; type?: string } | null = null;
|
||||
try {
|
||||
parsed = imageSize(head) as { width?: number; height?: number; type?: string };
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (!parsed || typeof parsed.width !== "number" || typeof parsed.height !== "number") {
|
||||
return null;
|
||||
}
|
||||
const result: ImageMetadataResult = { kind: "image", width: parsed.width, height: parsed.height };
|
||||
if (parsed.type) {
|
||||
result.format = parsed.type;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface StartedMultipartUpload {
|
||||
key: string;
|
||||
uploadId: string;
|
||||
|
||||
@@ -7,6 +7,42 @@ const attachmentStatus = z.enum(["upload_pending", "uploaded", "available", "del
|
||||
export const VisibilitySchema = visibility.openapi("Visibility");
|
||||
export const AttachmentStatusSchema = attachmentStatus.openapi("AttachmentStatus");
|
||||
|
||||
// 按 kind 判别的元信息联合:不同文件类型携带不同字段
|
||||
export const AttachmentMetadataSchema = z
|
||||
.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("image"),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
format: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("video"),
|
||||
width: z.number().int().positive().optional(),
|
||||
height: z.number().int().positive().optional(),
|
||||
duration_seconds: z.number().nonnegative().optional(),
|
||||
codec: z.string().optional(),
|
||||
bitrate: z.number().int().nonnegative().optional(),
|
||||
frame_rate: z.number().positive().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("audio"),
|
||||
duration_seconds: z.number().nonnegative().optional(),
|
||||
codec: z.string().optional(),
|
||||
sample_rate: z.number().int().positive().optional(),
|
||||
channels: z.number().int().positive().optional(),
|
||||
bitrate: z.number().int().nonnegative().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("document"),
|
||||
pages: z.number().int().positive().optional(),
|
||||
}),
|
||||
z.object({ kind: z.literal("file") }),
|
||||
])
|
||||
.openapi("AttachmentMetadata");
|
||||
|
||||
export type AttachmentMetadata = z.infer<typeof AttachmentMetadataSchema>;
|
||||
|
||||
export const ProblemSchema = z
|
||||
.object({
|
||||
type: z.string(),
|
||||
@@ -38,6 +74,8 @@ export const AttachmentSchema = z
|
||||
updated_at: z.string(),
|
||||
deleted_at: z.string().nullable(),
|
||||
public_url: z.string().url().nullable(),
|
||||
metadata: AttachmentMetadataSchema.nullable(),
|
||||
metadata_kind: z.string().nullable(),
|
||||
})
|
||||
.openapi("Attachment");
|
||||
|
||||
@@ -49,6 +87,7 @@ export const CreateAttachmentSchema = z
|
||||
description: z.string().optional(),
|
||||
categoryId: z.string().optional(),
|
||||
category_id: z.string().optional(),
|
||||
metadata: AttachmentMetadataSchema.optional(),
|
||||
})
|
||||
.openapi("CreateAttachment");
|
||||
|
||||
@@ -59,6 +98,7 @@ export const UpdateAttachmentSchema = z
|
||||
description: z.string().nullable().optional(),
|
||||
categoryId: z.string().nullable().optional(),
|
||||
tagIds: z.array(z.string()).optional(),
|
||||
metadata: AttachmentMetadataSchema.nullable().optional(),
|
||||
})
|
||||
.openapi("UpdateAttachment");
|
||||
|
||||
|
||||
@@ -608,3 +608,103 @@ describe("cfw-attachment worker (multipart & public files)", () => {
|
||||
expect(((await second.json()) as { code: string }).code).toBe("multipart-active");
|
||||
});
|
||||
});
|
||||
|
||||
// 1×1 PNG(合法),用于验证 finalize 时服务端自动解析图片尺寸
|
||||
const PNG_1x1_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
function decodeBase64ToBytes(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
describe("cfw-attachment worker (metadata)", () => {
|
||||
it("stores client-supplied video metadata and returns metadata_kind", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, "user_a");
|
||||
const created = await createAttachment(env, {
|
||||
filename: "clip.mp4",
|
||||
content_type: "video/mp4",
|
||||
metadata: { kind: "video", width: 1920, height: 1080, duration_seconds: 12.3, codec: "h264" },
|
||||
});
|
||||
|
||||
const detail = await jsonBody(await call("GET", `/api/attachments/${created.attachment.id}`, env));
|
||||
expect(detail.attachment.metadata).toEqual({
|
||||
kind: "video",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration_seconds: 12.3,
|
||||
codec: "h264",
|
||||
});
|
||||
expect(detail.attachment.metadata_kind).toBe("video");
|
||||
});
|
||||
|
||||
it("auto-extracts image dimensions on finalize", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, "user_a");
|
||||
const png = decodeBase64ToBytes(PNG_1x1_BASE64);
|
||||
const created = await createAttachment(env, { filename: "dot.png", content_type: "image/png" });
|
||||
|
||||
await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: png as BodyInit });
|
||||
const finalized = await jsonBody(await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env));
|
||||
|
||||
expect(finalized.attachment.status).toBe("available");
|
||||
expect(finalized.attachment.metadata).toEqual({ kind: "image", width: 1, height: 1, format: "png" });
|
||||
expect(finalized.attachment.metadata_kind).toBe("image");
|
||||
});
|
||||
|
||||
it("does not overwrite client-supplied image metadata on finalize", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, "user_a");
|
||||
const png = decodeBase64ToBytes(PNG_1x1_BASE64);
|
||||
const created = await createAttachment(env, {
|
||||
filename: "dot.png",
|
||||
content_type: "image/png",
|
||||
metadata: { kind: "image", width: 9, height: 9 },
|
||||
});
|
||||
|
||||
await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: png as BodyInit });
|
||||
const finalized = await jsonBody(await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env));
|
||||
|
||||
expect(finalized.attachment.metadata).toEqual({ kind: "image", width: 9, height: 9 });
|
||||
});
|
||||
|
||||
it("updates and clears metadata via PATCH", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, "user_a");
|
||||
const id = (await createAttachment(env, { filename: "doc.pdf", content_type: "application/pdf" })).attachment.id;
|
||||
|
||||
const updated = await jsonBody(
|
||||
await call("PATCH", `/api/attachments/${id}`, env, {
|
||||
body: JSON.stringify({ metadata: { kind: "document", pages: 5 } }),
|
||||
}),
|
||||
);
|
||||
expect(updated.attachment.metadata).toEqual({ kind: "document", pages: 5 });
|
||||
expect(updated.attachment.metadata_kind).toBe("document");
|
||||
|
||||
const cleared = await jsonBody(
|
||||
await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ metadata: null }) }),
|
||||
);
|
||||
expect(cleared.attachment.metadata).toBeNull();
|
||||
expect(cleared.attachment.metadata_kind).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid metadata with 422", async () => {
|
||||
const stores = sharedStores();
|
||||
const env = makeEnv(stores, "user_a");
|
||||
|
||||
const response = await call("POST", "/api/attachments", env, {
|
||||
body: JSON.stringify({
|
||||
filename: "x.png",
|
||||
content_type: "image/png",
|
||||
metadata: { kind: "image", width: -1, height: 1 },
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(422);
|
||||
expect(((await response.json()) as { code: string }).code).toBe("validation-failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import migrationSql from "../migrations/0001_initial.sql?raw";
|
||||
import migration2Sql from "../migrations/0002_upload_sessions.sql?raw";
|
||||
import migration3Sql from "../migrations/0003_attachment_metadata.sql?raw";
|
||||
import { vi } from "vitest";
|
||||
import type { Env, FetcherLike } from "../src/env";
|
||||
|
||||
@@ -15,6 +16,7 @@ export class FakeD1 {
|
||||
this.db = new DatabaseSync(":memory:");
|
||||
this.db.exec(migrationSql);
|
||||
this.db.exec(migration2Sql);
|
||||
this.db.exec(migration3Sql);
|
||||
}
|
||||
|
||||
prepare(sql: string) {
|
||||
@@ -168,7 +170,7 @@ export class FakeR2 {
|
||||
return handle as unknown as R2MultipartUpload;
|
||||
}
|
||||
|
||||
async get(key: string): Promise<R2ObjectBody | null> {
|
||||
async get(key: string, _options?: unknown): Promise<R2ObjectBody | null> {
|
||||
const stored = this.store.get(key);
|
||||
if (!stored) return null;
|
||||
const head = toR2Object(key, stored);
|
||||
|
||||
Reference in New Issue
Block a user