mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
add invention blob hash which does not seem to help
This commit is contained in:
@@ -21,6 +21,9 @@ export type Env = SharedHonoEnv & {
|
|||||||
// Image bucket (shared with the `img` worker, which serves objects back by
|
// Image bucket (shared with the `img` worker, which serves objects back by
|
||||||
// key). Uploaded saved images are written here.
|
// key). Uploaded saved images are written here.
|
||||||
IMAGES: R2Bucket
|
IMAGES: R2Bucket
|
||||||
|
// Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read
|
||||||
|
// here only to hash an invention's uploaded data blob under `invention/`.
|
||||||
|
CDN_ASSETS: R2Bucket
|
||||||
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||||
// push RelationshipChanged notifications when a player's relationship changes.
|
// push RelationshipChanged notifications when a player's relationship changes.
|
||||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||||
|
|||||||
@@ -130,6 +130,37 @@ function inventionBlobName(filename: string): string {
|
|||||||
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
|
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Base64 — the encoding the real API's hash fields (`BlobHash`) come back in. */
|
||||||
|
function toBase64(bytes: ArrayBuffer): string {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hash of an invention's data blob: its SHA-256, base64-encoded, matching the
|
||||||
|
* real API's `BlobHash`. Read from the checksum the `storage` worker records at
|
||||||
|
* upload time, so this is normally a HEAD with no body transfer; a blob stored
|
||||||
|
* before that (or by anything else) is downloaded and digested instead.
|
||||||
|
*
|
||||||
|
* Null when the blob isn't in the bucket — a metadata-only save names a file that
|
||||||
|
* was never uploaded, and a hash of nothing would be worse than the absent hash the
|
||||||
|
* field already allows for.
|
||||||
|
*/
|
||||||
|
export async function inventionBlobHash(
|
||||||
|
bucket: R2Bucket,
|
||||||
|
blobName: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const key = `invention/${inventionBlobName(blobName)}`
|
||||||
|
const head = await bucket.head(key)
|
||||||
|
if (head === null) return null
|
||||||
|
const recorded = head.checksums.sha256
|
||||||
|
if (recorded !== undefined) return toBase64(recorded)
|
||||||
|
|
||||||
|
const object = await bucket.get(key)
|
||||||
|
return object === null
|
||||||
|
? null
|
||||||
|
: toBase64(await crypto.subtle.digest('SHA-256', await object.arrayBuffer()))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields the client supplies on save (camelCase); everything else is defaulted here.
|
* Fields the client supplies on save (camelCase); everything else is defaulted here.
|
||||||
* `inventionDataFilename` is the one the caller must supply — an invention with no
|
* `inventionDataFilename` is the one the caller must supply — an invention with no
|
||||||
@@ -163,6 +194,7 @@ export interface NewInvention {
|
|||||||
*/
|
*/
|
||||||
export async function createInvention(
|
export async function createInvention(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
|
bucket: R2Bucket,
|
||||||
input: NewInvention
|
input: NewInvention
|
||||||
): Promise<SavedInvention> {
|
): Promise<SavedInvention> {
|
||||||
// Sequential id: one past the current max (the table starts empty).
|
// Sequential id: one past the current max (the table starts empty).
|
||||||
@@ -171,6 +203,7 @@ export async function createInvention(
|
|||||||
.first<{ next: number }>()
|
.first<{ next: number }>()
|
||||||
const inventionId = row?.next ?? 1
|
const inventionId = row?.next ?? 1
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
const blobName = inventionBlobName(input.inventionDataFilename)
|
||||||
const invention: SavedInvention = {
|
const invention: SavedInvention = {
|
||||||
InventionId: inventionId,
|
InventionId: inventionId,
|
||||||
ReplicationId: crypto.randomUUID(),
|
ReplicationId: crypto.randomUUID(),
|
||||||
@@ -183,8 +216,8 @@ export async function createInvention(
|
|||||||
InventionId: inventionId,
|
InventionId: inventionId,
|
||||||
ReplicationId: crypto.randomUUID(),
|
ReplicationId: crypto.randomUUID(),
|
||||||
VersionNumber: 1,
|
VersionNumber: 1,
|
||||||
BlobName: inventionBlobName(input.inventionDataFilename),
|
BlobName: blobName,
|
||||||
BlobHash: null,
|
BlobHash: await inventionBlobHash(bucket, blobName),
|
||||||
InstantiationCost: input.instantiationCost ?? 0,
|
InstantiationCost: input.instantiationCost ?? 0,
|
||||||
LightsCost: input.lightsCost ?? 0,
|
LightsCost: input.lightsCost ?? 0,
|
||||||
ChipsCost: input.chipsCost ?? 0,
|
ChipsCost: input.chipsCost ?? 0,
|
||||||
@@ -568,20 +601,38 @@ export async function getInventionsByRoom(
|
|||||||
*/
|
*/
|
||||||
export async function getInventionVersion(
|
export async function getInventionVersion(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
|
bucket: R2Bucket,
|
||||||
inventionId: number,
|
inventionId: number,
|
||||||
versionNumber: number
|
versionNumber: number
|
||||||
): Promise<InventionVersion | null> {
|
): Promise<InventionVersion | null> {
|
||||||
const invention = await getInventionById(db, inventionId)
|
const invention = await getInventionById(db, inventionId)
|
||||||
if (invention === null) return null
|
if (invention === null) return null
|
||||||
return invention.CurrentVersionNumber === versionNumber ? invention.CurrentVersion : null
|
if (invention.CurrentVersionNumber !== versionNumber) return null
|
||||||
|
|
||||||
|
// A version saved before its blob finished uploading (or before we hashed on
|
||||||
|
// save at all) carries no hash. Hash it now and keep the result, so the other
|
||||||
|
// invention endpoints serve it too and this stays a one-time cost per blob.
|
||||||
|
// ModifiedAt is deliberately left alone: reading a version is not an edit.
|
||||||
|
if (invention.CurrentVersion.BlobHash === null) {
|
||||||
|
const hash = await inventionBlobHash(bucket, invention.CurrentVersion.BlobName)
|
||||||
|
if (hash !== null) {
|
||||||
|
invention.CurrentVersion = { ...invention.CurrentVersion, BlobHash: hash }
|
||||||
|
await storeInvention(db, invention)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return invention.CurrentVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist an edited invention record, bumping ModifiedAt. */
|
/** Persist an edited invention record, bumping ModifiedAt. */
|
||||||
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
|
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
|
||||||
const updated: SavedInvention = { ...invention, ModifiedAt: new Date().toISOString() }
|
await storeInvention(db, { ...invention, ModifiedAt: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write a record back as it stands — for changes that aren't edits (see above). */
|
||||||
|
async function storeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
|
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
|
||||||
.bind(JSON.stringify(updated), invention.InventionId)
|
.bind(JSON.stringify(invention), invention.InventionId)
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,10 @@ export const InventionVersionDto = z.object({
|
|||||||
ReplicationId: z.string(),
|
ReplicationId: z.string(),
|
||||||
VersionNumber: z.int(),
|
VersionNumber: z.int(),
|
||||||
BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'),
|
BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'),
|
||||||
BlobHash: z.string().nullable(),
|
BlobHash: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe('Base64 SHA-256 of the blob; null when it was never uploaded'),
|
||||||
InstantiationCost: z.int(),
|
InstantiationCost: z.int(),
|
||||||
LightsCost: z.int(),
|
LightsCost: z.int(),
|
||||||
ChipsCost: z.int(),
|
ChipsCost: z.int(),
|
||||||
|
|||||||
@@ -330,18 +330,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
)
|
)
|
||||||
|
|
||||||
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
||||||
// RRInventionVersion, which carries the blob name the client downloads. Public.
|
// RRInventionVersion, which carries the blob name the client downloads and the
|
||||||
// Only the current version exists (nothing writes version history yet), so any
|
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
|
||||||
// other version number 404s rather than naming a blob that isn't there.
|
// version history yet), so any other version number 404s rather than naming a
|
||||||
|
// blob that isn't there.
|
||||||
.get(
|
.get(
|
||||||
'/api/inventions/v1/version',
|
'/api/inventions/v1/version',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'One version of an invention',
|
summary: 'One version of an invention',
|
||||||
description:
|
description:
|
||||||
'The bare `RRInventionVersion`, which carries the blob name the client downloads. ' +
|
'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
|
||||||
'Only the current version exists — nothing writes version history yet — so any ' +
|
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
|
||||||
'other version number 404s rather than naming a blob that is not there.',
|
'never uploaded). Only the current version exists — nothing writes version ' +
|
||||||
|
'history yet — so any other version number 404s rather than naming a blob that ' +
|
||||||
|
'is not there.',
|
||||||
parameters: [
|
parameters: [
|
||||||
intQuery('inventionId', 'Invention id; required'),
|
intQuery('inventionId', 'Invention id; required'),
|
||||||
intQuery('version', 'Version number; required'),
|
intQuery('version', 'Version number; required'),
|
||||||
@@ -358,7 +361,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
||||||
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
||||||
|
|
||||||
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
|
const version = await getInventionVersion(
|
||||||
|
c.env.DB,
|
||||||
|
c.env.CDN_ASSETS,
|
||||||
|
inventionId,
|
||||||
|
versionNumber
|
||||||
|
)
|
||||||
return version === null ? c.notFound() : c.json(version)
|
return version === null ? c.notFound() : c.json(version)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -704,7 +712,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const invention = await createInvention(c.env.DB, {
|
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||||||
creatorPlayerId: id,
|
creatorPlayerId: id,
|
||||||
inventionDataFilename,
|
inventionDataFilename,
|
||||||
name: str(body.name),
|
name: str(body.name),
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
|
|||||||
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
|
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */
|
||||||
|
async function base64Sha256(bytes: Uint8Array): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||||
|
}
|
||||||
|
|
||||||
describe('public endpoints', () => {
|
describe('public endpoints', () => {
|
||||||
test('GET /api/config/v1/amplitude', async () => {
|
test('GET /api/config/v1/amplitude', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
|
||||||
@@ -766,6 +772,12 @@ describe('public endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
|
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
|
||||||
|
// The data file is uploaded (via the storage worker) before the metadata save,
|
||||||
|
// so the version carries its hash from the start. No sha256 recorded on this
|
||||||
|
// object — the api worker digests the blob itself in that case.
|
||||||
|
const data = new Uint8Array([1, 2, 3, 4])
|
||||||
|
await env.CDN_ASSETS.put('invention/2026-07-12/lamp.inv', data)
|
||||||
|
|
||||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
|
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
|
||||||
@@ -777,7 +789,8 @@ describe('public endpoints', () => {
|
|||||||
})
|
})
|
||||||
const { Invention } = (await save.json()) as InventionSaveResult
|
const { Invention } = (await save.json()) as InventionSaveResult
|
||||||
|
|
||||||
// The bare RRInventionVersion — the blob name is what the client downloads.
|
// The bare RRInventionVersion — the blob name is what the client downloads,
|
||||||
|
// BlobHash the base64 SHA-256 of what it will download.
|
||||||
const res = await exports.default.fetch(
|
const res = await exports.default.fetch(
|
||||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
|
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
|
||||||
)
|
)
|
||||||
@@ -786,6 +799,7 @@ describe('public endpoints', () => {
|
|||||||
InventionId: Invention.InventionId,
|
InventionId: Invention.InventionId,
|
||||||
VersionNumber: 1,
|
VersionNumber: 1,
|
||||||
BlobName: '2026-07-12/lamp.inv',
|
BlobName: '2026-07-12/lamp.inv',
|
||||||
|
BlobHash: await base64Sha256(data),
|
||||||
InstantiationCost: 42,
|
InstantiationCost: 42,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -808,6 +822,44 @@ describe('public endpoints', () => {
|
|||||||
expect(noId.status).toBe(400)
|
expect(noId.status).toBe(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('BlobHash is null until the blob exists, then backfilled onto the invention', async () => {
|
||||||
|
// Saved before the upload landed: nothing to hash, so the field stays null
|
||||||
|
// rather than carrying a hash of something the client can't download.
|
||||||
|
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('7474')), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'Late Lamp', inventionDataFilename: '2026-07-12/late.inv' }),
|
||||||
|
})
|
||||||
|
const { Invention, InventionVersion } = (await save.json()) as InventionSaveResult
|
||||||
|
expect(InventionVersion.BlobHash).toBeNull()
|
||||||
|
|
||||||
|
const version = async (): Promise<Record<string, unknown>> => {
|
||||||
|
const res = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
|
||||||
|
)
|
||||||
|
return (await res.json()) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
expect((await version()).BlobHash).toBeNull()
|
||||||
|
|
||||||
|
// Once the blob is there the hash resolves — here from the checksum recorded at
|
||||||
|
// upload time (what the storage worker puts), not by digesting the body.
|
||||||
|
const data = new Uint8Array([9, 8, 7])
|
||||||
|
await env.CDN_ASSETS.put('invention/2026-07-12/late.inv', data, {
|
||||||
|
sha256: await crypto.subtle.digest('SHA-256', data),
|
||||||
|
})
|
||||||
|
const hash = await base64Sha256(data)
|
||||||
|
expect((await version()).BlobHash).toBe(hash)
|
||||||
|
|
||||||
|
// And it's kept, so the other invention endpoints serve it too — without the
|
||||||
|
// read counting as an edit (ModifiedAt is untouched).
|
||||||
|
const details = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/inventions/v1?inventionId=${Invention.InventionId}`
|
||||||
|
)
|
||||||
|
const stored = (await details.json()) as SavedInvention
|
||||||
|
expect(stored.CurrentVersion.BlobHash).toBe(hash)
|
||||||
|
expect(stored.ModifiedAt).toBe(Invention.ModifiedAt)
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
|
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
|
||||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -19,11 +19,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Image bucket shared with the `img` worker (which serves objects back by key).
|
// Image bucket shared with the `img` worker (which serves objects back by key).
|
||||||
// Saved-image uploads are written here.
|
// Saved-image uploads are written here. The `recflare-cdn` bucket (owned by the
|
||||||
|
// `cdn` worker, written by `storage`) is bound read-only alongside it, to hash an
|
||||||
|
// invention's uploaded data blob for its `BlobHash`.
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
"binding": "IMAGES",
|
"binding": "IMAGES",
|
||||||
"bucket_name": "recflare-img"
|
"bucket_name": "recflare-img"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "CDN_ASSETS",
|
||||||
|
"bucket_name": "recflare-cdn"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
||||||
|
|||||||
@@ -150,8 +150,14 @@ const app = new Hono<App>()
|
|||||||
// does the extension, which is why it goes on the key, not just the name.
|
// does the extension, which is why it goes on the key, not just the name.
|
||||||
const datePrefix = new Date().toISOString().slice(0, 10)
|
const datePrefix = new Date().toISOString().slice(0, 10)
|
||||||
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
|
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
|
||||||
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), {
|
const bytes = await file.arrayBuffer()
|
||||||
|
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
|
||||||
httpMetadata: { contentType: file.type || 'application/octet-stream' },
|
httpMetadata: { contentType: file.type || 'application/octet-stream' },
|
||||||
|
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
|
||||||
|
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
|
||||||
|
// only a checksum given at put time is readable later — this lets the `api`
|
||||||
|
// worker answer one from a HEAD instead of downloading the blob to digest it.
|
||||||
|
sha256: await crypto.subtle.digest('SHA-256', bytes),
|
||||||
})
|
})
|
||||||
return c.json({ filename })
|
return c.json({ filename })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user