diff --git a/.github/workflows/deploy-aws-lambda.yml b/.github/workflows/deploy-aws-lambda.yml new file mode 100644 index 00000000..de4abfb3 --- /dev/null +++ b/.github/workflows/deploy-aws-lambda.yml @@ -0,0 +1,208 @@ +name: Deploy to AWS Lambda + +on: + push: + branches: [master] + workflow_dispatch: + inputs: + version: + description: 'Release tag to deploy (e.g. v2.5.0). Leave empty for latest.' + required: false + +# Prevent overlapping deployments. +concurrency: + group: deploy-aws-lambda + cancel-in-progress: false + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + # Only run on forks — the upstream repo does not deploy itself. + if: github.repository != 'saltbo/zpan' + + steps: + - name: Disable upstream-only workflows + env: + GH_TOKEN: ${{ github.token }} + run: | + for workflow in ci.yml release.yml; do + gh api -X PUT "repos/${{ github.repository }}/actions/workflows/$workflow/disable" 2>/dev/null && \ + echo "Disabled $workflow" || echo "$workflow already disabled" + done + + - name: Check required secrets + env: + HAS_TURSO_URL: ${{ secrets.TURSO_DATABASE_URL != '' }} + HAS_TURSO_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN != '' }} + HAS_AWS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID != '' }} + HAS_AWS_SECRET: ${{ secrets.AWS_SECRET_ACCESS_KEY != '' }} + HAS_AWS_REGION: ${{ secrets.AWS_REGION != '' }} + run: | + missing="" + [ "$HAS_TURSO_URL" != "true" ] && missing="$missing TURSO_DATABASE_URL" + [ "$HAS_TURSO_TOKEN" != "true" ] && missing="$missing TURSO_AUTH_TOKEN" + [ "$HAS_AWS_KEY" != "true" ] && missing="$missing AWS_ACCESS_KEY_ID" + [ "$HAS_AWS_SECRET" != "true" ] && missing="$missing AWS_SECRET_ACCESS_KEY" + [ "$HAS_AWS_REGION" != "true" ] && missing="$missing AWS_REGION" + if [ -n "$missing" ]; then + echo "::error::Missing required secrets:$missing. Go to Settings → Secrets and variables → Actions and add them." + exit 1 + fi + + - name: Resolve release tag + id: release + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ -n "$INPUT_VERSION" ]; then + TAG="$INPUT_VERSION" + else + TAG=$(gh api repos/saltbo/zpan/releases/latest --jq '.tag_name') + fi + if [ -z "$TAG" ]; then + echo "::error::No release found in saltbo/zpan" + exit 1 + fi + echo "version=$TAG" >> "$GITHUB_OUTPUT" + echo "### 🚀 Deploying $TAG to AWS Lambda" >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/checkout@v4 + with: + repository: saltbo/zpan + ref: ${{ steps.release.outputs.version }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Setup SAM CLI + uses: aws-actions/setup-sam@v2 + + - run: npm ci + + - name: Ensure SAM artifact bucket exists + id: bucket + run: | + ACCOUNT=$(aws sts get-caller-identity --query Account --output text) + BUCKET="zpan-sam-artifacts-${ACCOUNT}-${{ secrets.AWS_REGION }}" + if ! aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then + aws s3 mb "s3://$BUCKET" --region "${{ secrets.AWS_REGION }}" + echo "Created SAM artifact bucket: $BUCKET" + else + echo "Reusing SAM artifact bucket: $BUCKET" + fi + echo "name=$BUCKET" >> "$GITHUB_OUTPUT" + + - name: Apply Turso migrations + env: + TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }} + TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }} + run: npx drizzle-kit migrate + + - name: Resolve existing deployment state + id: state + env: + USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + run: | + # Read current function config if it exists (redeployment case) + FUNC_JSON=$(aws lambda get-function-configuration --function-name zpan 2>/dev/null || echo "") + + if [ -n "$FUNC_JSON" ]; then + EXISTING_SECRET=$(echo "$FUNC_JSON" | jq -r '.Environment.Variables.BETTER_AUTH_SECRET // empty') + EXISTING_URL=$(aws lambda get-function-url-config --function-name zpan \ + --query FunctionUrl --output text 2>/dev/null || echo "") + fi + + # Determine BETTER_AUTH_SECRET (priority: user-supplied > existing > auto-generate) + if [ -n "$USER_SECRET" ]; then + SECRET="$USER_SECRET" + echo "Using BETTER_AUTH_SECRET from GitHub secret" + elif [ -n "$EXISTING_SECRET" ]; then + SECRET="$EXISTING_SECRET" + echo "Reusing existing BETTER_AUTH_SECRET" + else + SECRET=$(openssl rand -base64 32) + echo "Auto-generated BETTER_AUTH_SECRET" + fi + + echo "secret=$SECRET" >> "$GITHUB_OUTPUT" + echo "app_url=${EXISTING_URL:-}" >> "$GITHUB_OUTPUT" + + - name: Build + run: | + npx vite build --mode node + + # Bundle Lambda entry; @libsql/client is external (native binding) + npx tsup server/entry-lambda.ts --format cjs --outDir dist-lambda --external @libsql/client + + # Assemble minimal Lambda deployment package + mkdir -p dist-lambda-pkg + cp dist-lambda/entry-lambda.cjs dist-lambda-pkg/ + cp -r dist dist-lambda-pkg/ + cp -r migrations dist-lambda-pkg/ + + # Minimal package.json so SAM installs only @libsql/client + node -e " + const p = require('./package.json'); + const pkg = { + name: 'zpan-lambda', + version: p.version, + dependencies: { '@libsql/client': p.dependencies['@libsql/client'] } + }; + require('fs').writeFileSync('dist-lambda-pkg/package.json', JSON.stringify(pkg, null, 2)); + " + + - name: SAM build + run: sam build --template-file deploy/aws-lambda/template.yaml + + - name: SAM deploy + run: | + sam deploy \ + --stack-name zpan \ + --s3-bucket "${{ steps.bucket.outputs.name }}" \ + --capabilities CAPABILITY_IAM \ + --no-confirm-changeset \ + --no-fail-on-empty-changeset \ + --parameter-overrides \ + TursoDatabaseUrl="${{ secrets.TURSO_DATABASE_URL }}" \ + TursoAuthToken="${{ secrets.TURSO_AUTH_TOKEN }}" \ + BetterAuthSecret="${{ steps.state.outputs.secret }}" \ + AppUrl="${{ steps.state.outputs.app_url }}" + + - name: Finalize — set BETTER_AUTH_URL and write summary + run: | + # Get the actual Function URL from CloudFormation outputs + URL=$(aws cloudformation describe-stacks \ + --stack-name zpan \ + --query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" \ + --output text) + + # On first deploy AppUrl was empty; patch the live function so auth works immediately. + CURRENT_URL="${{ steps.state.outputs.app_url }}" + if [ "$URL" != "$CURRENT_URL" ]; then + aws lambda update-function-configuration \ + --function-name zpan \ + --environment "Variables={ + NODE_ENV=production, + TURSO_DATABASE_URL=${{ secrets.TURSO_DATABASE_URL }}, + TURSO_AUTH_TOKEN=${{ secrets.TURSO_AUTH_TOKEN }}, + BETTER_AUTH_SECRET=${{ steps.state.outputs.secret }}, + APP_URL=$URL, + BETTER_AUTH_URL=$URL + }" > /dev/null + echo "Updated BETTER_AUTH_URL → $URL" + fi + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**URL:** $URL" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Next step:** Open the URL, register the first admin account, then go to Admin → Storages to configure your S3-compatible bucket." >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 6f8e90cb..b7665b09 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ node_modules/ dist/ dist-server/ api/ +dist-lambda/ +dist-lambda-pkg/ +.aws-sam/ .wrangler/ # Environment diff --git a/README.md b/README.md index af131eff..6f510671 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,18 @@ Deploy via GitHub Actions with zero server management. Free tier covers personal After initial setup, the workflow runs automatically every time you sync your fork with the latest release. +### AWS Lambda + +Deploy via GitHub Actions using SAM. Lambda Function URL provides HTTPS with no API Gateway needed. + +1. **Fork** this repository +2. In your fork, go to **Settings → Secrets and variables → Actions** and add: + - `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` — from [Turso](https://turso.tech) (free, no credit card) + - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` +3. Go to the **Actions** tab, select **Deploy to AWS Lambda**, and click **Run workflow** + +See [docs/deploy/aws-lambda.md](docs/deploy/aws-lambda.md) for full setup instructions and IAM permissions. + ### Docker **Quick start** — pull the pre-built image and bring your own S3 storage: diff --git a/V2_ROADMAP.md b/V2_ROADMAP.md index 837204ec..a49ca548 100644 --- a/V2_ROADMAP.md +++ b/V2_ROADMAP.md @@ -44,7 +44,7 @@ v2.0–v2.4 ship on two runtimes. v2.5 expands to seven. - **Cloudflare Workers** — Zero-ops, free tier covers personal use, one-click deploy (all versions) - **Docker** — Self-hosted, bring your own S3, full control (all versions) -- **AWS Lambda** — For teams on AWS; SAM template + one-click CloudFormation (v2.5+) +- **AWS Lambda** — For teams on AWS; SAM template + GitHub Actions workflow ([docs](docs/deploy/aws-lambda.md)) (v2.5+) - **Vercel** — One-click "Deploy to Vercel" via GitHub (v2.5+) - **Netlify** — One-click "Deploy to Netlify" (v2.5+) - **Azure Functions** — Bicep template; for Azure-mandated environments (v2.5+) diff --git a/deploy/aws-lambda/template.yaml b/deploy/aws-lambda/template.yaml new file mode 100644 index 00000000..230c3cb5 --- /dev/null +++ b/deploy/aws-lambda/template.yaml @@ -0,0 +1,63 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: ZPan — S3-native file hosting platform on AWS Lambda + +Globals: + Function: + Timeout: 30 + MemorySize: 512 + Runtime: nodejs22.x + Architectures: + - x86_64 + +Parameters: + TursoDatabaseUrl: + Type: String + Description: Turso database URL (e.g. libsql://your-db.turso.io) + NoEcho: true + TursoAuthToken: + Type: String + Description: Turso auth token + NoEcho: true + BetterAuthSecret: + Type: String + Description: Signing secret for auth sessions + NoEcho: true + AppUrl: + Type: String + Description: Public Function URL of the deployed application (set after first deploy) + Default: '' + +Resources: + ZPanFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: zpan + CodeUri: ../../dist-lambda-pkg/ + Handler: entry-lambda.handler + Description: ZPan file hosting platform + Policies: + - AWSLambdaBasicExecutionRole + Environment: + Variables: + NODE_ENV: production + TURSO_DATABASE_URL: !Ref TursoDatabaseUrl + TURSO_AUTH_TOKEN: !Ref TursoAuthToken + BETTER_AUTH_SECRET: !Ref BetterAuthSecret + APP_URL: !Ref AppUrl + BETTER_AUTH_URL: !Ref AppUrl + + FunctionUrlConfig: + AuthType: NONE + Cors: + AllowOrigins: + - '*' + AllowHeaders: + - '*' + AllowMethods: + - '*' + +Outputs: + FunctionUrl: + Description: ZPan public URL + Value: !GetAtt ZPanFunctionUrl.FunctionUrl diff --git a/docs/deploy/aws-lambda.md b/docs/deploy/aws-lambda.md new file mode 100644 index 00000000..92dafaeb --- /dev/null +++ b/docs/deploy/aws-lambda.md @@ -0,0 +1,88 @@ +# AWS Lambda Deployment + +ZPan runs on AWS Lambda via a [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) — no API Gateway required. A [SAM](https://aws.amazon.com/serverless/sam/) template provisions the function and the GitHub Actions workflow deploys it automatically. + +## Prerequisites + +- An AWS account with permissions to create Lambda functions, IAM roles, and S3 buckets +- A [Turso](https://turso.tech) database (free tier: 9 GB, no credit card required) +- A fork of this repository + +### Create a Turso database (~3 minutes) + +```bash +curl -sSfL https://get.tur.so/install.sh | bash +turso auth signup # GitHub OAuth +turso db create zpan +turso db show zpan --url # → TURSO_DATABASE_URL +turso db tokens create zpan # → TURSO_AUTH_TOKEN +``` + +Alternatively, create the database via the [Turso dashboard](https://app.turso.tech) without installing the CLI. + +## Secrets + +Add the following in your fork under **Settings → Secrets and variables → Actions**: + +| Secret | Description | +|--------|-------------| +| `TURSO_DATABASE_URL` | Turso database URL, e.g. `libsql://your-db.turso.io` | +| `TURSO_AUTH_TOKEN` | Turso auth token (rotate via `turso db tokens create zpan`) | +| `AWS_ACCESS_KEY_ID` | AWS access key ID | +| `AWS_SECRET_ACCESS_KEY` | AWS secret access key | +| `AWS_REGION` | AWS region to deploy to, e.g. `us-east-1` | + +> **S3 credentials are not here.** ZPan stores your object storage configuration in the database, configured via the Admin UI after the first deploy. This keeps bucket secrets off GitHub and lets you manage multiple storage backends from one place. + +> **BETTER_AUTH_SECRET** — If omitted, the workflow auto-generates a secure random value on first deploy and stores it in the Lambda function configuration. Add this secret only if you want to supply your own value. + +### IAM permissions for the deploy user + +The AWS credentials need these permissions: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { "Effect": "Allow", "Action": ["lambda:*", "iam:*", "s3:*", "cloudformation:*"], "Resource": "*" }, + { "Effect": "Allow", "Action": "sts:GetCallerIdentity", "Resource": "*" } + ] +} +``` + +For production, scope the `Resource` fields to specific ARNs. The workflow creates a single S3 bucket (`zpan-sam-artifacts--`) for SAM deployment artifacts on first run. + +## Trigger + +1. Fork this repository +2. Add the secrets above +3. Go to the **Actions** tab → **Deploy to AWS Lambda** → **Run workflow** + +The workflow runs automatically on every push to `master` after initial setup. Re-running is idempotent — it redeploys without recreating existing resources. + +## First-boot storage setup + +After the workflow reports success: + +1. Open the Function URL shown in the job summary +2. Register a user (the first user gets admin role) +3. Go to **Admin → Storages → Add storage** and fill in your S3-compatible bucket details: + - **Endpoint**: your S3 endpoint (e.g. `https://s3.amazonaws.com` for AWS S3, or your R2/Tigris/B2 URL) + - **Bucket**: your bucket name + - **Region**: the bucket's region + - **Access Key / Secret Key**: bucket credentials + +> The storage endpoint must be reachable from the **client browser**, since ZPan uploads files directly to S3 via presigned URLs — no server bandwidth is used. + +## Cost + +With the AWS free tier and Turso free tier, personal ZPan usage costs $0/month: + +| Resource | Free tier | Notes | +|----------|-----------|-------| +| AWS Lambda | 1M requests / 400,000 GB-seconds / month | Easily covers personal use | +| Lambda Function URL | Included with Lambda | No extra charge | +| S3 (SAM artifacts) | 5 GB / month | One-time ~10 MB upload per deploy | +| Turso | 9 GB storage, 1B row reads / month | Shared across all deployments | + +S3 (or R2/Tigris) for ZPan file storage is billed separately and depends on your usage. ZPan itself does not add server-side bandwidth costs because files transfer directly between client and S3. diff --git a/package.json b/package.json index 36b90fc7..81bc976e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:node": "node --env-file=.dev.vars node_modules/vite/bin/vite.js dev --mode node", "build": "[ \"$WORKERS_CI\" = \"1\" ] && [ \"$WORKERS_CI_BRANCH\" != \"master\" ] && export CLOUDFLARE_ENV=staging; vite build", "build:node": "vite build --mode node && tsup server/entry-node.ts --format esm --outDir dist-server --external better-sqlite3 --external @libsql/client", + "build:lambda": "tsup server/entry-lambda.ts --format cjs --outDir dist-lambda --external @libsql/client", "build:vercel": "vite build --mode node && tsup server/entry-vercel.ts --format esm --outDir api --external @libsql/client", "deploy": "npm run db:migrate:d1:prod && wrangler deploy", "db:generate": "drizzle-kit generate", diff --git a/server/entry-lambda.ts b/server/entry-lambda.ts new file mode 100644 index 00000000..89d1a673 --- /dev/null +++ b/server/entry-lambda.ts @@ -0,0 +1,51 @@ +import { existsSync, readFileSync } from 'node:fs' +import { extname, join } from 'node:path' +import { Hono } from 'hono' +import { handle } from 'hono/aws-lambda' +import { createBootstrap } from './bootstrap' +import { createLibsqlPlatform } from './platform/libsql' + +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.webp': 'image/webp', +} + +// Initialized once per Lambda container; reused across warm invocations. +let cachedHandler: ReturnType | undefined + +async function init(): Promise> { + if (cachedHandler) return cachedHandler + + const platform = await createLibsqlPlatform({ + TURSO_DATABASE_URL: process.env.TURSO_DATABASE_URL!, + TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN, + }) + const app = await createBootstrap(platform) + + const server = new Hono() + server.route('/', app) + server.get('/*', (c) => { + const filePath = join('./dist', c.req.path === '/' ? 'index.html' : c.req.path) + if (existsSync(filePath)) { + const content = readFileSync(filePath) + const mime = MIME[extname(filePath)] ?? 'application/octet-stream' + return c.body(content, 200, { 'Content-Type': mime }) + } + return c.html(readFileSync('./dist/index.html', 'utf-8')) + }) + + cachedHandler = handle(server) + return cachedHandler +} + +// biome-ignore lint/suspicious/noExplicitAny: Lambda event/context types vary by invocation model +export const handler = async (event: any, context: any) => (await init())(event, context)