Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5e8528a
feat: self host cap on railway with r2
shashank-sn May 8, 2026
510df4e
chore: simplify dashboard chrome
shashank-sn May 10, 2026
a9075c3
chore: hide dashboard video title
shashank-sn May 10, 2026
0bb6f26
feat: add browser studio cloud editor
shashank-sn May 10, 2026
0380ac8
fix: use video content type for webm studio assets
shashank-sn May 10, 2026
593a026
feat: render browser studio edits to share video
shashank-sn May 10, 2026
87a0ac9
fix: clean uploaded browser studio recordings
shashank-sn May 10, 2026
b051d14
fix: request recorder permissions on safari
shashank-sn May 10, 2026
1223e51
fix: unlock safari recorder devices
shashank-sn May 10, 2026
23b9910
fix: disable safari system audio toggle
shashank-sn May 10, 2026
4162917
fix: clarify safari recorder audio mode
shashank-sn May 10, 2026
b832d84
fix: stabilize safari recorder stop
shashank-sn May 10, 2026
8d3c932
fix: proxy safari browser recording uploads
shashank-sn May 10, 2026
efdc889
fix: quiet thumbnail warning and refresh favicon
shashank-sn May 10, 2026
52ea535
fix: add studio edit action to share page
shashank-sn May 10, 2026
05967d3
feat: add browser studio zoom segments
shashank-sn May 10, 2026
543a184
feat: add browser studio background blur
shashank-sn May 10, 2026
8deba4d
feat: add browser studio camera controls
shashank-sn May 10, 2026
018fb67
feat: add browser studio text overlays
shashank-sn May 10, 2026
a70471f
feat: add browser studio gradient backgrounds
shashank-sn May 10, 2026
646f2a1
feat: add browser studio camera shape controls
shashank-sn May 10, 2026
3aa0371
feat: add browser recorder countdown delay
shashank-sn May 10, 2026
33a2cce
fix: remove cap branding from private video flows
shashank-sn May 10, 2026
dcbab08
fix: proxy safari thumbnail uploads
shashank-sn May 10, 2026
164b78e
fix: proxy safari studio uploads
shashank-sn May 10, 2026
2cfdbf6
feat: improve browser studio editor
shashank-sn May 10, 2026
70df753
feat: deploy cap on cloudflare containers
shashank-sn May 10, 2026
5f5198a
feat: send email through cloudflare
shashank-sn May 10, 2026
c083bef
chore: allow human email sender
shashank-sn May 10, 2026
d9cbd9b
fix: remove cap branding from app emails
shashank-sn May 10, 2026
997442c
fix: remove cap origin from login csp
shashank-sn May 10, 2026
be895f0
chore: reduce cloudflare container idle timeouts
shashank-sn May 11, 2026
d4e312a
fix: cap server proxy upload cap
shashank-sn May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ target
crates
**/node_modules
**/.next
!apps/web/.next
!apps/web/.next/**
39 changes: 39 additions & 0 deletions .railwayignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
.git
.claude
.config
.cursor
.docs
.growth-agent
.next
.opencode
.sst
.tinyb
.turbo
.vercel
.vinxi
.vscode
.zed
analysis
crates
logs
node_modules
outputs
target
target-agent
target-phase8
tmp
**/.next
**/.tinyb
**/node_modules
**/target
apps/storybook/storybook-static
apps/web/.next
apps/web/playwright-report
apps/web/test-results
native-deps*
*.local
*.log
*.tsbuildinfo
.DS_Store
.env
.env.*
23 changes: 23 additions & 0 deletions apps/cloudflare-web/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM node:24-bookworm-slim AS node

FROM mysql:8.4 AS runner
WORKDIR /app

COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules

COPY apps/web/.next/standalone ./
COPY apps/web/.next/static ./apps/web/.next/static
COPY apps/web/public ./apps/web/public
COPY packages/database/migrations ./apps/web/migrations
COPY packages/database/migrations ./migrations
COPY apps/cloudflare-web/start.sh ./start.sh

