Temporary file sharing with expiring links. Upload a file, get a link, set how many times it can be used and when it expires. No permanent storage.
Built as a learning project.
- Guest uploads — anyone can upload up to 10 MB and get a 1-hour link, no account needed
- Authenticated uploads — signed-in users get configurable expiry (up to 7 days) and a use limit
- Pro tier — 500 MB files, 30-day expiry, 100 active links, password-protected links
- Email sharing — send a download link directly to a recipient via Resend; password is included in the email if the link is protected
- Link history — view, copy, and revoke your active links
- Light/dark theme — system-aware, toggleable, with a view-transition animation on switch
- Client requests a presigned S3 PUT URL from
/api/upload/presign - File is uploaded directly from the browser to S3/R2 (server never touches the bytes)
- Client calls
/api/upload/complete— server records the file metadata in Supabase and returns a short ID
/f/[id]renders the file page server-side — checks expiry and use count before showing the download button- On click, the browser fetches
/api/download/[id]— server atomically incrementsused_countvia a Postgres function (claim_file_download) and streams a presigned GET URL redirect - If the link is password-protected, the password is verified server-side before the claim is consumed; on failure the count is rolled back
Google OAuth via Supabase. A Postgres trigger auto-creates a profiles row (plan = free) when a user signs up. All API routes use the Supabase service role key server-side — no client has direct DB access.
- Local dev: MinIO (Docker)
- Production: Cloudflare R2
The S3 client switches endpoint based on NODE_ENV. Presigned URLs are generated server-side; the bucket is never public.
flowchart LR
Browser["Browser"]
subgraph next ["Next.js 16"]
RSC["Server Components\n(page rendering)"]
API["API Routes\n(/api/*)"]
end
DB[("Supabase\n(Postgres)")]
S3[("S3\nMinIO · R2")]
GAuth["Google OAuth\n(Supabase Auth)"]
Resend["Resend\n(Email)"]
Razorpay["Razorpay\n(Payments)"]
Browser -->|"page requests"| RSC
Browser -->|"API calls"| API
Browser -->|"presigned PUT\n(direct upload)"| S3
Browser -->|"presigned GET\n(follows redirect)"| S3
RSC -->|"read metadata"| DB
RSC -->|"verify session"| GAuth
API -->|"metadata · claims\nplan updates"| DB
API -->|"presigned PUT/GET URLs"| S3
API -->|"verify session"| GAuth
API -->|"send share email"| Resend
API -->|"create order\nverify HMAC"| Razorpay
The browser never holds S3 credentials. For uploads it receives a short-lived presigned PUT URL and writes directly. For downloads, the API first claims the use (claim_file_download in Postgres), then returns a redirect to a presigned GET URL — the browser follows it and streams the file straight from S3. The server never proxies file bytes in either direction.
One-time payment instead of subscription Razorpay subscriptions require a plan ID, webhook infrastructure for lifecycle events (halted, cancelled, completed), and a cancel flow. For a learning project that's significant overhead with little upside — there's no recurring billing logic to test. A single order is simpler: create order → verify HMAC signature → set plan. No webhooks, no cancel route.
Password hashing without a dependency
Passwords are hashed with Node's built-in crypto.scryptSync (salt stored inline as salt:hash). No bcrypt or argon2 package needed. The comparison uses timingSafeEqual to prevent timing attacks.
Atomic download claiming
used_count is incremented inside a Postgres function (claim_file_download) that also checks expiry and the use limit in the same query. This avoids a race condition where two simultaneous requests could both pass a JS-level check and both count as valid downloads.
Server-side streaming with Suspense
The profile page fetches plan and usage data in async server components behind <Suspense>. The shell (header + identity card, sourced from JWT metadata) renders immediately; the DB-dependent cards stream in with a skeleton.
No client-side DB access All Supabase queries run through the service role key on the server. RLS is enabled but configured to allow only the service role — there's no anon/user-level policy because the app never exposes Supabase to the browser.
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router) |
| Database | Supabase (Postgres) |
| Storage | MinIO (dev) / Cloudflare R2 (prod) |
| Auth | Supabase Auth (Google OAuth) |
| Resend | |
| Payments | Razorpay (sandbox) |
| UI | Tailwind CSS v4 + shadcn/ui |
| Hosting | Vercel |
- Node.js 20+
- pnpm
- Docker (for MinIO)
git clone https://github.com/0xRadioAc7iv/tempfs.git
cd tempfs
pnpm installdocker compose up -dThis starts MinIO on port 10000 (API) and 10001 (console). The bucket is created automatically from docker-compose.yml.
cp .env.example .env.localFill in .env.local:
| Variable | Where to get it |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase dashboard → Project Settings → API |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Same page, anon key |
SUPABASE_SERVICE_ROLE_KEY |
Same page, service_role key |
S3_BUCKET |
Leave as tempfs-local-bucket for local |
RESEND_API_KEY |
resend.com → API Keys |
RESEND_FROM |
Use onboarding@resend.dev for testing |
NEXT_PUBLIC_SITE_URL |
http://localhost:3000 for local |
RAZORPAY_KEY_ID |
Razorpay dashboard → Settings → API Keys |
RAZORPAY_KEY_SECRET |
Same page |
RAZORPAY_PRO_PRICE_PAISE |
Amount in paise, default 49900 (₹499) |
R2 variables (R2_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) are only needed in production.
In the Supabase SQL editor, run supabase/migration.sql. This creates the files and profiles tables, RLS policies, grants, and the claim_file_download function. Safe to re-run.
In the Supabase dashboard → Authentication → Providers, enable Google and add your OAuth credentials. Add http://localhost:3000/auth/callback to the allowed redirect URLs.
pnpm run devApp runs at http://localhost:3000. MinIO console at http://localhost:10001 (credentials in docker-compose.yml).
- Razorpay sandbox doesn't process real payments. Use Razorpay test card details for checkout testing.
- Email sending in dev uses Resend's
onboarding@resend.devsender, which only delivers to the account owner's email. - There's no background job to clean up expired files from S3. Expired links are blocked at the app layer; the objects stay in the bucket.