Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .github/workflows/deploy-ephemeral.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ jobs:
# Format: array of cron expressions (Cloudflare doesn't support named triggers)
# - "0 0 * * *" = midnight UTC daily (subscription downgrade)
# - "0 4 * * *" = 4 AM UTC daily (webhook cleanup)
# NOTE: Custom domain polling is done manually via admin panel
# in ephemeral/staging due to cron trigger limits.
[triggers]
crons = ["0 0 * * *", "0 4 * * *"]

Expand Down Expand Up @@ -278,12 +280,14 @@ jobs:
MAILGUN_BASE_URL = "${{ secrets.MAILGUN_BASE_URL }}"
MAILGUN_FROM = "${{ secrets.MAILGUN_FROM }}"
DOMAIN = "rushomon-pr-${{ env.PR_NUMBER }}.${{ env.WORKERS_DOMAIN }}"
FALLBACK_DOMAIN = "rushomon-pr-${{ env.PR_NUMBER }}.${{ env.WORKERS_DOMAIN }}"
FRONTEND_URL = "https://rushomon-pr-${{ env.PR_NUMBER }}.${{ env.WORKERS_DOMAIN }}"
ALLOWED_ORIGINS = "http://localhost:5173,http://localhost:5174,https://rushomon-pr-${{ env.PR_NUMBER }}.${{ env.WORKERS_DOMAIN }}"
EPHEMERAL_ORIGIN_PATTERN = "https://rushomon-pr-{}.${{ env.WORKERS_DOMAIN }}"
ENABLE_KV_RATE_LIMITING = "false"
POLAR_ORG_SLUG = "${{ secrets.POLAR_ORG_SLUG }}"
POLAR_SANDBOX = "true"
CF_ZONE_ID = "${{ secrets.CF_ZONE_ID }}"
EOF

- name: Apply D1 Migrations
Expand Down Expand Up @@ -328,6 +332,7 @@ jobs:
MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }}
POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }}
POLAR_WEBHOOK_SECRET: ${{ secrets.POLAR_WEBHOOK_SECRET }}
CF_SAAS_API_TOKEN: ${{ secrets.CF_SAAS_API_TOKEN }}
run: |
# Set secrets using wrangler secrets API (not visible in Worker dashboard)
echo "$GH_CLIENT_SECRET" | wrangler secret put GITHUB_CLIENT_SECRET -c wrangler.ephemeral.toml
Expand All @@ -338,6 +343,9 @@ jobs:
echo "$POLAR_ACCESS_TOKEN" | wrangler secret put POLAR_ACCESS_TOKEN -c wrangler.ephemeral.toml
echo "$POLAR_WEBHOOK_SECRET" | wrangler secret put POLAR_WEBHOOK_SECRET -c wrangler.ephemeral.toml
fi
if [ -n "$CF_SAAS_API_TOKEN" ]; then
echo "$CF_SAAS_API_TOKEN" | wrangler secret put CF_API_TOKEN -c wrangler.ephemeral.toml
fi

- name: Deploy to Ephemeral Environment
env:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ crate-type = ["cdylib", "rlib"]