RUN chmod +x ./start.sh

ENV PORT=8080
ENV HOSTNAME=0.0.0.0

EXPOSE 8080

CMD ["./start.sh"]
17 changes: 17 additions & 0 deletions apps/cloudflare-web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@cap/cloudflare-web",
"private": true,
"type": "module",
"scripts": {
"deploy:web": "wrangler deploy -c ../../wrangler.cap-web.jsonc",
"deploy:media": "wrangler deploy -c ../../wrangler.cap-media.jsonc",
"containers:list": "wrangler containers list -c ../../wrangler.cap-web.jsonc",
"containers:images": "wrangler containers images list -c ../../wrangler.cap-web.jsonc"
},
"dependencies": {
"@cloudflare/containers": "^0.3.3"
},
"devDependencies": {
"wrangler": "^4.90.0"
}
}
35 changes: 35 additions & 0 deletions apps/cloudflare-web/src/media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Container } from "@cloudflare/containers";

interface Env {
CAP_MEDIA: DurableObjectNamespace<CapMediaContainer>;
MEDIA_SERVER_WEBHOOK_SECRET?: string;
}

export class CapMediaContainer extends Container<Env> {
defaultPort = 3456;
sleepAfter = "2m";
enableInternet = true;

constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.envVars = {
MEDIA_SERVER_WEBHOOK_SECRET: env.MEDIA_SERVER_WEBHOOK_SECRET ?? "",
PORT: "3456",
};
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === "/cf-health") {
return new Response("OK");
}

const id = env.CAP_MEDIA.idFromName("media");
const container = env.CAP_MEDIA.get(id);

return container.fetch(request);
},
};
162 changes: 162 additions & 0 deletions apps/cloudflare-web/src/web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { Container } from "@cloudflare/containers";

interface Env {
CAP_WEB: DurableObjectNamespace<CapWebContainer>;
CAP_AWS_ACCESS_KEY?: string;
CAP_AWS_BUCKET?: string;
CAP_AWS_REGION?: string;
CAP_AWS_SECRET_KEY?: string;
CAP_STORAGE_LIMIT_BYTES?: string;
CAP_VIDEOS_DEFAULT_PUBLIC?: string;
CLOUDFLARE_EMAIL_FROM_DOMAIN?: string;
CLOUDFLARE_EMAIL_SECRET?: string;
CLOUDFLARE_EMAIL_WORKER_URL?: string;
CRON_SECRET?: string;
DATABASE_ENCRYPTION_KEY?: string;
EMAIL: {
send(message: {
cc?: string | string[];
from: string | { email: string; name: string };
html?: string;
replyTo?: string;
subject: string;
text?: string;
to: string | string[];
}): Promise<{ messageId: string }>;
};
MEDIA_SERVER_URL?: string;
MEDIA_SERVER_WEBHOOK_SECRET?: string;
MEDIA_SERVER_WEBHOOK_URL?: string;
NEXTAUTH_SECRET?: string;
NEXTAUTH_URL?: string;
NEXT_PUBLIC_WEB_URL?: string;
NODE_ENV?: string;
S3_INTERNAL_ENDPOINT?: string;
S3_PATH_STYLE?: string;
S3_PUBLIC_ENDPOINT?: string;
WEB_URL?: string;
WORKFLOWS_RPC_SECRET?: string;
}

const copiedKeys = [
"CAP_AWS_ACCESS_KEY",
"CAP_AWS_BUCKET",
"CAP_AWS_REGION",
"CAP_AWS_SECRET_KEY",
"CAP_STORAGE_LIMIT_BYTES",
"CAP_VIDEOS_DEFAULT_PUBLIC",
"CLOUDFLARE_EMAIL_FROM_DOMAIN",
"CLOUDFLARE_EMAIL_SECRET",
"CLOUDFLARE_EMAIL_WORKER_URL",
"CRON_SECRET",
"DATABASE_ENCRYPTION_KEY",
"MEDIA_SERVER_URL",
"MEDIA_SERVER_WEBHOOK_SECRET",
"MEDIA_SERVER_WEBHOOK_URL",
"NEXTAUTH_SECRET",
"NEXTAUTH_URL",
"NEXT_PUBLIC_WEB_URL",
"NODE_ENV",
"S3_INTERNAL_ENDPOINT",
"S3_PATH_STYLE",
"S3_PUBLIC_ENDPOINT",
"WEB_URL",
"WORKFLOWS_RPC_SECRET",
] as const;

