Skip to content

0xRadioAc7iv/tempfs

Repository files navigation

TempFS

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.


What it does

  • 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

How it works

Upload flow

  1. Client requests a presigned S3 PUT URL from /api/upload/presign
  2. File is uploaded directly from the browser to S3/R2 (server never touches the bytes)
  3. Client calls /api/upload/complete — server records the file metadata in Supabase and returns a short ID

Download flow

  1. /f/[id] renders the file page server-side — checks expiry and use count before showing the download button
  2. On click, the browser fetches /api/download/[id] — server atomically increments used_count via a Postgres function (claim_file_download) and streams a presigned GET URL redirect
  3. If the link is password-protected, the password is verified server-side before the claim is consumed; on failure the count is rolled back

Auth

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.

Storage

  • 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.


Architecture

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
Loading

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.


Design decisions

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.


Stack

Layer Choice
Framework Next.js 16 (App Router)
Database Supabase (Postgres)
Storage MinIO (dev) / Cloudflare R2 (prod)
Auth Supabase Auth (Google OAuth)
Email Resend
Payments Razorpay (sandbox)
UI Tailwind CSS v4 + shadcn/ui
Hosting Vercel

Dev setup

Prerequisites

  • Node.js 20+
  • pnpm
  • Docker (for MinIO)

1. Clone and install

git clone https://github.com/0xRadioAc7iv/tempfs.git
cd tempfs
pnpm install

2. Start MinIO

docker compose up -d

This starts MinIO on port 10000 (API) and 10001 (console). The bucket is created automatically from docker-compose.yml.

3. Configure environment

cp .env.example .env.local

Fill 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.

4. Set up the database

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.

5. Configure Supabase Auth

In the Supabase dashboard → Authentication → Providers, enable Google and add your OAuth credentials. Add http://localhost:3000/auth/callback to the allowed redirect URLs.

6. Run

pnpm run dev

App runs at http://localhost:3000. MinIO console at http://localhost:10001 (credentials in docker-compose.yml).


Notes

  • Razorpay sandbox doesn't process real payments. Use Razorpay test card details for checkout testing.
  • Email sending in dev uses Resend's onboarding@resend.dev sender, 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.

About

Secure Temporary File Sharing Service

Topics

Resources

Stars

Watchers

Forks

Contributors