Kaynağa Gözat

feat: expired-share-link cron + SDK alignment

- functions/expire-share-links.js: cron (*/30) deletes share_links past expires_at
  (re-verifies each date before delete). 42 of 44 rows are currently expired.
- useImages: gallery storage.list was hard-capped at limit:100, silently dropping
  every image past 100 (same LIMIT-100 footgun as piconotes). Raised the ceiling.
- shareLinks: drop dead extractData()/PaginatedResponse (the SDK unwraps {data,meta}
  itself); use .maybeSingle() for single-row lookups; collapse revokeShareLink to a
  one-shot filter UPDATE by link_code; add explicit .limit() to listShareLinks.

Bumps version to 1.2.0.
Fszontagh 2 hafta önce
ebeveyn
işleme
0e50b94e6f
4 değiştirilmiş dosya ile 60 ekleme ve 59 silme
  1. 36 0
      functions/expire-share-links.js
  2. 1 1
      package.json
  3. 17 56
      src/api/shareLinks.ts
  4. 6 2
      src/hooks/useImages.ts

+ 36 - 0
functions/expire-share-links.js

@@ -0,0 +1,36 @@
+// Remove expired share links.
+//
+// imagedrop's share_links rows carry an `expires_at` (ISO-8601 TEXT, written by
+// the client as `new Date(...).toISOString()`). The app already refuses to serve
+// an expired link, but the rows pile up forever (most of the table is expired).
+// A periodic cron sweep deletes the ones whose expiry has passed.
+//
+// runAs:'service' so the delete bypasses RLS (no per-row owner check needed).
+export const on = {
+    type: 'cron',
+    schedule: '*/30 * * * *', // every 30 minutes, UTC
+    runAs: 'service',
+};
+
+export default async function (ctx) {
+    const now = Date.now();
+    // Same toISOString() format the client writes, so a lexicographic '<' on the
+    // TEXT column is a chronological compare — the DB does the filtering, no
+    // full-table scan. Each candidate is re-verified by parsing the date before
+    // it is deleted (defence in depth for a destructive op).
+    const nowIso = new Date(now).toISOString();
+
+    const rows = await ctx.db.from('share_links').lt('expires_at', nowIso).limit(2000).select();
+    if (!Array.isArray(rows)) return;
+
+    let removed = 0;
+    for (const row of rows) {
+        const exp = Date.parse(row.expires_at);
+        if (!Number.isFinite(exp) || exp >= now) continue; // skip anything not actually expired
+        // .eq('id', …) targets the row by primary key for the DELETE.
+        await ctx.db.from('share_links').eq('id', row.id).delete();
+        removed++;
+    }
+
+    if (removed > 0) console.log('expire-share-links: removed ' + removed + ' expired share link(s)');
+}

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "imagedrop",
-  "version": "1.1.0",
+  "version": "1.2.0",
   "description": "Image upload and sharing application powered by PicoBaaS",
   "type": "module",
   "scripts": {

+ 17 - 56
src/api/shareLinks.ts

@@ -33,26 +33,8 @@ interface ShareLinkRecord {
   created_at: string;
 }
 
-// Backend returns paginated response with { data: [...], meta: {...} }
-interface PaginatedResponse<T> {
-  data: T[];
-  meta?: {
-    total: number;
-    limit: number;
-    offset: number;
-  };
-}
-
-// Helper to extract array from paginated response
-function extractData<T>(response: { data: T[] | PaginatedResponse<T> | null }): T[] {
-  if (!response.data) return [];
-  // Check if response.data is the paginated wrapper or direct array
-  if (Array.isArray(response.data)) {
-    return response.data;
-  }
-  // It's a paginated response { data: [...], meta: {...} }
-  return (response.data as PaginatedResponse<T>).data || [];
-}
+// Note: the SDK's .get() already unwraps the server's { data, meta } envelope and
+// returns a plain array on response.data, so no manual extraction is needed.
 
 function generateLinkCode(): string {
   const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
@@ -131,39 +113,25 @@ export async function listShareLinks(
     .select('*')
     .eq('image_short_code', shortCode)
     .order('created_at', { ascending: false })
+    .limit(500) // explicit bound: the list endpoint silently caps at 100 otherwise
     .get();
 
   if (response.error) {
     throw new Error(response.error.message || 'Failed to load share links');
   }
 
-  const records = extractData<ShareLinkRecord>(response);
-  return records.map(recordToShareLink);
+  return (response.data ?? []).map(recordToShareLink);
 }
 
 export async function revokeShareLink(
   client: BaaSClient,
   linkCode: string
 ): Promise<void> {
-  // First find the link by link_code
-  const findResponse = await client
-    .from<ShareLinkRecord>('share_links')
-    .select('id')
-    .eq('link_code', linkCode)
-    .limit(1)
-    .get();
-
-  const records = extractData<ShareLinkRecord>(findResponse);
-  if (findResponse.error || records.length === 0) {
-    throw new Error('Share link not found');
-  }
-
-  const linkId = records[0].id;
-
-  // Update the link to mark it as revoked
+  // Filter UPDATE by link_code in a single round-trip (server-side filter
+  // mutation), no find-then-update.
   const updateResponse = await client
     .from<ShareLinkRecord>('share_links')
-    .eq('id', linkId)
+    .eq('link_code', linkCode)
     .update({ is_revoked: true });
 
   if (updateResponse.error) {
@@ -179,43 +147,36 @@ export async function getShareLinkByCode(
     .from<ShareLinkRecord>('share_links')
     .select('*')
     .eq('link_code', linkCode)
-    .limit(1)
-    .get();
+    .maybeSingle();
 
-  const records = extractData<ShareLinkRecord>(response);
-  if (response.error || records.length === 0) {
+  if (response.error || !response.data) {
     return null;
   }
 
-  return recordToShareLink(records[0]);
+  return recordToShareLink(response.data);
 }
 
 export async function incrementViewCount(
   client: BaaSClient,
   linkCode: string
 ): Promise<void> {
-  // Find the link
-  const findResponse = await client
+  const response = await client
     .from<ShareLinkRecord>('share_links')
     .select('id,view_count')
     .eq('link_code', linkCode)
-    .limit(1)
-    .get();
+    .maybeSingle();
 
-  const records = extractData<ShareLinkRecord>(findResponse);
-  if (findResponse.error || records.length === 0) {
+  if (response.error || !response.data) {
     return;
   }
 
-  const link = records[0];
-  const currentCount = link.view_count || 0;
-
-  // Update view count
+  // Note: read-modify-write — a true atomic increment would need a server-side
+  // function; acceptable for a view counter. Updated by link_code in one call.
   await client
     .from<ShareLinkRecord>('share_links')
-    .eq('id', link.id)
+    .eq('link_code', linkCode)
     .update({
-      view_count: currentCount + 1,
+      view_count: (response.data.view_count ?? 0) + 1,
       last_viewed_at: new Date().toISOString(),
     });
 }

+ 6 - 2
src/hooks/useImages.ts

@@ -21,9 +21,13 @@ export function useImages(bucket: string = PUBLIC_BUCKET) {
       const storage = client.storage.from(bucket);
       const prefix = bucket === USER_BUCKET && userId ? `${userId}/` : '';
 
-      // Fetch from storage
+      // Fetch from storage. The storage list endpoint caps results at the limit
+      // sent (default 100), and the SDK can't cursor-paginate it, so a bare
+      // limit:100 silently dropped every image past the first 100. Request a
+      // generous ceiling so a full gallery loads. (Offset is ignored server-side;
+      // raise this further or add server cursor support if a user ever exceeds it.)
       const { data, error: listError } = await storage.list(prefix, {
-        limit: 100,
+        limit: 10000,
       });
 
       if (listError) throw listError;