function buildEnv(env: Env): Record<string, string> {
const vars: Record<string, string> = {
DATABASE_URL: "mysql://cap:cap-local-pwd@127.0.0.1:3306/cap",
HOSTNAME: "0.0.0.0",
MYSQL_DATABASE: "cap",
MYSQL_PASSWORD: "cap-local-pwd",
MYSQL_USER: "cap",
CLOUDFLARE_EMAIL_FROM_DOMAIN:
env.CLOUDFLARE_EMAIL_FROM_DOMAIN ?? "shashanksn.xyz",
CLOUDFLARE_EMAIL_WORKER_URL:
env.CLOUDFLARE_EMAIL_WORKER_URL ??
"https://video.shashanksn.xyz/cf-email/send",
NEXT_PUBLIC_DOCKER_BUILD: "true",
NEXT_PUBLIC_WEB_URL:
env.NEXT_PUBLIC_WEB_URL ?? "https://video.shashanksn.xyz",
NEXTAUTH_URL: env.NEXTAUTH_URL ?? "https://video.shashanksn.xyz",
NODE_ENV: env.NODE_ENV ?? "production",
PORT: "8080",
WEB_URL: env.WEB_URL ?? "https://video.shashanksn.xyz",
};

for (const key of copiedKeys) {
const value = env[key];

if (typeof value === "string" && value.length > 0) {
vars[key] = value;
}
}

return vars;
}

export class CapWebContainer extends Container<Env> {
defaultPort = 8080;
sleepAfter = "2m";
enableInternet = true;

constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.envVars = buildEnv(env);
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === "/cf-health") {
return new Response("OK");
}

if (url.pathname === "/cf-email/send") {
const token = request.headers
.get("authorization")
?.replace("Bearer ", "");

if (
!env.CLOUDFLARE_EMAIL_SECRET ||
token !== env.CLOUDFLARE_EMAIL_SECRET
) {
return new Response("Unauthorized", { status: 401 });
}

const message = (await request.json()) as {
cc?: string | string[];
from: string | { email: string; name: string };
html?: string;
replyTo?: string;
subject?: string;
text?: string;
to?: string | string[];
};

if (!message.to || !message.from || !message.subject) {
return new Response("Missing required email fields", { status: 400 });
}

const result = await env.EMAIL.send({
cc: message.cc,
from: message.from,
html: message.html,
replyTo: message.replyTo,
subject: message.subject,
text: message.text,
to: message.to,
});

return Response.json(result);
}

const id = env.CAP_WEB.idFromName("web");
const container = env.CAP_WEB.get(id);

return container.fetch(request);
},
};
49 changes: 49 additions & 0 deletions apps/cloudflare-web/start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/bin/sh
set -eu

export MYSQL_DATABASE="${MYSQL_DATABASE:-cap}"
export MYSQL_USER="${MYSQL_USER:-cap}"
export MYSQL_PASSWORD="${MYSQL_PASSWORD:-cap-local-pwd}"
export MYSQL_DATADIR="${MYSQL_DATADIR:-/tmp/mysql-data}"
export MYSQL_SOCKET="${MYSQL_SOCKET:-/tmp/mysql.sock}"
export MYSQL_PORT="${MYSQL_PORT:-3306}"
export DATABASE_URL="${DATABASE_URL:-mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@127.0.0.1:${MYSQL_PORT}/${MYSQL_DATABASE}}"
export HOSTNAME="${HOSTNAME:-0.0.0.0}"
export PORT="${PORT:-8080}"

