-
Notifications
You must be signed in to change notification settings - Fork 0
Add hosted anti-abuse guards #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,4 @@ | ||||||||||||||||||||||||||||||||||||||||
| import { del, get, put } from "@vercel/blob"; | ||||||||||||||||||||||||||||||||||||||||
| import { del, get, list, put } from "@vercel/blob"; | ||||||||||||||||||||||||||||||||||||||||
| import { z } from "zod"; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -11,14 +11,16 @@ import { | |||||||||||||||||||||||||||||||||||||||
| type FilePayload, | ||||||||||||||||||||||||||||||||||||||||
| NamespaceNotFoundError, | ||||||||||||||||||||||||||||||||||||||||
| type PublishRepository, | ||||||||||||||||||||||||||||||||||||||||
| type RateLimitRecord, | ||||||||||||||||||||||||||||||||||||||||
| } from "./repository.js"; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const LookupRecordSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||
| pageId: z.string().uuid(), | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const NamespacePageIndexSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||
| pages: z.array(StoredPageSchema), | ||||||||||||||||||||||||||||||||||||||||
| const RateLimitRecordSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||||
| count: z.number(), | ||||||||||||||||||||||||||||||||||||||||
| windowStartedAt: z.string(), | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export function createBlobStore( | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -29,13 +31,25 @@ export function createBlobStore( | |||||||||||||||||||||||||||||||||||||||
| namespace: string, | ||||||||||||||||||||||||||||||||||||||||
| tokenHash: string, | ||||||||||||||||||||||||||||||||||||||||
| ): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||
| const record: NamespaceRecord = { | ||||||||||||||||||||||||||||||||||||||||
| namespace, | ||||||||||||||||||||||||||||||||||||||||
| tokenHash, | ||||||||||||||||||||||||||||||||||||||||
| createdAt: new Date().toISOString(), | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
| await saveNamespace( | ||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||
| namespace, | ||||||||||||||||||||||||||||||||||||||||
| tokenHash, | ||||||||||||||||||||||||||||||||||||||||
| createdAt: new Date().toISOString(), | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| false, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| await writeJsonBlob(namespacePath(namespace), record, false); | ||||||||||||||||||||||||||||||||||||||||
| async function saveNamespace( | ||||||||||||||||||||||||||||||||||||||||
| record: NamespaceRecord, | ||||||||||||||||||||||||||||||||||||||||
| allowOverwrite = true, | ||||||||||||||||||||||||||||||||||||||||
| ): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||
| await writeJsonBlob( | ||||||||||||||||||||||||||||||||||||||||
| namespacePath(record.namespace), | ||||||||||||||||||||||||||||||||||||||||
| record, | ||||||||||||||||||||||||||||||||||||||||
| allowOverwrite, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| async function getNamespace( | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -54,22 +68,50 @@ export function createBlobStore( | |||||||||||||||||||||||||||||||||||||||
| throw new NamespaceNotFoundError(namespace); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| await writeJsonBlob(namespacePath(namespace), { | ||||||||||||||||||||||||||||||||||||||||
| await saveNamespace({ | ||||||||||||||||||||||||||||||||||||||||
| ...current, | ||||||||||||||||||||||||||||||||||||||||
| lastPublishAt, | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| async function getRateLimitRecord( | ||||||||||||||||||||||||||||||||||||||||
| bucket: string, | ||||||||||||||||||||||||||||||||||||||||
| ): Promise<RateLimitRecord | null> { | ||||||||||||||||||||||||||||||||||||||||
| return readJsonBlob(rateLimitPath(bucket), RateLimitRecordSchema); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| async function setRateLimitRecord( | ||||||||||||||||||||||||||||||||||||||||
| bucket: string, | ||||||||||||||||||||||||||||||||||||||||
| record: RateLimitRecord, | ||||||||||||||||||||||||||||||||||||||||
| ): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||
| await writeJsonBlob(rateLimitPath(bucket), record); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| async function listPages(namespace: string): Promise<StoredPage[]> { | ||||||||||||||||||||||||||||||||||||||||
| const index = await readJsonBlob( | ||||||||||||||||||||||||||||||||||||||||
| namespaceIndexPath(namespace), | ||||||||||||||||||||||||||||||||||||||||
| NamespacePageIndexSchema, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| const pages = index?.pages ?? []; | ||||||||||||||||||||||||||||||||||||||||
| const lookupResults = await list({ | ||||||||||||||||||||||||||||||||||||||||
| limit: 1000, | ||||||||||||||||||||||||||||||||||||||||
| prefix: `${lookupPrefix(namespace)}/`, | ||||||||||||||||||||||||||||||||||||||||
| token: metadataToken, | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
90
to
+95
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return pages.sort((left, right) => | ||||||||||||||||||||||||||||||||||||||||
| right.updatedAt.localeCompare(left.updatedAt), | ||||||||||||||||||||||||||||||||||||||||
| const pages = await Promise.all( | ||||||||||||||||||||||||||||||||||||||||
| lookupResults.blobs.map(async (lookupBlob) => { | ||||||||||||||||||||||||||||||||||||||||
| const lookup = await readJsonBlob( | ||||||||||||||||||||||||||||||||||||||||
| lookupBlob.pathname, | ||||||||||||||||||||||||||||||||||||||||
| LookupRecordSchema, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (lookup === null) { | ||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return findPageById(lookup.pageId); | ||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return pages | ||||||||||||||||||||||||||||||||||||||||
| .filter((page): page is StoredPage => page !== null) | ||||||||||||||||||||||||||||||||||||||||
| .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+112
to
+114
|
||||||||||||||||||||||||||||||||||||||||
| return pages | |
| .filter((page): page is StoredPage => page !== null) | |
| .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); | |
| const nonNullPages = pages.filter( | |
| (page): page is StoredPage => page !== null, | |
| ); | |
| const uniquePagesById = new Map<string, StoredPage>(); | |
| for (const page of nonNullPages) { | |
| // Deduplicate by page identifier to avoid returning the same page multiple times | |
| if (!uniquePagesById.has(page.id)) { | |
| uniquePagesById.set(page.id, page); | |
| } | |
| } | |
| return Array.from(uniquePagesById.values()).sort((left, right) => | |
| right.updatedAt.localeCompare(left.updatedAt), | |
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
allowOverwriteisfalse,put()will fail if the namespace blob already exists (including race conditions betweengetNamespaceandclaimNamespace). That failure currently propagates as a storage error and will be mapped to a generic HTTP 400/500. Consider translating overwrite-conflict errors intoNamespaceExistsErrorto keep claim behavior stable under concurrency.