diff options
| author | Fuwn <[email protected]> | 2026-02-07 05:41:07 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 05:41:07 -0800 |
| commit | a1a405e56a0907ed44bfaba721e0ea632e051141 (patch) | |
| tree | 99621f7c1407eed732eeb5742fc7458c14ee6d48 | |
| parent | security: remove unsafe-eval CSP, fix host header injection, harden API routes (diff) | |
| download | asa.news-a1a405e56a0907ed44bfaba721e0ea632e051141.tar.xz asa.news-a1a405e56a0907ed44bfaba721e0ea632e051141.zip | |
fix: resolve 6 pre-ship audit bugs
- Webhook entry identifier: use entry GUID instead of feed identifier
- Optimistic rollback: add previousTimeline snapshot and onError handler
to both useToggleEntryReadState and useToggleEntrySavedState
- Rate limiter memory leak: delete Map entries when window expires,
use else-if to avoid re-setting after delete
- Entries API limit param: use Number.isFinite guard instead of falsy
coercion that treats 0 as default
- PWA manifest: add PNG raster icon routes (192x192, 512x512) for
devices that don't support SVG icons
- Billing webhook: throw on DB errors and return 500 so Stripe retries
failed events instead of silently losing them
| -rw-r--r-- | apps/web/app/api/billing/webhook/route.ts | 60 | ||||
| -rw-r--r-- | apps/web/app/api/v1/entries/route.ts | 3 | ||||
| -rw-r--r-- | apps/web/app/icon-192x192/route.tsx | 25 | ||||
| -rw-r--r-- | apps/web/app/icon-512x512/route.tsx | 25 | ||||
| -rw-r--r-- | apps/web/app/manifest.ts | 15 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-entry-state-mutations.ts | 20 | ||||
| -rw-r--r-- | apps/web/lib/rate-limit.ts | 4 | ||||
| -rw-r--r-- | services/worker/internal/webhook/webhook.go | 2 |
8 files changed, 128 insertions, 26 deletions
diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts index 297ef8e..52b1420 100644 --- a/apps/web/app/api/billing/webhook/route.ts +++ b/apps/web/app/api/billing/webhook/route.ts @@ -45,6 +45,7 @@ async function updateBillingState( if (error) { console.error("failed to update billing state:", error) + throw new Error(`failed to update billing state: ${error.message}`) } } @@ -62,7 +63,7 @@ async function handleCheckoutSessionCompleted( ) const adminClient = createSupabaseAdminClient() - await adminClient + const { error } = await adminClient .from("user_profiles") .update({ tier: determineTierFromSubscription(subscription), @@ -72,6 +73,11 @@ async function handleCheckoutSessionCompleted( stripe_current_period_end: extractPeriodEnd(subscription), }) .eq("id", userIdentifier) + + if (error) { + console.error("failed to update billing state on checkout:", error) + throw new Error(`failed to update billing state on checkout: ${error.message}`) + } } async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { @@ -153,28 +159,36 @@ export async function POST(request: Request) { return NextResponse.json({ error: "invalid signature" }, { status: 400 }) } - switch (event.type) { - case "checkout.session.completed": - await handleCheckoutSessionCompleted( - event.data.object as Stripe.Checkout.Session - ) - break - case "customer.subscription.updated": - await handleSubscriptionUpdated( - event.data.object as Stripe.Subscription - ) - break - case "customer.subscription.deleted": - await handleSubscriptionDeleted( - event.data.object as Stripe.Subscription - ) - break - case "invoice.payment_failed": - await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice) - break - case "invoice.paid": - await handleInvoicePaid(event.data.object as Stripe.Invoice) - break + try { + switch (event.type) { + case "checkout.session.completed": + await handleCheckoutSessionCompleted( + event.data.object as Stripe.Checkout.Session + ) + break + case "customer.subscription.updated": + await handleSubscriptionUpdated( + event.data.object as Stripe.Subscription + ) + break + case "customer.subscription.deleted": + await handleSubscriptionDeleted( + event.data.object as Stripe.Subscription + ) + break + case "invoice.payment_failed": + await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice) + break + case "invoice.paid": + await handleInvoicePaid(event.data.object as Stripe.Invoice) + break + } + } catch (handlerError) { + console.error("webhook handler failed:", handlerError) + return NextResponse.json( + { error: "webhook handler failed" }, + { status: 500 } + ) } return NextResponse.json({ received: true }) diff --git a/apps/web/app/api/v1/entries/route.ts b/apps/web/app/api/v1/entries/route.ts index e782e3b..8a2de62 100644 --- a/apps/web/app/api/v1/entries/route.ts +++ b/apps/web/app/api/v1/entries/route.ts @@ -18,7 +18,8 @@ export async function GET(request: Request) { const isSaved = searchParams.get("isSaved") const cursor = searchParams.get("cursor") const limitParameter = searchParams.get("limit") - const limit = Math.min(Math.max(Number(limitParameter) || 50, 1), 100) + const parsedLimit = Number(limitParameter) + const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(Math.floor(parsedLimit), 100) : 50 const adminClient = createSupabaseAdminClient() diff --git a/apps/web/app/icon-192x192/route.tsx b/apps/web/app/icon-192x192/route.tsx new file mode 100644 index 0000000..0a3d703 --- /dev/null +++ b/apps/web/app/icon-192x192/route.tsx @@ -0,0 +1,25 @@ +import { ImageResponse } from "next/og" + +export function GET() { + return new ImageResponse( + ( + <div + style={{ + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "#0a0a0a", + color: "#e5e5e5", + fontFamily: "monospace", + fontWeight: 700, + fontSize: 96, + }} + > + asa + </div> + ), + { width: 192, height: 192 } + ) +} diff --git a/apps/web/app/icon-512x512/route.tsx b/apps/web/app/icon-512x512/route.tsx new file mode 100644 index 0000000..fedee34 --- /dev/null +++ b/apps/web/app/icon-512x512/route.tsx @@ -0,0 +1,25 @@ +import { ImageResponse } from "next/og" + +export function GET() { + return new ImageResponse( + ( + <div + style={{ + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "#0a0a0a", + color: "#e5e5e5", + fontFamily: "monospace", + fontWeight: 700, + fontSize: 256, + }} + > + asa + </div> + ), + { width: 512, height: 512 } + ) +} diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts index cb940d5..be2bc21 100644 --- a/apps/web/app/manifest.ts +++ b/apps/web/app/manifest.ts @@ -21,6 +21,21 @@ export default function manifest(): MetadataRoute.Manifest { type: "image/svg+xml", purpose: "maskable", }, + { + src: "/icon-192x192", + sizes: "192x192", + type: "image/png", + }, + { + src: "/icon-512x512", + sizes: "512x512", + type: "image/png", + }, + { + src: "/apple-icon", + sizes: "180x180", + type: "image/png", + }, ], } } diff --git a/apps/web/lib/queries/use-entry-state-mutations.ts b/apps/web/lib/queries/use-entry-state-mutations.ts index a8c72d0..80bab79 100644 --- a/apps/web/lib/queries/use-entry-state-mutations.ts +++ b/apps/web/lib/queries/use-entry-state-mutations.ts @@ -65,6 +65,13 @@ export function useToggleEntryReadState() { return { previousTimeline } }, + onError: (_error, _variables, context) => { + if (context?.previousTimeline) { + for (const [queryKey, queryData] of context.previousTimeline) { + queryClient.setQueryData(queryKey, queryData) + } + } + }, onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) @@ -107,6 +114,10 @@ export function useToggleEntrySavedState() { onMutate: async ({ entryIdentifier, isSaved }) => { await queryClient.cancelQueries({ queryKey: queryKeys.timeline.all }) + const previousTimeline = queryClient.getQueriesData< + InfiniteData<TimelineEntry[]> + >({ queryKey: queryKeys.timeline.all }) + queryClient.setQueriesData<InfiniteData<TimelineEntry[]>>( { queryKey: queryKeys.timeline.all }, (existingData) => { @@ -124,6 +135,15 @@ export function useToggleEntrySavedState() { } } ) + + return { previousTimeline } + }, + onError: (_error, _variables, context) => { + if (context?.previousTimeline) { + for (const [queryKey, queryData] of context.previousTimeline) { + queryClient.setQueryData(queryKey, queryData) + } + } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts index 4016781..506511d 100644 --- a/apps/web/lib/rate-limit.ts +++ b/apps/web/lib/rate-limit.ts @@ -12,7 +12,9 @@ export function rateLimit( (timestamp) => timestamp > windowStart ) - if (recentTimestamps.length >= limit) { + if (recentTimestamps.length === 0) { + requestTimestamps.delete(identifier) + } else if (recentTimestamps.length >= limit) { requestTimestamps.set(identifier, recentTimestamps) return { success: false, remaining: 0 } } diff --git a/services/worker/internal/webhook/webhook.go b/services/worker/internal/webhook/webhook.go index cf063b9..1a9bccd 100644 --- a/services/worker/internal/webhook/webhook.go +++ b/services/worker/internal/webhook/webhook.go @@ -92,7 +92,7 @@ func (webhookDispatcher *Dispatcher) DispatchForFeed( } entryPayloads = append(entryPayloads, EntryPayload{ - EntryIdentifier: entry.FeedIdentifier, + EntryIdentifier: entry.GUID, FeedIdentifier: entry.FeedIdentifier, GUID: entry.GUID, URL: entry.URL, |