mkdir -p "$MYSQL_DATADIR" /run/mysqld
chown -R mysql:mysql "$MYSQL_DATADIR" /run/mysqld

if [ ! -d "$MYSQL_DATADIR/mysql" ]; then
mysqld --initialize-insecure --datadir="$MYSQL_DATADIR" --user=mysql >/tmp/mysql-install.log 2>&1
fi

mysqld --datadir="$MYSQL_DATADIR" --socket="$MYSQL_SOCKET" --pid-file=/tmp/mysql.pid --bind-address=127.0.0.1 --port="$MYSQL_PORT" --user=mysql &
MYSQL_PID="$!"

cleanup() {
kill "$MYSQL_PID" 2>/dev/null || true
wait "$MYSQL_PID" 2>/dev/null || true
}

trap cleanup INT TERM EXIT

for _ in $(seq 1 60); do
if mysqladmin --socket="$MYSQL_SOCKET" ping >/dev/null 2>&1; then
break
fi
sleep 1
done

mysql --socket="$MYSQL_SOCKET" -uroot <<SQL
CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE}\`;
CREATE USER IF NOT EXISTS '${MYSQL_USER}'@'%' IDENTIFIED BY '${MYSQL_PASSWORD}';
CREATE USER IF NOT EXISTS '${MYSQL_USER}'@'localhost' IDENTIFIED BY '${MYSQL_PASSWORD}';
CREATE USER IF NOT EXISTS '${MYSQL_USER}'@'127.0.0.1' IDENTIFIED BY '${MYSQL_PASSWORD}';
GRANT ALL PRIVILEGES ON \`${MYSQL_DATABASE}\`.* TO '${MYSQL_USER}'@'%';
GRANT ALL PRIVILEGES ON \`${MYSQL_DATABASE}\`.* TO '${MYSQL_USER}'@'localhost';
GRANT ALL PRIVILEGES ON \`${MYSQL_DATABASE}\`.* TO '${MYSQL_USER}'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL

exec node apps/web/server.js
3 changes: 3 additions & 0 deletions apps/cloudflared/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM cloudflare/cloudflared:2026.3.0

CMD ["tunnel", "--no-autoupdate", "run"]
3 changes: 3 additions & 0 deletions apps/expiry-cleaner/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM curlimages/curl:8.11.1

CMD ["sh", "-c", "curl -fsS -H \"Authorization: Bearer ${CRON_SECRET}\" \"${CAP_WEB_URL:-https://video.shashanksn.xyz}/api/cron/expired-videos\""]
10 changes: 10 additions & 0 deletions apps/expiry-cleaner/railway.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"dockerfilePath": "Dockerfile"
},
"deploy": {
"cronSchedule": "0 */6 * * *",
"restartPolicyType": "NEVER"
}
}
10 changes: 6 additions & 4 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# syntax=docker.io/docker/dockerfile:1

FROM node:24-alpine AS base
RUN corepack enable
RUN apk add --no-cache ffmpeg

FROM base AS builder
WORKDIR /app
COPY . .

RUN corepack enable pnpm
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm i --frozen-lockfile
RUN if [ -f pnpm-lock.yaml ]; then pnpm i --frozen-lockfile; else pnpm i --no-frozen-lockfile; fi

ARG NEXT_PUBLIC_DOCKER_BUILD=true
ENV NEXT_PUBLIC_WEB_URL=http://localhost:3000
ARG NEXT_PUBLIC_WEB_URL=http://localhost:3000
ENV NEXT_PUBLIC_DOCKER_BUILD=${NEXT_PUBLIC_DOCKER_BUILD}
ENV NEXT_PUBLIC_WEB_URL=${NEXT_PUBLIC_WEB_URL}

RUN pnpm run build:web

Expand All @@ -30,6 +31,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/packages/database/migrations ./apps/web/migrations
COPY --from=builder --chown=nextjs:nodejs /app/packages/database/migrations ./migrations


USER nextjs
Expand Down
Loading