尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗A disposable camera for events. Organisers create an event and share a QR code. Guests scan it, get a limited roll of shots on their own phone (no app, no account), and every photo lands in one shared gallery that opens live, at a set time, or when the organiser decides.
sharp for server-side validation, metadata stripping and resizingqrcode, pdf-lib (printable sign), client-zip (ZIP downloads in the browser)Supabase project: Lastshot (djufzzzbrekklhtmhpmb, Sydney region).
npm install
cp .env.example .env.local # then fill in SUPABASE_SECRET_KEY
npm run dev
| Variable | Where it's used | Notes |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | browser + server | Project URL |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | browser + server | Publishable key. Safe to expose; RLS protects data |
SUPABASE_SECRET_KEY | server only | Supabase dashboard > Project Settings > API Keys > Secret keys. Never prefix with NEXT_PUBLIC_ |
NEXT_PUBLIC_SITE_URL | QR codes, auth emails | Your deployed URL, no trailing slash |
RESEND_API_KEY | server only | Resend API key, for "photos are ready" emails |
EMAIL_FROM | server only | Sender, e.g. Lastshot <[email protected]>. The domain must be verified in Resend |
EMAIL_REPLY_TO | server only | Optional reply-to address |
CRON_SECRET | server only | Optional. Locks the email scheduler endpoint (see below) |
TEXTO_API_KEY | server only | Texto API key for the SMS alerts add-on (ClickSend's CLICKSEND_USERNAME + CLICKSEND_API_KEY also work as a fallback) |
SMS_FROM | server only | Optional sender name or number for texts |
STRIPE_SECRET_KEY | server only | Stripe secret key. sk_test_... for testing, sk_live_... for real payments |
STRIPE_WEBHOOK_SECRET | server only | Signing secret of the Stripe webhook for /api/stripe/webhook |
Camera access needs HTTPS on phones. To test on a phone locally, use a tunnel (for example npx localtunnel --port 3000) or deploy a preview.
https://<your-domain>/auth/callback to Redirect URLs.Migrations live in supabase/migrations and have already been applied to the Lastshot project:
core_schema: profiles, events, event_guests, photos, event_settings, rate_limitsfunctions_and_security: lifecycle guard, atomic guest functions, RLS, column grants, storage bucketdashboard_stats: read-only stats for the organiser dashboardadmin_and_guest_emails: site admins, suspension, guest email capture and claimingadmin_views: admin-only read functionsemail_scheduler: pg_cron job that triggers the email senderreports_retention_team_errors: co-organisers, photo reports, auto-clean, error logplans_payments_bonus: plans and guest limits, Stripe payments, bonus shotsplans_small_tier: the 50-guest Small plan, Free down to 10 guestsextra_seats: extra seats bought on top of a planphoto_watermarks: marks photos that have watermarked copiesaddons: add-on flags and settings, add-on payments, 2-year retention, SMS sign-upsstorage_1y_and_bundles: 1-year storage option, bundle paymentscart_checkout: one checkout for plan, seats and add-ons; publish once paidsupabase/tests/db_rules.sql, db_admin_emails.sql and db_team_reports_retention.sql exercise the rules (limits, retries, lifecycle, cross-organiser isolation, anon lock-out, path scoping, blocking, closed events, rate limiting). Paste it into the SQL editor; it ends with ALL TESTS PASSED and rolls itself back.
Unit tests for the reveal and access rules: npm test.
Each phone gets a random 256-bit token on joining. It is stored in an httpOnly cookie and in localStorage (sent as a header if the cookie is cleared, which Safari sometimes does). The database only stores its SHA-256 hash.
Limits are enforced in Postgres, not the browser. claim_photo_slot() locks the guest row (SELECT ... FOR UPDATE), so concurrent uploads from one guest are serialised and can't exceed the limit. Each shot has a client-generated clientUploadId with a unique constraint, so a retried upload resumes or returns the existing photo instead of becoming a second one.
Upload order: validate image, claim a shot, write three sizes to storage, mark complete, then report success. If storage fails the shot is released. Abandoned uploads (pending over 15 minutes) release automatically. Deleting a photo does not refund the shot.
On the phone, every shot is saved to IndexedDB before upload, uploaded oldest-first with progress, retried automatically (2s, 6s, 15s, then on reconnect or tap), and removed only after the server confirms.
An anonymous guest can get a fresh roll by clearing site data or switching browser. Mitigations in place:
For events that need strict one-roll-per-person, the remaining options are invite-only links (one token per guest, sent individually) or requiring guests to verify an email or phone. The schema has event_guests.user_id ready for a signed-in guest mode.
event-media is private with no storage policies. Only the server (secret key) can read or write.galleryAccess() (visibility + live/scheduled/manual reveal) before listing or signing anything. Hidden photos are never returned or signed, so direct API calls and guessed URLs get nothing.sharp, which strips EXIF and GPS data and rejects anything that isn't a real JPEG, PNG or WebP of sane dimensions.draft → published → active → ended → archived, enforced by a trigger. An event becomes active when the first photo arrives. Unpublishing is only possible before anyone joins. Settings changes never touch existing photos: lowering the shot limit just leaves some guests with zero shots left.
Guests can leave an email when they join or on the finished-roll screen. An event is ready to email when its gallery is open to guests and no more photos are coming:
Private galleries and suspended events never send. Each address gets one email with a private link (/e/<code>/g/<token>) that opens the gallery with downloads on any device. Only a hash of the link is stored. Links are derived from LINK_SECRET (or the Supabase secret key), so a retried send reuses the same link.
Sending happens straight after an organiser ends or reveals, and every 5 minutes through Supabase pg_cron (job lastshot-gallery-emails), which calls /api/cron/gallery-emails. Postgres hands each email to exactly one sender (claim_gallery_emails, FOR UPDATE SKIP LOCKED), retries up to 5 times, and Resend's idempotency key stops duplicates. Every email has an unsubscribe link.
To point the scheduler at your app, add a Vault secret named lastshot_cron_url with the value https://<your-domain>/api/cron/gallery-emails (Supabase > Project Settings > Vault). If you set CRON_SECRET in Vercel, add the same value as lastshot_cron_secret.
/admin is for site admins only (checked in the database on every request; others get a 404). Admins are flagged in profiles.is_admin; addresses in app_admins become admins on sign-up (currently [email protected]). It covers:
Suspension stops guests joining, shooting and viewing, and can only be lifted by an admin.
The creator of an event can invite co-organisers by email (Settings tab, Team). Invites last 14 days and only work for an account with the invited address. Co-organisers can do everything except delete the event or change the team. Access is enforced in Postgres: every organiser policy uses is_event_organiser(event_id).
Anyone who can see a gallery can report a photo. The photo is flagged and the creator is emailed (at most every 15 minutes per event). Three separate reports hide the photo until it's reviewed. Approving a photo or clearing its flag closes its reports.
Photos are deleted 180 days after an event ends (drafts that were never published: 180 days after creation), unless the organiser turns on "Keep photos after 180 days". The creator is emailed two weeks before. A daily pg_cron job (lastshot-cleanup) calls /api/cron/cleanup (Vault secret lastshot_cleanup_url).
Each event has a plan: Free (10 guests), Small ($49, 50), Party ($99, 100), Big event ($179, 300) or Unlimited ($299). Prices are in src/lib/plans.ts; guest limits are also in public.plan_guest_limit() and must match (a unit test checks the TypeScript side). Postgres refuses new guests once the limit is reached (guest_join, with the event row locked so two guests can't take the last place); guests who already joined always get back in. The creator gets one "event is full" email.
Everything is bought through one order on the plan page (/dashboard/events/<id>/upgrade): pick a plan, extra seats and add-ons, see the itemised total, then pay once with Stripe Checkout (startCartCheckout(), a kind = 'bundle' payment). priceCart() in src/lib/plans.ts prices the order in the browser and again on the server before charging. Upgrading from Free costs the new plan's full price; moving up from a paid plan gets 25% off the new plan (UPGRADE_DISCOUNT), not the difference. The plan changes when Stripe's webhook (/api/stripe/webhook, events checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.expired, charge.refunded) or the success page confirms the payment with Stripe; fulfil_payment() is idempotent and never downgrades. Organisers can't change events.plan themselves (column grants). Admins can set a plan from the admin event page.
/dashboard/billing lists every payment for the organiser's events with totals, a CSV export (/api/billing/export) and links to Stripe's hosted invoice, invoice PDF and card receipt (/api/billing/<payment id>/invoice|pdf|receipt, looked up live from Stripe after an RLS check). Refunds made in Stripe are marked on the page when the webhook receives charge.refunded.
On any plan except Unlimited, organisers can add 5 to 500 extra seats with a slider on the upgrade page. The more they add, the less each seat costs: $2 (5–45), $1.50 (50–145), $1 (150+); see SEAT_TIERS in src/lib/plans.ts. The server recalculates the price, so the slider can't be tampered with. Paid seats are added to events.extra_guests by fulfil_payment(), and event_guest_limit() (plan limit plus extra seats) is what guest_join checks.
One-off extras per event, bought through Stripe from the plan page (kind = 'addon' payments; fulfil_payment() sets events.addon_*). Organisers can't set the flags themselves; admins can from the admin event page. Prices are in addonPrice() in src/lib/plans.ts.
drawFrame() in src/lib/client/develop.ts), so it applies to photos taken after it's set up./api/events/<id>/logo, re-encoded as PNG). Guest pages show it instead of the "Made with Lastshot" card, and the slideshow and printable signs use it.event_retention_until() uses 365 or 730 days instead of 180. The old free "keep forever" switch is gone; keep_photos is admin-only./api/e/<code>/sms, add_guest_phone()), and when the gallery is ready they get one text with a private link, sent through Texto (texto.com.au; Australian mobiles only) alongside the emails (sendDueGallerySms()). Texts are capped at the event's guest limit (1,000 on Unlimited). Text links work as gallery passes exactly like email links.The setup has seven steps: Basics, The roll, Gallery, Cover, Plan, Add-ons, Review. The event is saved as a draft from the first step. On Review the order is priced up, then "Publish and pay" opens one Stripe Checkout for the plan and add-ons (startCartCheckout() with publish). The event stays a draft until the payment goes through, then fulfil_payment() publishes it (payments.publish_on_paid). Free events publish straight away.
Photos taken at events on the Free plan also get watermarked copies (original.wm.jpg, display.wm.jpg, with a "Made with lastshot.app" badge bottom-left) and photos.watermarked is set. While the event is on Free, guests, the slideshow and all downloads get the watermarked files (servedPath() in src/lib/server/media.ts); thumbnails and the organiser's dashboard view stay clean. Upgrading switches every photo to the clean file straight away, since the clean originals are always kept.
Organisers can add extra shots that unlock at a set time. event_shot_limit() adds them once bonus_at has passed, and claim_photo_slot() uses it, so the limit is still enforced in Postgres.
/show/<event id> (organisers only) plays approved photos full screen, puts new ones first, refreshes every 12 seconds and shows the join QR code.
Server errors (src/instrumentation.ts) and browser errors (/api/client-error) are logged in app_errors, grouped by fingerprint, and shown at /admin/errors. Site admins (and ALERT_EMAIL, if set) are emailed the first time an error appears and again if it recurs after a quiet hour.
src/app/ routes (landing, auth, dashboard, guest /e/[code], /try demo, API)
src/components/camera/ the guest camera
src/lib/client/ image pipeline, upload queue, ZIP downloads
src/lib/server/ guest auth, media, gallery access, organiser helpers
src/lib/rules.ts reveal and access rules shared by pages and routes
supabase/ migrations and SQL tests