summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 05:41:07 -0800
committerFuwn <[email protected]>2026-02-07 05:41:07 -0800
commita1a405e56a0907ed44bfaba721e0ea632e051141 (patch)
tree99621f7c1407eed732eeb5742fc7458c14ee6d48
parentsecurity: remove unsafe-eval CSP, fix host header injection, harden API routes (diff)
downloadasa.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.ts60
-rw-r--r--apps/web/app/api/v1/entries/route.ts3
-rw-r--r--apps/web/app/icon-192x192/route.tsx25
-rw-r--r--apps/web/app/icon-512x512/route.tsx25
-rw-r--r--apps/web/app/manifest.ts15
-rw-r--r--apps/web/lib/queries/use-entry-state-mutations.ts20
-rw-r--r--apps/web/lib/rate-limit.ts4
-rw-r--r--services/worker/internal/webhook/webhook.go2
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,