mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
feat: add terraform IaC, dev/prod environments, and multi-host worker support
- Terraform configs for D1 databases, KV namespaces, Worker custom domains - wrangler.dev.jsonc and wrangler.prod.jsonc for environment-specific deployments - Worker code refactored to use env vars for host checking (PUBLIC_HOST, PRIVATE_HOST) - Configurable app URLs and cookie domain via env vars - Deploy and migrate npm scripts for dev/prod - Updated all tests with new env fixtures - Deployment guide in README
This commit is contained in:
@@ -8,3 +8,12 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
/exports
|
/exports
|
||||||
|
|
||||||
|
# Terraform
|
||||||
|
terraform/.terraform/
|
||||||
|
terraform/*.tfstate
|
||||||
|
terraform/*.tfstate.*
|
||||||
|
terraform/*.tfvars
|
||||||
|
!terraform/environments/*.tfvars
|
||||||
|
terraform/crash.log
|
||||||
|
terraform/crash.*.log
|
||||||
|
|||||||
@@ -25,6 +25,86 @@ npm test
|
|||||||
- `npm run build` type-checks the TypeScript project and builds the React SPA into `dist/client` for Workers Assets.
|
- `npm run build` type-checks the TypeScript project and builds the React SPA into `dist/client` for Workers Assets.
|
||||||
- `npm test` runs Vitest tests against the Worker API handler.
|
- `npm test` runs Vitest tests against the Worker API handler.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Heygo ships as a single Cloudflare Worker fronted by two custom domains: a
|
||||||
|
**public** shortlink host and a **private** shortlink host. There are two
|
||||||
|
environments, each with its own D1 database, KV namespace, Worker, and domains.
|
||||||
|
|
||||||
|
| Environment | Public host | Private host | Worker | D1 database |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| dev | `dev.heygo.cc` | `my.dev.junv.cc` | `heygo-dev` | `heygo-shortlinks-dev` |
|
||||||
|
| prod | `heygo.cc` | `my.heygo.cc` | `heygo` | `heygo-shortlinks` |
|
||||||
|
|
||||||
|
The Worker reads `PUBLIC_HOST`, `PRIVATE_HOST`, `APP_BASE_URL`, and
|
||||||
|
`COOKIE_DOMAIN` from env vars (set in each wrangler config), so the same code
|
||||||
|
serves every environment without hardcoded hostnames.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- A Cloudflare account with the `heygo.cc` zone added (and `junv.cc` for dev's
|
||||||
|
private host).
|
||||||
|
- A Cloudflare API token with D1, Workers KV, Workers Scripts, and Workers
|
||||||
|
Routes permissions.
|
||||||
|
- Terraform >= 1.5 installed.
|
||||||
|
|
||||||
|
### 1. Provision infrastructure with Terraform
|
||||||
|
|
||||||
|
The `terraform/` directory provisions the D1 database, KV namespace, and both
|
||||||
|
Worker custom domains. See `terraform/README.md` for full details.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CLOUDFLARE_API_TOKEN=*** # fill account_id + zone_ids first
|
||||||
|
npm run infra:init
|
||||||
|
npm run infra:plan:dev # review, then:
|
||||||
|
npm run infra:apply:dev
|
||||||
|
npm run infra:plan:prod # review, then:
|
||||||
|
npm run infra:apply:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Copy Terraform outputs into Wrangler configs
|
||||||
|
|
||||||
|
After `infra:apply:dev`, read the created resource IDs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terraform -chdir=terraform output -var-file=environments/dev.tfvars
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the printed `d1_database_id` into `wrangler.dev.jsonc`
|
||||||
|
(`d1_databases[].database_id`) and `kv_namespace_id` into
|
||||||
|
`wrangler.dev.jsonc` (`kv_namespaces[].id`). Repeat for prod outputs into
|
||||||
|
`wrangler.prod.jsonc`. The placeholder `00000000-...` IDs are there until you
|
||||||
|
fill them in.
|
||||||
|
|
||||||
|
### 3. Apply D1 migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate:dev
|
||||||
|
npm run migrate:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Deploy the Worker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy:dev
|
||||||
|
npm run deploy:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dev environment cross-domain cookie caveat
|
||||||
|
|
||||||
|
In dev the private host `my.dev.junv.cc` lives on the `junv.cc` zone, while the
|
||||||
|
app UI and auth flow are served from `dev.heygo.cc` (`.heygo.cc` cookie domain).
|
||||||
|
A session cookie scoped to `.heygo.cc` is **not** sent to `junv.cc`, so private
|
||||||
|
shortlink auth on `my.dev.junv.cc` will not work out of the box. Options:
|
||||||
|
|
||||||
|
- Use `my.dev.heygo.cc` (same `.heygo.cc` domain) as the dev private host
|
||||||
|
instead — update `terraform/environments/dev.tfvars` and `wrangler.dev.jsonc`
|
||||||
|
accordingly.
|
||||||
|
- Manually set the `heygo_session` cookie on `junv.cc` for local dev testing.
|
||||||
|
|
||||||
|
Prod does not have this limitation because `heygo.cc` and `my.heygo.cc` share
|
||||||
|
the `.heygo.cc` cookie domain.
|
||||||
|
|
||||||
## Current API skeleton
|
## Current API skeleton
|
||||||
|
|
||||||
- `GET /api/health` returns JSON health status from the Worker.
|
- `GET /api/health` returns JSON health status from the Worker.
|
||||||
|
|||||||
+10
-1
@@ -9,7 +9,16 @@
|
|||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"import:links": "npx tsx scripts/import-links-to-d1.ts"
|
"import:links": "npx tsx scripts/import-links-to-d1.ts",
|
||||||
|
"deploy:dev": "wrangler deploy --config wrangler.dev.jsonc",
|
||||||
|
"deploy:prod": "wrangler deploy --config wrangler.prod.jsonc",
|
||||||
|
"migrate:dev": "wrangler d1 migrations apply heygo-shortlinks-dev --config wrangler.dev.jsonc",
|
||||||
|
"migrate:prod": "wrangler d1 migrations apply heygo-shortlinks --config wrangler.prod.jsonc",
|
||||||
|
"infra:init": "terraform -chdir=terraform init",
|
||||||
|
"infra:plan:dev": "terraform -chdir=terraform plan -var-file=environments/dev.tfvars",
|
||||||
|
"infra:apply:dev": "terraform -chdir=terraform apply -var-file=environments/dev.tfvars",
|
||||||
|
"infra:plan:prod": "terraform -chdir=terraform plan -var-file=environments/prod.tfvars",
|
||||||
|
"infra:apply:prod": "terraform -chdir=terraform apply -var-file=environments/prod.tfvars"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
|
|||||||
Generated
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/cloudflare/cloudflare" {
|
||||||
|
version = "5.21.0"
|
||||||
|
constraints = "~> 5.0"
|
||||||
|
hashes = [
|
||||||
|
"h1:GHS9VFEa0rXjux9Cipqy2QGEmQ0bst6Xqd5WUUJsOwI=",
|
||||||
|
"zh:2d3dc17f27dcf308f52bdede3b7bb00e6cda6c1a9fbdafd1bfe4a915e75fdc44",
|
||||||
|
"zh:413e3569ad0cc89f8ac425d8a197d9e892ff356ac24dedde386820cf5880a48e",
|
||||||
|
"zh:59f262b9af9a8845afeb2734a6bd83b260aa2c521e5c318850745823ef62b38d",
|
||||||
|
"zh:7d42963a47ff6dda8aada0cb73a26eb6807c7ff6bb4419cb91b32ce2412c8362",
|
||||||
|
"zh:89cad3906c9affa0f2947607d316d70c38541c399d0519ebee1f1ccc8aaf5675",
|
||||||
|
"zh:d5c7ff6c39d895e5746fed74ecc3f284c91c8c1f502da2e93f5c581f41f87f25",
|
||||||
|
"zh:dc425b5f40e94165e563dc55bd6e49cedfe879a03e668168610459ef071b1a87",
|
||||||
|
"zh:f0665907dd24ae93f9511216052d653bba0823c933e888cf02a4abf95f73dfe0",
|
||||||
|
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Terraform Infrastructure
|
||||||
|
|
||||||
|
This directory provisions the Cloudflare resources that heygo depends on:
|
||||||
|
a D1 database, a Workers KV namespace, and two Worker custom domains
|
||||||
|
(public + private shortlink hosts). The same configuration targets both the
|
||||||
|
`dev` and `prod` environments via separate `.tfvars` files.
|
||||||
|
|
||||||
|
## Resources managed
|
||||||
|
|
||||||
|
| Resource | Terraform type | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D1 database | `cloudflare_d1_database` | Stores links, sessions, users, promotion submissions |
|
||||||
|
| KV namespace | `cloudflare_workers_kv_namespace` | Public link cache in front of D1 |
|
||||||
|
| Public custom domain | `cloudflare_workers_custom_domain` | `dev.heygo.cc` / `heygo.cc` |
|
||||||
|
| Private custom domain | `cloudflare_workers_custom_domain` | `my.dev.junv.cc` / `my.heygo.cc` |
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
1. Install Terraform >= 1.5.
|
||||||
|
2. Create a Cloudflare API token with these permissions:
|
||||||
|
- Account / D1: Read + Write
|
||||||
|
- Account / Workers KV Storage: Read + Write
|
||||||
|
- Account / Workers Scripts: Read + Write
|
||||||
|
- Zone / Workers Routes: Read + Write
|
||||||
|
3. Export the token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CLOUDFLARE_API_TOKEN=cf-token-here
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Fill in `account_id`, `heygo_cc_zone_id`, and `private_domain_zone_id` in
|
||||||
|
`environments/dev.tfvars` and `environments/prod.tfvars`. The other values
|
||||||
|
are pre-filled for each environment.
|
||||||
|
|
||||||
|
## Apply per environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev
|
||||||
|
terraform -chdir=terraform init
|
||||||
|
terraform -chdir=terraform plan -var-file=environments/dev.tfvars
|
||||||
|
terraform -chdir=terraform apply -var-file=environments/dev.tfvars
|
||||||
|
|
||||||
|
# Prod
|
||||||
|
terraform -chdir=terraform plan -var-file=environments/prod.tfvars
|
||||||
|
terraform -chdir=terraform apply -var-file=environments/prod.tfvars
|
||||||
|
```
|
||||||
|
|
||||||
|
These commands are also exposed as npm scripts (`infra:init`, `infra:plan:dev`,
|
||||||
|
`infra:apply:dev`, `infra:plan:prod`, `infra:apply:prod`) from the project root.
|
||||||
|
|
||||||
|
## Copy outputs into Wrangler configs
|
||||||
|
|
||||||
|
After `terraform apply`, read the outputs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terraform -chdir=terraform output -var-file=environments/dev.tfvars
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the printed `d1_database_id` into the `d1_databases[].database_id` field
|
||||||
|
and `kv_namespace_id` into the `kv_namespaces[].id` field of the matching
|
||||||
|
wrangler config:
|
||||||
|
|
||||||
|
- dev outputs → `wrangler.dev.jsonc`
|
||||||
|
- prod outputs → `wrangler.prod.jsonc`
|
||||||
|
|
||||||
|
Then apply D1 migrations and deploy the Worker (see the project root README
|
||||||
|
Deployment section).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `terraform/*.tfvars` are committed because they contain no secrets, only
|
||||||
|
placeholder zone/account IDs. Real IDs are filled in locally. The
|
||||||
|
`.gitignore` keeps any other stray `*.tfvars` files out of version control.
|
||||||
|
- State files (`*.tfstate`) and the `.terraform/` plugin cache are git-ignored.
|
||||||
|
For team use, configure a remote backend (e.g. Cloudflare R2 + native
|
||||||
|
`backend` block) before the first `apply`.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
environment = "dev"
|
||||||
|
account_id = "" # Fill in your Cloudflare account ID
|
||||||
|
heygo_cc_zone_id = "" # Fill in heygo.cc zone ID
|
||||||
|
private_domain_zone_id = "" # Fill in junv.cc zone ID
|
||||||
|
public_domain = "dev.heygo.cc"
|
||||||
|
private_domain = "my.dev.junv.cc"
|
||||||
|
d1_database_name = "heygo-shortlinks-dev"
|
||||||
|
kv_namespace_title = "heygo-public-cache-dev"
|
||||||
|
worker_name = "heygo-dev"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
environment = "prod"
|
||||||
|
account_id = "" # Fill in your Cloudflare account ID
|
||||||
|
heygo_cc_zone_id = "" # Fill in heygo.cc zone ID
|
||||||
|
private_domain_zone_id = "" # Fill in heygo.cc zone ID (same as heygo_cc_zone_id for prod)
|
||||||
|
public_domain = "heygo.cc"
|
||||||
|
private_domain = "my.heygo.cc"
|
||||||
|
d1_database_name = "heygo-shortlinks"
|
||||||
|
kv_namespace_title = "heygo-public-cache"
|
||||||
|
worker_name = "heygo"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# D1 database that stores links, sessions, users, and promotion submissions.
|
||||||
|
resource "cloudflare_d1_database" "shortlinks" {
|
||||||
|
account_id = var.account_id
|
||||||
|
name = var.d1_database_name
|
||||||
|
primary_location_hint = var.primary_location_hint
|
||||||
|
}
|
||||||
|
|
||||||
|
# Workers KV namespace used as a public link cache in front of D1.
|
||||||
|
resource "cloudflare_workers_kv_namespace" "public_cache" {
|
||||||
|
account_id = var.account_id
|
||||||
|
title = var.kv_namespace_title
|
||||||
|
}
|
||||||
|
|
||||||
|
# Custom domain for the public shortlinks host (heygo.cc / dev.heygo.cc).
|
||||||
|
# Verified v5 resource name: cloudflare_workers_custom_domain
|
||||||
|
# (see https://developers.cloudflare.com/api/terraform/resources/workers/subresources/domains/).
|
||||||
|
resource "cloudflare_workers_custom_domain" "public" {
|
||||||
|
account_id = var.account_id
|
||||||
|
zone_id = var.heygo_cc_zone_id
|
||||||
|
hostname = var.public_domain
|
||||||
|
service = var.worker_name
|
||||||
|
}
|
||||||
|
|
||||||
|
# Custom domain for the private shortlinks host (my.heygo.cc / my.dev.junv.cc).
|
||||||
|
resource "cloudflare_workers_custom_domain" "private" {
|
||||||
|
account_id = var.account_id
|
||||||
|
zone_id = var.private_domain_zone_id
|
||||||
|
hostname = var.private_domain
|
||||||
|
service = var.worker_name
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
output "d1_database_id" {
|
||||||
|
value = cloudflare_d1_database.shortlinks.id
|
||||||
|
description = "D1 database UUID. Copy this into the database_id field of the matching wrangler config."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "kv_namespace_id" {
|
||||||
|
value = cloudflare_workers_kv_namespace.public_cache.id
|
||||||
|
description = "Workers KV namespace ID. Copy this into the id field of the matching wrangler config."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "worker_name" {
|
||||||
|
value = var.worker_name
|
||||||
|
description = "Name of the deployed Cloudflare Worker service."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "public_domain" {
|
||||||
|
value = var.public_domain
|
||||||
|
description = "Public shortlinks hostname served by the Worker."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "private_domain" {
|
||||||
|
value = var.private_domain
|
||||||
|
description = "Private shortlinks hostname served by the Worker."
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
variable "cloudflare_api_token" {
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
description = "Cloudflare API token with D1, Workers KV, and Workers Custom Domains permissions. Export via the CLOUDFLARE_API_TOKEN env var instead of committing it."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "account_id" {
|
||||||
|
type = string
|
||||||
|
description = "Cloudflare account ID that owns the D1 databases, KV namespaces, and Workers."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "environment" {
|
||||||
|
type = string
|
||||||
|
description = "Deployment environment. Must be \"dev\" or \"prod\"."
|
||||||
|
validation {
|
||||||
|
condition = var.environment == "dev" || var.environment == "prod"
|
||||||
|
error_message = "environment must be either \"dev\" or \"prod\"."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "heygo_cc_zone_id" {
|
||||||
|
type = string
|
||||||
|
description = "Cloudflare zone ID for heygo.cc. The public shortlinks hostname always lives under this zone."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "private_domain_zone_id" {
|
||||||
|
type = string
|
||||||
|
description = "Zone ID where the private shortlinks hostname lives. For prod this is the same as heygo_cc_zone_id (my.heygo.cc is under heygo.cc); for dev it is the junv.cc zone ID (my.dev.junv.cc)."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "public_domain" {
|
||||||
|
type = string
|
||||||
|
description = "Public shortlinks hostname (e.g. heygo.cc or dev.heygo.cc)."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "private_domain" {
|
||||||
|
type = string
|
||||||
|
description = "Private shortlinks hostname (e.g. my.heygo.cc or my.dev.junv.cc)."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "d1_database_name" {
|
||||||
|
type = string
|
||||||
|
description = "Name of the D1 database that stores short links and sessions."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "kv_namespace_title" {
|
||||||
|
type = string
|
||||||
|
description = "Title of the Workers KV namespace used for public link caching."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "worker_name" {
|
||||||
|
type = string
|
||||||
|
description = "Name of the Cloudflare Worker service that the custom domains route to."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "primary_location_hint" {
|
||||||
|
type = string
|
||||||
|
default = "wnam"
|
||||||
|
description = "Region hint for the D1 primary. One of wnam, enam, weur, eeur, apac, oc."
|
||||||
|
validation {
|
||||||
|
condition = contains(
|
||||||
|
["wnam", "enam", "weur", "eeur", "apac", "oc"],
|
||||||
|
var.primary_location_hint,
|
||||||
|
)
|
||||||
|
error_message = "primary_location_hint must be one of wnam, enam, weur, eeur, apac, oc."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
cloudflare = {
|
||||||
|
source = "cloudflare/cloudflare"
|
||||||
|
version = "~> 5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "cloudflare" {
|
||||||
|
api_token = var.cloudflare_api_token
|
||||||
|
}
|
||||||
+11
-1
@@ -306,7 +306,17 @@ function cookie(token: string): string {
|
|||||||
|
|
||||||
function makeEnv(links: LinkRow[] = [], sessions: SessionRow[] = [], dbOptions: FakeD1Options = {}) {
|
function makeEnv(links: LinkRow[] = [], sessions: SessionRow[] = [], dbOptions: FakeD1Options = {}) {
|
||||||
const db = new FakeD1Database(links, sessions, dbOptions);
|
const db = new FakeD1Database(links, sessions, dbOptions);
|
||||||
return { env: { DB: db as unknown as D1Database }, db, ctx: new FakeExecutionContext() };
|
return {
|
||||||
|
env: {
|
||||||
|
DB: db as unknown as D1Database,
|
||||||
|
PUBLIC_HOST: 'heygo.cc',
|
||||||
|
PRIVATE_HOST: 'my.heygo.cc',
|
||||||
|
APP_BASE_URL: 'https://heygo.cc',
|
||||||
|
COOKIE_DOMAIN: '.heygo.cc',
|
||||||
|
},
|
||||||
|
db,
|
||||||
|
ctx: new FakeExecutionContext(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWorker(
|
async function fetchWorker(
|
||||||
|
|||||||
@@ -391,7 +391,17 @@ function makeEnv(
|
|||||||
dbOptions: FakeD1Options = {},
|
dbOptions: FakeD1Options = {},
|
||||||
) {
|
) {
|
||||||
const db = new FakeD1Database(links, submissions, sessions, dbOptions);
|
const db = new FakeD1Database(links, submissions, sessions, dbOptions);
|
||||||
return { env: { DB: db as unknown as D1Database }, db, ctx: new FakeExecutionContext() };
|
return {
|
||||||
|
env: {
|
||||||
|
DB: db as unknown as D1Database,
|
||||||
|
PUBLIC_HOST: 'heygo.cc',
|
||||||
|
PRIVATE_HOST: 'my.heygo.cc',
|
||||||
|
APP_BASE_URL: 'https://heygo.cc',
|
||||||
|
COOKIE_DOMAIN: '.heygo.cc',
|
||||||
|
},
|
||||||
|
db,
|
||||||
|
ctx: new FakeExecutionContext(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWorker(
|
async function fetchWorker(
|
||||||
|
|||||||
+14
-4
@@ -22,8 +22,8 @@ describe('auth configuration constants', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('session cookie policy', () => {
|
describe('session cookie policy', () => {
|
||||||
it('uses a shared heygo.cc cookie domain in production', () => {
|
it('uses the configured cookie domain when one is supplied', () => {
|
||||||
expect(getSessionCookieAttributes('https://my.heygo.cc/app')).toEqual([
|
expect(getSessionCookieAttributes('https://my.heygo.cc/app', '.heygo.cc')).toEqual([
|
||||||
'HttpOnly',
|
'HttpOnly',
|
||||||
'Secure',
|
'Secure',
|
||||||
'SameSite=Lax',
|
'SameSite=Lax',
|
||||||
@@ -32,12 +32,22 @@ describe('session cookie policy', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits Domain for localhost development', () => {
|
it('omits Domain when the cookie domain env var is empty (localhost development)', () => {
|
||||||
expect(getSessionCookieAttributes('http://localhost:5173/app')).toEqual([
|
expect(getSessionCookieAttributes('http://localhost:5173/app', '')).toEqual([
|
||||||
'HttpOnly',
|
'HttpOnly',
|
||||||
'Secure',
|
'Secure',
|
||||||
'SameSite=Lax',
|
'SameSite=Lax',
|
||||||
'Path=/',
|
'Path=/',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('honors an arbitrary dev cookie domain such as .junv.cc', () => {
|
||||||
|
expect(getSessionCookieAttributes('https://my.dev.junv.cc/app', '.junv.cc')).toEqual([
|
||||||
|
'HttpOnly',
|
||||||
|
'Secure',
|
||||||
|
'SameSite=Lax',
|
||||||
|
'Path=/',
|
||||||
|
'Domain=.junv.cc',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import worker from '../worker/index';
|
||||||
|
import { isHeygoPublicHost } from '../worker/routes/redirect';
|
||||||
|
import { isHeygoPrivateHost } from '../worker/routes/private-redirect';
|
||||||
|
|
||||||
|
// Minimal fake D1 that returns no rows. These tests exercise host routing,
|
||||||
|
// not link data, so an empty database is sufficient.
|
||||||
|
class EmptyD1 {
|
||||||
|
prepare(): { bind(): { first(): Promise<null>; all(): Promise<{ results: never[] }> }; first(): Promise<null>; all(): Promise<{ results: never[] }> } {
|
||||||
|
const stmt = {
|
||||||
|
bind() {
|
||||||
|
return stmt;
|
||||||
|
},
|
||||||
|
async first() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async all() {
|
||||||
|
return { results: [] as never[] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeExecutionContext {
|
||||||
|
waitUntil(): void {}
|
||||||
|
passThroughOnException(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HostVars = {
|
||||||
|
PUBLIC_HOST: string;
|
||||||
|
PRIVATE_HOST: string;
|
||||||
|
APP_BASE_URL: string;
|
||||||
|
COOKIE_DOMAIN: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROD_VARS: HostVars = {
|
||||||
|
PUBLIC_HOST: 'heygo.cc',
|
||||||
|
PRIVATE_HOST: 'my.heygo.cc',
|
||||||
|
APP_BASE_URL: 'https://heygo.cc',
|
||||||
|
COOKIE_DOMAIN: '.heygo.cc',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEV_VARS: HostVars = {
|
||||||
|
PUBLIC_HOST: 'dev.heygo.cc',
|
||||||
|
PRIVATE_HOST: 'my.dev.junv.cc',
|
||||||
|
APP_BASE_URL: 'https://dev.heygo.cc',
|
||||||
|
COOKIE_DOMAIN: '.heygo.cc',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchWorker(url: string, vars: HostVars) {
|
||||||
|
const env = { DB: new EmptyD1() as unknown as D1Database, ...vars };
|
||||||
|
const ctx = new FakeExecutionContext();
|
||||||
|
return worker.fetch(
|
||||||
|
new Request(url) as unknown as Parameters<typeof worker.fetch>[0],
|
||||||
|
env as unknown as Parameters<typeof worker.fetch>[1],
|
||||||
|
ctx as unknown as Parameters<typeof worker.fetch>[2],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isHeygoPublicHost / isHeygoPrivateHost are env-driven', () => {
|
||||||
|
it('matches only the configured public host, not the prod default', () => {
|
||||||
|
expect(isHeygoPublicHost('dev.heygo.cc', 'dev.heygo.cc')).toBe(true);
|
||||||
|
expect(isHeygoPublicHost('heygo.cc', 'dev.heygo.cc')).toBe(false);
|
||||||
|
expect(isHeygoPublicHost('heygo.cc', 'heygo.cc')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches only the configured private host', () => {
|
||||||
|
expect(isHeygoPrivateHost('my.dev.junv.cc', 'my.dev.junv.cc')).toBe(true);
|
||||||
|
expect(isHeygoPrivateHost('my.heygo.cc', 'my.dev.junv.cc')).toBe(false);
|
||||||
|
expect(isHeygoPrivateHost('my.heygo.cc', 'my.heygo.cc')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('worker routing with dev environment vars', () => {
|
||||||
|
it('treats dev.heygo.cc as the public shortlink host', async () => {
|
||||||
|
// No rows => unknown alias => public 404 HTML (proves it reached the public route).
|
||||||
|
const response = await fetchWorker('https://dev.heygo.cc/missing', DEV_VARS);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.headers.get('content-type')).toContain('text/html');
|
||||||
|
await expect(response.text()).resolves.toContain('Not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT treat heygo.cc as public when dev vars are configured', async () => {
|
||||||
|
// heygo.cc is not the configured public host, so it falls through to the
|
||||||
|
// generic JSON 404 instead of the public HTML 404.
|
||||||
|
const response = await fetchWorker('https://heygo.cc/missing', DEV_VARS);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.headers.get('content-type')).toContain('application/json');
|
||||||
|
await expect(response.json()).resolves.toEqual({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats my.dev.junv.cc as the private shortlink host (login prompt, no-store)', async () => {
|
||||||
|
const response = await fetchWorker('https://my.dev.junv.cc/foo', DEV_VARS);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.headers.get('content-type')).toContain('text/html');
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
const body = await response.text();
|
||||||
|
// Login link must point at the dev APP_BASE_URL, not the prod one.
|
||||||
|
expect(body).toContain('https://dev.heygo.cc/app/login');
|
||||||
|
expect(body).not.toContain('https://heygo.cc/app/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT treat my.heygo.cc as private when dev vars are configured', async () => {
|
||||||
|
const response = await fetchWorker('https://my.heygo.cc/foo', DEV_VARS);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.headers.get('content-type')).toContain('application/json');
|
||||||
|
await expect(response.json()).resolves.toEqual({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('worker routing with prod environment vars', () => {
|
||||||
|
it('routes heygo.cc to the public handler and my.heygo.cc to the private handler', async () => {
|
||||||
|
const pub = await fetchWorker('https://heygo.cc/missing', PROD_VARS);
|
||||||
|
expect(pub.status).toBe(404);
|
||||||
|
expect(pub.headers.get('content-type')).toContain('text/html');
|
||||||
|
|
||||||
|
const priv = await fetchWorker('https://my.heygo.cc/foo', PROD_VARS);
|
||||||
|
expect(priv.status).toBe(404);
|
||||||
|
expect(priv.headers.get('content-type')).toContain('text/html');
|
||||||
|
expect(priv.headers.get('cache-control')).toBe('no-store');
|
||||||
|
const body = await priv.text();
|
||||||
|
expect(body).toContain('https://heygo.cc/app/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -139,7 +139,13 @@ function futureIso(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EnvBundle = {
|
type EnvBundle = {
|
||||||
env: { DB: D1Database };
|
env: {
|
||||||
|
DB: D1Database;
|
||||||
|
PUBLIC_HOST: string;
|
||||||
|
PRIVATE_HOST: string;
|
||||||
|
APP_BASE_URL: string;
|
||||||
|
COOKIE_DOMAIN: string;
|
||||||
|
};
|
||||||
db: FakeD1Database;
|
db: FakeD1Database;
|
||||||
ctx: FakeExecutionContext;
|
ctx: FakeExecutionContext;
|
||||||
};
|
};
|
||||||
@@ -147,7 +153,13 @@ type EnvBundle = {
|
|||||||
function makeEnv(links: LinkRow[] = [], sessions: SessionRow[] = []): EnvBundle {
|
function makeEnv(links: LinkRow[] = [], sessions: SessionRow[] = []): EnvBundle {
|
||||||
const db = new FakeD1Database(links, sessions);
|
const db = new FakeD1Database(links, sessions);
|
||||||
return {
|
return {
|
||||||
env: { DB: db as unknown as D1Database },
|
env: {
|
||||||
|
DB: db as unknown as D1Database,
|
||||||
|
PUBLIC_HOST: 'heygo.cc',
|
||||||
|
PRIVATE_HOST: 'my.heygo.cc',
|
||||||
|
APP_BASE_URL: 'https://heygo.cc',
|
||||||
|
COOKIE_DOMAIN: '.heygo.cc',
|
||||||
|
},
|
||||||
db,
|
db,
|
||||||
ctx: new FakeExecutionContext(),
|
ctx: new FakeExecutionContext(),
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-1
@@ -86,7 +86,16 @@ class FakeExecutionContext {
|
|||||||
|
|
||||||
function makeEnv(rows: LinkRow[] = []) {
|
function makeEnv(rows: LinkRow[] = []) {
|
||||||
const db = new FakeD1Database(rows);
|
const db = new FakeD1Database(rows);
|
||||||
return { env: { DB: db as unknown as D1Database }, db };
|
return {
|
||||||
|
env: {
|
||||||
|
DB: db as unknown as D1Database,
|
||||||
|
PUBLIC_HOST: 'heygo.cc',
|
||||||
|
PRIVATE_HOST: 'my.heygo.cc',
|
||||||
|
APP_BASE_URL: 'https://heygo.cc',
|
||||||
|
COOKIE_DOMAIN: '.heygo.cc',
|
||||||
|
},
|
||||||
|
db,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWorker(url: string, rows: LinkRow[] = []) {
|
async function fetchWorker(url: string, rows: LinkRow[] = []) {
|
||||||
|
|||||||
+10
-1
@@ -66,7 +66,16 @@ class FakeSessionStatement {
|
|||||||
|
|
||||||
function makeEnv(rows: SessionRow[] = []): { env: Env; db: FakeSessionD1 } {
|
function makeEnv(rows: SessionRow[] = []): { env: Env; db: FakeSessionD1 } {
|
||||||
const db = new FakeSessionD1(rows);
|
const db = new FakeSessionD1(rows);
|
||||||
return { env: { DB: db as unknown as D1Database }, db };
|
return {
|
||||||
|
env: {
|
||||||
|
DB: db as unknown as D1Database,
|
||||||
|
PUBLIC_HOST: 'heygo.cc',
|
||||||
|
PRIVATE_HOST: 'my.heygo.cc',
|
||||||
|
APP_BASE_URL: 'https://heygo.cc',
|
||||||
|
COOKIE_DOMAIN: '.heygo.cc',
|
||||||
|
},
|
||||||
|
db,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRequest(cookieHeader?: string | null): Request {
|
function makeRequest(cookieHeader?: string | null): Request {
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ class FakeExecutionContext {
|
|||||||
|
|
||||||
function fetchWorker(path: string) {
|
function fetchWorker(path: string) {
|
||||||
const request = new Request(`https://heygo.test${path}`);
|
const request = new Request(`https://heygo.test${path}`);
|
||||||
const env = { DB: {} as D1Database };
|
const env = {
|
||||||
|
DB: {} as D1Database,
|
||||||
|
PUBLIC_HOST: 'heygo.cc',
|
||||||
|
PRIVATE_HOST: 'my.heygo.cc',
|
||||||
|
APP_BASE_URL: 'https://heygo.cc',
|
||||||
|
COOKIE_DOMAIN: '.heygo.cc',
|
||||||
|
};
|
||||||
const ctx = new FakeExecutionContext();
|
const ctx = new FakeExecutionContext();
|
||||||
|
|
||||||
return worker.fetch(
|
return worker.fetch(
|
||||||
|
|||||||
+10
-8
@@ -29,21 +29,23 @@ export function isAuthProviderEnabled(provider: string): provider is EnabledAuth
|
|||||||
return ENABLED_PROVIDER_SET.has(provider);
|
return ENABLED_PROVIDER_SET.has(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionCookieAttributes(requestUrl: string | URL): string[] {
|
export function getSessionCookieAttributes(
|
||||||
const url = typeof requestUrl === 'string' ? new URL(requestUrl) : requestUrl;
|
requestUrl: string | URL,
|
||||||
|
cookieDomain: string,
|
||||||
|
): string[] {
|
||||||
|
// The request URL is retained so callers can derive a host-specific domain in
|
||||||
|
// the future; the shared Domain attribute now comes from the COOKIE_DOMAIN
|
||||||
|
// env var so each deployment (dev/prod/local) controls its own scope.
|
||||||
|
void requestUrl;
|
||||||
const attributes = ['HttpOnly', 'Secure', 'SameSite=Lax', 'Path=/'];
|
const attributes = ['HttpOnly', 'Secure', 'SameSite=Lax', 'Path=/'];
|
||||||
|
|
||||||
if (isHeygoProductionHost(url.hostname)) {
|
if (cookieDomain) {
|
||||||
attributes.push('Domain=.heygo.cc');
|
attributes.push(`Domain=${cookieDomain}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return attributes;
|
return attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHeygoProductionHost(hostname: string): boolean {
|
|
||||||
return hostname === 'heygo.cc' || hostname.endsWith('.heygo.cc');
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthError extends Error {
|
export class AuthError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,8 @@ export interface Env {
|
|||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
PUBLIC_LINK_CACHE?: KVNamespace;
|
PUBLIC_LINK_CACHE?: KVNamespace;
|
||||||
ASSETS?: Fetcher;
|
ASSETS?: Fetcher;
|
||||||
|
PUBLIC_HOST: string;
|
||||||
|
PRIVATE_HOST: string;
|
||||||
|
APP_BASE_URL: string;
|
||||||
|
COOKIE_DOMAIN: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -21,8 +21,8 @@ function json(body: unknown, init: ResponseInit = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function withPrivateHostNoStoreHeaders(url: URL, response: Response): Response {
|
function withPrivateHostNoStoreHeaders(url: URL, response: Response, privateHost: string): Response {
|
||||||
return isHeygoPrivateHost(url) ? withPrivateNoStoreHeaders(response) : response;
|
return isHeygoPrivateHost(url.hostname, privateHost) ? withPrivateNoStoreHeaders(response) : response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -30,29 +30,29 @@ export default {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
if (url.pathname === '/api/health') {
|
if (url.pathname === '/api/health') {
|
||||||
return withPrivateHostNoStoreHeaders(url, json({ ok: true, service: 'heygo-worker' }));
|
return withPrivateHostNoStoreHeaders(url, json({ ok: true, service: 'heygo-worker' }), env.PRIVATE_HOST);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith('/api/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
const apiResponse = await handleLinksApi(request, env);
|
const apiResponse = await handleLinksApi(request, env);
|
||||||
if (apiResponse) {
|
if (apiResponse) {
|
||||||
return withPrivateHostNoStoreHeaders(url, apiResponse);
|
return withPrivateHostNoStoreHeaders(url, apiResponse, env.PRIVATE_HOST);
|
||||||
}
|
}
|
||||||
const promotionsResponse = await handlePromotionsApi(request, env);
|
const promotionsResponse = await handlePromotionsApi(request, env);
|
||||||
if (promotionsResponse) {
|
if (promotionsResponse) {
|
||||||
return withPrivateHostNoStoreHeaders(url, promotionsResponse);
|
return withPrivateHostNoStoreHeaders(url, promotionsResponse, env.PRIVATE_HOST);
|
||||||
}
|
}
|
||||||
return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 }));
|
return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 }), env.PRIVATE_HOST);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHeygoPublicHost(url) && !isReservedPublicPath(url.pathname)) {
|
if (isHeygoPublicHost(url.hostname, env.PUBLIC_HOST) && !isReservedPublicPath(url.pathname)) {
|
||||||
return handlePublicShortlink(request, env, ctx);
|
return handlePublicShortlink(request, env, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHeygoPrivateHost(url) && !isReservedPublicPath(url.pathname)) {
|
if (isHeygoPrivateHost(url.hostname, env.PRIVATE_HOST) && !isReservedPublicPath(url.pathname)) {
|
||||||
return handlePrivateShortlink(request, env, ctx);
|
return handlePrivateShortlink(request, env, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 }));
|
return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 }), env.PRIVATE_HOST);
|
||||||
},
|
},
|
||||||
} satisfies ExportedHandler<Env>;
|
} satisfies ExportedHandler<Env>;
|
||||||
|
|||||||
+20
-6
@@ -61,19 +61,33 @@ export function publicBadRequestResponse(message = 'Invalid short link'): Respon
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PRIVATE_LOGIN_URL = 'https://heygo.cc/app/login';
|
/**
|
||||||
export const PRIVATE_APP_URL = 'https://heygo.cc/app/private';
|
* Build the app login URL for a given environment's APP_BASE_URL.
|
||||||
|
* Kept as a function (not a constant) so each deployment's base URL is honored.
|
||||||
|
*/
|
||||||
|
export function privateLoginUrl(appBaseUrl: string): string {
|
||||||
|
return `${appBaseUrl}/app/login`;
|
||||||
|
}
|
||||||
|
|
||||||
export function privateLoginRequiredResponse(): Response {
|
/**
|
||||||
|
* Build the private links app URL for a given environment's APP_BASE_URL.
|
||||||
|
*/
|
||||||
|
export function privateAppUrl(appBaseUrl: string): string {
|
||||||
|
return `${appBaseUrl}/app/private`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function privateLoginRequiredResponse(appBaseUrl: string): Response {
|
||||||
|
const loginUrl = privateLoginUrl(appBaseUrl);
|
||||||
return privateHtmlResponse(
|
return privateHtmlResponse(
|
||||||
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login required</title></head><body><h1>Not found</h1><p><a href="${PRIVATE_LOGIN_URL}">Login to use your private links</a>.</p></body></html>`,
|
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login required</title></head><body><h1>Not found</h1><p><a href="${loginUrl}">Login to use your private links</a>.</p></body></html>`,
|
||||||
{ status: 404 },
|
{ status: 404 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function privateAliasNotFoundResponse(): Response {
|
export function privateAliasNotFoundResponse(appBaseUrl: string): Response {
|
||||||
|
const appUrl = privateAppUrl(appBaseUrl);
|
||||||
return privateHtmlResponse(
|
return privateHtmlResponse(
|
||||||
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not found</title></head><body><h1>Not found</h1><p>This private link does not exist.</p><p><a href="${PRIVATE_APP_URL}">Create this private link</a>.</p></body></html>`,
|
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not found</title></head><body><h1>Not found</h1><p>This private link does not exist.</p><p><a href="${appUrl}">Create this private link</a>.</p></body></html>`,
|
||||||
{ status: 404 },
|
{ status: 404 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { getCurrentUser } from '../auth';
|
|||||||
import { validateAlias } from '../lib/aliases';
|
import { validateAlias } from '../lib/aliases';
|
||||||
import { renderCustomLinkHtml } from '../lib/custom-link';
|
import { renderCustomLinkHtml } from '../lib/custom-link';
|
||||||
import {
|
import {
|
||||||
PRIVATE_APP_URL,
|
|
||||||
privateAliasNotFoundResponse,
|
privateAliasNotFoundResponse,
|
||||||
|
privateAppUrl,
|
||||||
privateHtmlResponse,
|
privateHtmlResponse,
|
||||||
privateLoginRequiredResponse,
|
privateLoginRequiredResponse,
|
||||||
privateRedirectResponse,
|
privateRedirectResponse,
|
||||||
@@ -30,8 +30,8 @@ LIMIT 1`;
|
|||||||
|
|
||||||
const CLICK_COUNT_UPDATE = `UPDATE links SET click_count = click_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id=?`;
|
const CLICK_COUNT_UPDATE = `UPDATE links SET click_count = click_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id=?`;
|
||||||
|
|
||||||
export function isHeygoPrivateHost(url: URL): boolean {
|
export function isHeygoPrivateHost(hostname: string, privateHost: string): boolean {
|
||||||
return url.hostname === 'my.heygo.cc';
|
return hostname === privateHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePrivateShortlink(
|
export async function handlePrivateShortlink(
|
||||||
@@ -45,9 +45,9 @@ export async function handlePrivateShortlink(
|
|||||||
if (url.pathname === '/') {
|
if (url.pathname === '/') {
|
||||||
const user = await getCurrentUser(request, env);
|
const user = await getCurrentUser(request, env);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return privateLoginRequiredResponse();
|
return privateLoginRequiredResponse(env.APP_BASE_URL);
|
||||||
}
|
}
|
||||||
return privateRedirectResponse(PRIVATE_APP_URL, 302);
|
return privateRedirectResponse(privateAppUrl(env.APP_BASE_URL), 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathParts = parseAliasPath(url.pathname);
|
const pathParts = parseAliasPath(url.pathname);
|
||||||
@@ -62,21 +62,21 @@ export async function handlePrivateShortlink(
|
|||||||
|
|
||||||
const user = await getCurrentUser(request, env);
|
const user = await getCurrentUser(request, env);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return privateLoginRequiredResponse();
|
return privateLoginRequiredResponse(env.APP_BASE_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = await env.DB.prepare(PRIVATE_LINK_QUERY)
|
const link = await env.DB.prepare(PRIVATE_LINK_QUERY)
|
||||||
.bind(user.id, aliasValidation.value)
|
.bind(user.id, aliasValidation.value)
|
||||||
.first<PrivateLinkRow>();
|
.first<PrivateLinkRow>();
|
||||||
if (!link) {
|
if (!link) {
|
||||||
return privateAliasNotFoundResponse();
|
return privateAliasNotFoundResponse(env.APP_BASE_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.waitUntil(recordClick(env.DB, link.id));
|
ctx.waitUntil(recordClick(env.DB, link.id));
|
||||||
|
|
||||||
if (link.link_type === 'redirect') {
|
if (link.link_type === 'redirect') {
|
||||||
if (!link.target_url) {
|
if (!link.target_url) {
|
||||||
return privateAliasNotFoundResponse();
|
return privateAliasNotFoundResponse(env.APP_BASE_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -100,15 +100,15 @@ export async function handlePrivateShortlink(
|
|||||||
return privateHtmlResponse(renderCustomLinkHtml(link), { status: 200 });
|
return privateHtmlResponse(renderCustomLinkHtml(link), { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return privateAliasNotFoundResponse();
|
return privateAliasNotFoundResponse(env.APP_BASE_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function privateNotFoundOrLogin(request: Request, env: Env): Promise<Response> {
|
async function privateNotFoundOrLogin(request: Request, env: Env): Promise<Response> {
|
||||||
const user = await getCurrentUser(request, env);
|
const user = await getCurrentUser(request, env);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return privateLoginRequiredResponse();
|
return privateLoginRequiredResponse(env.APP_BASE_URL);
|
||||||
}
|
}
|
||||||
return privateAliasNotFoundResponse();
|
return privateAliasNotFoundResponse(env.APP_BASE_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordClick(db: D1Database, linkId: string): Promise<void> {
|
async function recordClick(db: D1Database, linkId: string): Promise<void> {
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export function isReservedPublicPath(pathname: string): boolean {
|
|||||||
|| pathname === '/admin' || pathname.startsWith('/admin/');
|
|| pathname === '/admin' || pathname.startsWith('/admin/');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHeygoPublicHost(url: URL): boolean {
|
export function isHeygoPublicHost(hostname: string, publicHost: string): boolean {
|
||||||
return url.hostname === 'heygo.cc';
|
return hostname === publicHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePublicShortlink(
|
export async function handlePublicShortlink(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "heygo-dev",
|
||||||
|
"main": "worker/index.ts",
|
||||||
|
"compatibility_date": "2026-06-20",
|
||||||
|
"assets": {
|
||||||
|
"directory": "./dist/client",
|
||||||
|
"not_found_handling": "single-page-application"
|
||||||
|
},
|
||||||
|
"observability": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"vars": {
|
||||||
|
"PUBLIC_HOST": "dev.heygo.cc",
|
||||||
|
"PRIVATE_HOST": "my.dev.junv.cc",
|
||||||
|
"APP_BASE_URL": "https://dev.heygo.cc",
|
||||||
|
"COOKIE_DOMAIN": ".heygo.cc"
|
||||||
|
},
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "heygo-shortlinks-dev",
|
||||||
|
"database_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"migrations_dir": "migrations"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"kv_namespaces": [
|
||||||
|
{
|
||||||
|
"binding": "PUBLIC_LINK_CACHE",
|
||||||
|
"id": "00000000-0000-0000-0000-000000000000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@
|
|||||||
"observability": {
|
"observability": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"vars": {
|
||||||
|
"PUBLIC_HOST": "localhost",
|
||||||
|
"PRIVATE_HOST": "localhost",
|
||||||
|
"APP_BASE_URL": "http://localhost:5173",
|
||||||
|
"COOKIE_DOMAIN": ""
|
||||||
|
},
|
||||||
"d1_databases": [
|
"d1_databases": [
|
||||||
{
|
{
|
||||||
"binding": "DB",
|
"binding": "DB",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "heygo",
|
||||||
|
"main": "worker/index.ts",
|
||||||
|
"compatibility_date": "2026-06-20",
|
||||||
|
"assets": {
|
||||||
|
"directory": "./dist/client",
|
||||||
|
"not_found_handling": "single-page-application"
|
||||||
|
},
|
||||||
|
"observability": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"vars": {
|
||||||
|
"PUBLIC_HOST": "heygo.cc",
|
||||||
|
"PRIVATE_HOST": "my.heygo.cc",
|
||||||
|
"APP_BASE_URL": "https://heygo.cc",
|
||||||
|
"COOKIE_DOMAIN": ".heygo.cc"
|
||||||
|
},
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "heygo-shortlinks",
|
||||||
|
"database_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"migrations_dir": "migrations"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"kv_namespaces": [
|
||||||
|
{
|
||||||
|
"binding": "PUBLIC_LINK_CACHE",
|
||||||
|
"id": "00000000-0000-0000-0000-000000000000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user