[dependencies]
worker = { version = "0.8.0", features = ["d1"] }
wasm-bindgen = { version = "0.2.114", features = ["serde-serialize"] }
wasm-bindgen = { version = "0.2.121", features = ["serde-serialize"] }
web-sys = { version = "0.3", features = ["Window", "Request", "RequestInit", "Response", "Headers", "RequestMode"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ Never forget that naming things is a hard problem to solve. Nevertheless, I'm gl
- **Rate Limiting**: Comprehensive IP, user, and session-based rate limiting
- **Instance Settings**: Configurable admin settings including signup control and default tiers
- **Email Notifications**: Transactional email via Mailgun for team invitations
- **Custom Domains**: Pro (1) and Business (3) custom domains per organization with SSL via Cloudflare for SaaS

## Planned Features

- **Analytics aggregation**: Advanced queries and dashboard UI
- **More OAuth providers**: GitLab and other providers beyond GitHub/Google
- **QR Codes Generation**: Generate QR codes for links
- **Bulk link operations**: Import/export and batch management
- **Custom domains per organization**: Organization-specific branded domains

## How to try it out

Expand Down
16 changes: 16 additions & 0 deletions docs/SELF_HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USER_URL = "https://openidconnect.googleapis.com/v1/userinfo"

DOMAIN = "api.myapp.com" # Where OAuth callbacks go (your API domain)
FALLBACK_DOMAIN = "redirect.myapp.com" # CNAME target for custom domains (optional, defaults to DOMAIN)
FRONTEND_URL = "https://myapp.com" # Main web interface URL
ALLOWED_ORIGINS = "https://myapp.com,https://api.myapp.com" # CORS allowed origins
# KV-based rate limiting is disabled by default in favor of Cloudflare rate limiting rules
Expand All @@ -497,6 +498,7 @@ Replace the placeholder values:
- `YOUR_GITHUB_CLIENT_ID` — from Step 3a (omit key entirely to disable GitHub login)
- `YOUR_GOOGLE_CLIENT_ID` — from Step 3b (omit key entirely to disable Google login)
- `api.myapp.com` — your API domain/subdomain (must match OAuth callback URL)
- `redirect.myapp.com` — optional fallback domain for custom domain CNAMEs (defaults to DOMAIN if not set)
- `myapp.com` — your main web domain
- Adjust `ALLOWED_ORIGINS` to match your domain setup
- Set Mailgun values to match your [Mailgun account](https://www.mailgun.com/) configuration (see Step 3c)
Expand Down Expand Up @@ -543,6 +545,17 @@ wrangler secret put JWT_SECRET -c wrangler.toml

# Mailgun API key (from Step 3c) — required for team invitation emails
wrangler secret put MAILGUN_API_KEY -c wrangler.toml

# Cloudflare for SaaS (optional — required for custom domains)
# IMPORTANT: Cloudflare for SaaS is an Enterprise-only feature
# Requires quota allocation from Cloudflare - not available on Free/Pro/Business plans
# Contact Cloudflare sales or use Enterprise preview: https://developers.cloudflare.com/billing/understand/preview-services/
# Get your Zone ID from Cloudflare Dashboard → Select zone → Overview → Zone ID
wrangler secret put CF_ZONE_ID -c wrangler.toml

# Get API token from Cloudflare Dashboard → My Profile → API Tokens → Create Token
# Required permissions: Zone - SSL and Certificates - Edit
wrangler secret put CF_API_TOKEN -c wrangler.toml
```

**Security Requirements**:
Expand Down Expand Up @@ -910,6 +923,7 @@ As an admin, you can:
| `GITHUB_TOKEN_URL` | GitHub OAuth token endpoint | `https://github.com/login/oauth/access_token` |
| `GITHUB_USER_URL` | GitHub user API endpoint | `https://api.github.com/user` |
| `DOMAIN` | Domain where OAuth callbacks go (no protocol) | `api.myapp.com` |
| `FALLBACK_DOMAIN` | CNAME target for custom domains (optional, defaults to DOMAIN) | `redirect.myapp.com` |
| `FRONTEND_URL` | Main web interface URL (with protocol) | `https://myapp.com` |
| `ALLOWED_ORIGINS` | Comma-separated CORS origins | `https://myapp.com,https://api.myapp.com` |
| `ENABLE_KV_RATE_LIMITING` | Enable KV-based rate limiting (default: false) | `false` |
Expand All @@ -925,6 +939,8 @@ As an admin, you can:
| `GOOGLE_CLIENT_SECRET` | Google OAuth App client secret (if enabled) |
| `JWT_SECRET` | JWT signing key (32+ random characters) |
| `MAILGUN_API_KEY` | Mailgun API key (team invitations) |
| `CF_ZONE_ID` | Cloudflare Zone ID for custom domains (Cloudflare for SaaS - Enterprise only) |
| `CF_API_TOKEN` | Cloudflare API token with SSL/Certificates Edit permission (custom domains - Enterprise only) |

### Frontend Build-Time Variables

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/config/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const createPricingTiers = (
"Custom short codes",
"Advanced QR codes (sizes, SVG, org logo)",
"Redirect type selection (301/307)",
"1 custom domain",
"API access",
"Email support"
],
Expand Down Expand Up @@ -190,6 +191,7 @@ export const createPricingTiers = (
"3-year analytics retention",
"3 organizations",
"20 team members",
"3 custom domains",
"Device-based routing",
"Password protection",
"API access",
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/lib/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
User
} from "$lib/types/api";
import { apiClient } from "./client";
import type { CustomDomain } from "./domains";

export interface AdminUsersResponse {
users: User[];
Expand All @@ -18,6 +19,13 @@ export interface AdminUsersResponse {
org_tiers: Record<string, string>;
}

export interface PollDomainsResponse {
success: boolean;
domains_processed: number;
status_changes: number;
message: string;
}

export interface UpdateUserRoleRequest {
role: "admin" | "member";
}
Expand Down Expand Up @@ -654,5 +662,19 @@ export const adminApi = {
`/api/admin/api-keys/${id}/restore`,
{ method: "POST" }
);
},

/**
* List all custom domains across all orgs (admin only)
*/
async listDomains(): Promise<CustomDomain[]> {
return apiClient.get<CustomDomain[]>("/api/admin/domains");
},

/**
* Manually poll all pending custom domains (admin only)
*/
async pollDomains(): Promise<PollDomainsResponse> {
return apiClient.post<PollDomainsResponse>("/api/admin/domains/poll", {});
}
};
66 changes: 66 additions & 0 deletions frontend/src/lib/api/domains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { apiClient } from "./client";

export interface CustomDomain {
id: string;
org_id: string;
hostname: string;
status: "pending" | "active" | "failed";
cf_hostname_id: string | null;
ssl_status: "pending" | "active" | "failed";
created_at: number;
verified_at: number | null;
}

export interface DnsInstructions {
cname_target: string;
txt_records: TxtRecord[];
needs_cname: boolean;
needs_txt: boolean;
}

export interface TxtRecord {
name: string;
value: string;
purpose: "ownership" | "ssl_validation";
}

export interface DomainWithInstructions {
domain: CustomDomain;
dns_instructions: DnsInstructions | null;
}

export type CreateDomainResponse = DomainWithInstructions;

export const domainsApi = {
async listDomains(orgId: string): Promise<CustomDomain[]> {
return apiClient.get<CustomDomain[]>(`/api/orgs/${orgId}/domains`);
},

async addDomain(
orgId: string,
hostname: string
): Promise<CreateDomainResponse> {
return apiClient.post<CreateDomainResponse>(`/api/orgs/${orgId}/domains`, {
hostname
});
},

async deleteDomain(
orgId: string,
hostname: string
): Promise<{ deleted: boolean }> {
return apiClient.delete<{ deleted: boolean }>(
`/api/orgs/${orgId}/domains/${hostname}`
);
},

async refreshDomain(
orgId: string,
hostname: string
): Promise<DomainWithInstructions> {
return apiClient.post<DomainWithInstructions>(
`/api/orgs/${orgId}/domains/${hostname}/refresh`,
{}
);
}
};
18 changes: 17 additions & 1 deletion frontend/src/routes/admin/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
| "blacklist"
| "reports"
| "api-keys"
| "settings";
| "settings"
| "domains";

const { children } = $props();

Expand Down Expand Up @@ -122,6 +123,13 @@
<span class="nav-icon">🔗</span>
<span class="nav-label">Links</span>
</button>
<button
class="nav-item {activeModule === 'domains' ? 'active' : ''}"
onclick={() => navigateTo("domains")}
>
<span class="nav-icon">🌐</span>
<span class="nav-label">Custom Domains</span>
</button>
<button
class="nav-item {activeModule === 'blacklist' ? 'active' : ''}"
onclick={() => navigateTo("blacklist")}
Expand Down Expand Up @@ -278,6 +286,14 @@
<span class="mobile-nav-icon">🔗</span>
<span>Links</span>
</a>
<a
href="/admin/domains"
class="mobile-nav-item {activeModule === 'domains' ? 'active' : ''}"
onclick={() => (mobileMenuOpen = false)}
>
<span class="mobile-nav-icon">🌐</span>
<span>Custom Domains</span>
</a>
<a
href="/admin/blacklist"
class="mobile-nav-item {activeModule === 'blacklist' ? 'active' : ''}"
Expand Down
Loading
Loading