diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..79b6c93 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: Quality Checks + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + trunk: + name: Trunk Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: trunk-io/trunk-action@v1 + + build: + name: Build & Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run build + - uses: actions/upload-artifact@v4 + with: + name: worker-dist + path: dist/ + retention-days: 1 + + terraform: + name: Terraform Validate + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: worker-dist + path: dist + - uses: opentofu/setup-opentofu@v1 + - working-directory: terraform + run: tofu init -backend=false + - working-directory: terraform + run: tofu fmt -check + - working-directory: terraform + run: tofu validate diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 65f700f..8508a76 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: deploy: runs-on: ubuntu-latest @@ -11,8 +14,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install dependencies - run: npm install + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci - name: Typecheck run: npm run typecheck diff --git a/.gitignore b/.gitignore index c4f826a..e587f01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,13 @@ node_modules/ .wrangler/ -dist/ *.local + +# Terraform +terraform/.terraform/ +terraform/terraform.tfstate +terraform/terraform.tfstate.backup +terraform/terraform.tfvars +# terraform/.terraform.lock.hcl is intentionally committed (provider version pinning) + +# Worker build output (generated by scripts/build.sh before tf apply) +dist/ diff --git a/.trunk/.gitignore b/.trunk/.gitignore new file mode 100644 index 0000000..15966d0 --- /dev/null +++ b/.trunk/.gitignore @@ -0,0 +1,9 @@ +*out +*logs +*actions +*notifications +*tools +plugins +user_trunk.yaml +user.yaml +tmp diff --git a/.trunk/configs/.markdownlint.yaml b/.trunk/configs/.markdownlint.yaml new file mode 100644 index 0000000..b40ee9d --- /dev/null +++ b/.trunk/configs/.markdownlint.yaml @@ -0,0 +1,2 @@ +# Prettier friendly markdownlint config (all formatting rules disabled) +extends: markdownlint/style/prettier diff --git a/.trunk/configs/.shellcheckrc b/.trunk/configs/.shellcheckrc new file mode 100644 index 0000000..8c7b1ad --- /dev/null +++ b/.trunk/configs/.shellcheckrc @@ -0,0 +1,7 @@ +enable=all +source-path=SCRIPTDIR +disable=SC2154 + +# If you're having issues with shellcheck following source, disable the errors via: +# disable=SC1090 +# disable=SC1091 diff --git a/.trunk/configs/.yamllint.yaml b/.trunk/configs/.yamllint.yaml new file mode 100644 index 0000000..184e251 --- /dev/null +++ b/.trunk/configs/.yamllint.yaml @@ -0,0 +1,7 @@ +rules: + quoted-strings: + required: only-when-needed + extra-allowed: ["{|}"] + key-duplicates: {} + octal-values: + forbid-implicit-octal: true diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml new file mode 100644 index 0000000..9962fd8 --- /dev/null +++ b/.trunk/trunk.yaml @@ -0,0 +1,38 @@ +# This file controls the behavior of Trunk: https://docs.trunk.io/cli +# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml +version: 0.1 +cli: + version: 1.25.0 +# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) +plugins: + sources: + - id: trunk + ref: v1.7.4 + uri: https://github.com/trunk-io/plugins +# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) +runtimes: + enabled: + - go@1.21.0 + - node@22.16.0 + - python@3.10.8 +# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) +lint: + enabled: + - actionlint@1.7.8 + - checkov@3.2.506 + - git-diff-check + - markdownlint@0.47.0 + - osv-scanner@2.2.4 + - prettier@3.8.1 + - shellcheck@0.11.0 + - shfmt@3.6.0 + - taplo@0.10.0 + - tflint@0.61.0 + - trufflehog@3.90.13 + - yamllint@1.38.0 +actions: + enabled: + - trunk-announce + - trunk-check-pre-push + - trunk-fmt-pre-commit + - trunk-upgrade-available diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..13ef681 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +.PHONY: build tf-init tf-plan tf-apply tf-destroy deploy + +# Bundle the worker (required before tf-plan / tf-apply) +build: + ./scripts/build.sh + +# Init Terraform providers +tf-init: + cd terraform && tofu init + +# Plan infra changes (builds first) +tf-plan: build + cd terraform && \ + TF_VAR_publish_token="$$(pass obsidian-redirect/publish-token)" \ + CLOUDFLARE_API_TOKEN="$$(pass cloudflare/workers-api-token)" \ + tofu plan + +# Apply infra changes (builds first) +tf-apply: build + cd terraform && \ + TF_VAR_publish_token="$$(pass obsidian-redirect/publish-token)" \ + CLOUDFLARE_API_TOKEN="$$(pass cloudflare/workers-api-token)" \ + tofu apply + +# Full deploy: build → apply +deploy: tf-apply + +# Destroy all infra (use with care) +tf-destroy: + cd terraform && \ + TF_VAR_publish_token="$$(pass obsidian-redirect/publish-token)" \ + CLOUDFLARE_API_TOKEN="$$(pass cloudflare/workers-api-token)" \ + tofu destroy diff --git a/README.md b/README.md index 1b3d9ad..b42c3a1 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Get your own instance running in ~5 minutes: 1. **Fork** [gisk0/obsidian-redirect](https://github.com/gisk0/obsidian-redirect) 2. **Configure your domain** — edit `wrangler.toml`: + ```toml routes = [ { pattern = "obs.yourdomain.com/*", zone_name = "yourdomain.com" } @@ -16,6 +17,7 @@ Get your own instance running in ~5 minutes: ``` 3. **Deploy:** + ```bash npm install npx wrangler login # authenticate with Cloudflare @@ -32,6 +34,7 @@ Get your own instance running in ~5 minutes: After the first manual deploy, pushes to `main` auto-deploy via GitHub Actions. Add one GitHub secret: **`CF_API_TOKEN`** + - Create at [Cloudflare → Profile → API Tokens](https://dash.cloudflare.com/profile/api-tokens) - Template: **Edit Cloudflare Workers** - Scope: Account → Workers Scripts → Edit @@ -83,10 +86,10 @@ GET /health → 200 "ok" - A **"Open in Obsidian →" button** — user-initiated taps pass WKWebView's security checks (iOS) - A **JS auto-redirect** — fires immediately on macOS/desktop browsers for zero-friction UX -| Platform | Experience | -|---|---| -| **macOS** | Click link → browser flashes → Obsidian opens instantly | -| **iOS** | Tap link → see "Open in Obsidian" page → tap button → Obsidian opens | +| Platform | Experience | +| --------- | -------------------------------------------------------------------- | +| **macOS** | Click link → browser flashes → Obsidian opens instantly | +| **iOS** | Tap link → see "Open in Obsidian" page → tap button → Obsidian opens | The extra tap on iOS is unavoidable — it's a WKWebView security constraint. The button approach is the most reliable method available (Obsidian doesn't support Universal Links). diff --git a/bun.lock b/bun.lock index f175452..177be39 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "obsidian-redirect", + "dependencies": { + "marked": "^17.0.3", + }, "devDependencies": { "@cloudflare/workers-types": "^4.20250214.0", "typescript": "^5.7.3", @@ -164,6 +167,8 @@ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "marked": ["marked@17.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="], + "miniflare": ["miniflare@4.20260219.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260219.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-EIb5wXbWUnnC60XU2aiFOPNd4fgTXzECkwRSOXZ1vdcY9WZaEE9rVf+h+Apw+WkOHRkp3Dr9/ZhQ5y1R+9iZ4Q=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..15501cd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,535 @@ +{ + "name": "obsidian-redirect", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-redirect", + "version": "0.1.0", + "dependencies": { + "marked": "^17.0.3" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250214.0", + "typescript": "^5.7.3", + "wrangler": "^4.0.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.14.0", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "^1.20260218.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260219.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260228.0", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.14", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/marked": { + "version": "17.0.3", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/miniflare": { + "version": "4.20260219.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.18.2", + "workerd": "1.20260219.0", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.18.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260219.0", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260219.0", + "@cloudflare/workerd-darwin-arm64": "1.20260219.0", + "@cloudflare/workerd-linux-64": "1.20260219.0", + "@cloudflare/workerd-linux-arm64": "1.20260219.0", + "@cloudflare/workerd-windows-64": "1.20260219.0" + } + }, + "node_modules/wrangler": { + "version": "4.67.0", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.14.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260219.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260219.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260219.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json index 011e89b..870fd22 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "wrangler dev", + "build": "wrangler deploy --dry-run --outdir=dist", "deploy": "wrangler deploy", "typecheck": "tsc --noEmit" }, @@ -11,5 +12,8 @@ "@cloudflare/workers-types": "^4.20250214.0", "typescript": "^5.7.3", "wrangler": "^4.0.0" + }, + "dependencies": { + "marked": "^17.0.3" } } diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..2cdefbb --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# scripts/build.sh — Bundle the worker for Terraform deployment +# +# Runs wrangler in dry-run mode to produce dist/index.js without deploying. +# Terraform then picks up the bundle via content_file = "../dist/index.js". +# +# Usage: ./scripts/build.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "${REPO_ROOT}" + +echo "Building worker bundle..." +bun run wrangler deploy --dry-run --outdir=dist 2>&1 + +if [[ ! -f dist/index.js ]]; then + echo "Error: dist/index.js not found after build" >&2 + exit 1 +fi + +size="$(du -sh dist/index.js | cut -f1)" +echo "✅ Built: dist/index.js (${size})" diff --git a/src/index.ts b/src/index.ts index 77c4ab0..ce8c4bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,281 @@ -const HTML = (obsidianUrl: string) => ` +import { marked } from "marked"; + +export interface Env { + PUBLISHED_NOTES: KVNamespace; + PUBLISH_TOKEN: string; +} + +interface NoteRecord { + title: string; + markdown: string; + publishedAt: string; + updatedAt: string; +} + +interface NoteMetaEntry { + slug: string; + title: string; + publishedAt: string; + updatedAt: string; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const MAX_BODY_SIZE = 1024 * 1024; // 1 MB +const MAX_MARKDOWN_SIZE = 512 * 1024; // 512 KB +const MAX_TITLE_SIZE = 1024; // 1 KB +const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; +const META_INDEX_KEY = "_meta:index"; + +// ─── CSS ────────────────────────────────────────────────────────────────────── + +const CSS = ` + *, *::before, *::after { box-sizing: border-box; } + html { font-size: 18px; } + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Georgia, serif; + background: #fafaf9; + color: #1c1c1c; + max-width: 720px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; + line-height: 1.75; + } + header { margin-bottom: 2.5rem; border-bottom: 1px solid #e5e5e5; padding-bottom: 1.5rem; } + header h1 { font-size: 2rem; font-weight: 700; margin: 0 0 0.4rem; line-height: 1.25; } + header .meta { color: #6b6b6b; font-size: 0.875rem; } + article h1, article h2, article h3, article h4, article h5, article h6 { + margin: 1.8rem 0 0.6rem; font-weight: 600; line-height: 1.3; + } + article h1 { font-size: 1.75rem; } + article h2 { font-size: 1.4rem; } + article h3 { font-size: 1.15rem; } + article p { margin: 0 0 1rem; } + article a { color: #7c3aed; text-decoration: underline; } + article a:hover { color: #6d28d9; } + article ul, article ol { margin: 0 0 1rem 1.5rem; } + article li { margin-bottom: 0.25rem; } + article blockquote { + border-left: 4px solid #d4d4d4; + margin: 1rem 0; + padding: 0.25rem 1rem; + color: #555; + font-style: italic; + } + article code { + background: #f0efe9; + padding: 0.15em 0.4em; + border-radius: 4px; + font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; + font-size: 0.875em; + } + article pre { + background: #1e1e2e; + color: #cdd6f4; + border-radius: 8px; + padding: 1.25rem; + overflow-x: auto; + margin: 1rem 0; + } + article pre code { + background: none; + padding: 0; + color: inherit; + font-size: 0.875rem; + } + article table { + border-collapse: collapse; + width: 100%; + margin: 1rem 0; + font-size: 0.9rem; + } + article th, article td { + border: 1px solid #e5e5e5; + padding: 0.5rem 0.75rem; + text-align: left; + } + article th { background: #f0efe9; font-weight: 600; } + article img { max-width: 100%; border-radius: 6px; } + article hr { border: none; border-top: 1px solid #e5e5e5; margin: 2rem 0; } + footer { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e5e5; + text-align: center; + color: #9ca3af; + font-size: 0.8rem; + } + @media (max-width: 600px) { + body { padding: 1.25rem 1rem 3rem; } + header h1 { font-size: 1.5rem; } + } +`; + +// ─── HTML Sanitizer (allowlist-based) ───────────────────────────────────────── + +/** Allowed tags and their permitted attributes. Everything else is stripped. */ +const ALLOWED_TAGS: Record> = { + // Block + h1: new Set(), + h2: new Set(), + h3: new Set(), + h4: new Set(), + h5: new Set(), + h6: new Set(), + p: new Set(), + blockquote: new Set(), + pre: new Set(), + hr: new Set(), + br: new Set(), + div: new Set(), + // Inline + a: new Set(["href", "title", "rel"]), + strong: new Set(), + b: new Set(), + em: new Set(), + i: new Set(), + code: new Set(["class"]), + del: new Set(), + s: new Set(), + mark: new Set(), + sub: new Set(), + sup: new Set(), + span: new Set(), + // Lists + ul: new Set(), + ol: new Set(["start"]), + li: new Set(), + // Tables + table: new Set(), + thead: new Set(), + tbody: new Set(), + tr: new Set(), + th: new Set(["align"]), + td: new Set(["align"]), + // Media + img: new Set(["src", "alt", "title", "width", "height"]), + // Definition lists + dl: new Set(), + dt: new Set(), + dd: new Set(), +}; + +/** Attributes whose values must be checked for safe URL schemes. */ +const URL_ATTRS = new Set(["href", "src"]); +const SAFE_URL_RE = /^(?:https?:\/\/|mailto:|#|\/)/i; + +/** + * Allowlist-based HTML sanitizer for CF Workers (no DOM required). + * Parses tags with a regex, keeps only allowed tags/attributes, and ensures + * URL attributes use safe schemes (http, https, mailto, fragment, relative). + */ +function sanitizeHtml(html: string): string { + return html.replace( + /<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)\/?>|/g, + (match, tagName?: string, attrStr?: string) => { + // Strip HTML comments + if (match.startsWith("