Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

Compare changes

Choose any two refs to compare.

+6
.dockerignore
··· 9 9 *.log 10 10 .vscode 11 11 .idea 12 + .prettierrc 13 + testDeploy 14 + .tangled 15 + .crush 16 + .claude 17 + hosting-service
+1
.gitignore
··· 16 16 17 17 # production 18 18 /build 19 + /result 19 20 20 21 # misc 21 22 .DS_Store
-3
.gitmodules
··· 1 - [submodule "cli/jacquard"] 2 - path = cli/jacquard 3 - url = https://tangled.org/@nonbinary.computer/jacquard
-1
.tangled/workflows/deploy-wisp.yml
··· 42 42 43 43 - name: 'Deploy to Wisp.place' 44 44 command: | 45 - echo 46 45 ./cli/target/release/wisp-cli \ 47 46 "$WISP_HANDLE" \ 48 47 --path "$SITE_PATH" \
+4
.tangled/workflows/test.yml
··· 14 14 - name: install dependencies 15 15 command: | 16 16 export PATH="$HOME/.nix-profile/bin:$PATH" 17 + 18 + # have to regenerate otherwise it wont install necessary dependencies to run 19 + rm -rf bun.lock package-lock.json 20 + bun install @oven/bun-linux-aarch64 17 21 bun install 18 22 19 23 - name: run all tests
+10 -15
Dockerfile
··· 5 5 WORKDIR /app 6 6 7 7 # Copy package files 8 - COPY package.json bun.lock* ./ 8 + COPY package.json ./ 9 + 10 + # Copy Bun configuration 11 + COPY bunfig.toml ./ 12 + 13 + COPY tsconfig.json ./ 9 14 10 15 # Install dependencies 11 - RUN bun install --frozen-lockfile 16 + RUN bun install 12 17 13 18 # Copy source code 14 19 COPY src ./src 15 20 COPY public ./public 16 21 17 - # Build the application (if needed) 18 - # RUN bun run build 19 - 20 - # Set environment variables (can be overridden at runtime) 21 - ENV PORT=3000 22 + ENV PORT=8000 22 23 ENV NODE_ENV=production 23 24 24 - # Expose the application port 25 - EXPOSE 3000 26 - 27 - # Health check 28 - HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 29 - CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" 25 + EXPOSE 8000 30 26 31 - # Start the application 32 - CMD ["bun", "src/index.ts"] 27 + CMD ["bun", "start"]
+99 -5
README.md
··· 1 1 # Wisp.place 2 - A static site hosting service built on the AT Protocol. [https://wisp.place](https://wisp.place) 3 2 4 - /src is the main backend 3 + Decentralized static site hosting on the AT Protocol. [https://wisp.place](https://wisp.place) 4 + 5 + ## What is this? 5 6 6 - /hosting-service is the microservice that serves on-disk caches of sites pulled from the firehose and pdses 7 + Host static sites in your AT Protocol repo, served with CDN distribution. Your PDS holds the cryptographically signed manifest and files - the source of truth. Hosting services index and serve them fast. 7 8 8 - /cli is the wisp-cli, a way to upload sites directly to the pds 9 + ## Quick Start 9 10 10 - full readme soon 11 + ```bash 12 + # Using the web interface 13 + Visit https://wisp.place and sign in 14 + 15 + # Or use the CLI 16 + cd cli 17 + cargo build --release 18 + ./target/release/wisp-cli your-handle.bsky.social --path ./my-site --site my-site 19 + ``` 20 + 21 + Your site appears at `https://sites.wisp.place/{your-did}/{site-name}` or your custom domain. 22 + 23 + ## Architecture 24 + 25 + - **`/src`** - Main backend (OAuth, site management, custom domains) 26 + - **`/hosting-service`** - Microservice that serves cached sites from disk 27 + - **`/cli`** - Rust CLI for direct PDS uploads 28 + - **`/public`** - React frontend 29 + 30 + ### How it works 31 + 32 + 1. Sites stored as `place.wisp.fs` records in your AT Protocol repo 33 + 2. Files compressed (gzip) and base64-encoded as blobs 34 + 3. Hosting service watches firehose, caches sites locally 35 + 4. Sites served via custom domains or `*.wisp.place` subdomains 36 + 37 + ## Development 38 + 39 + ```bash 40 + # Backend 41 + bun install 42 + bun run src/index.ts 43 + 44 + # Hosting service 45 + cd hosting-service 46 + npm run start 47 + 48 + # CLI 49 + cd cli 50 + cargo build 51 + ``` 52 + 53 + ## Features 54 + 55 + ### URL Redirects and Rewrites 56 + 57 + The hosting service supports Netlify-style `_redirects` files for managing URLs. Place a `_redirects` file in your site root to enable: 58 + 59 + - **301/302 Redirects**: Permanent and temporary URL redirects 60 + - **200 Rewrites**: Serve different content without changing the URL 61 + - **404 Custom Pages**: Custom error pages for specific paths 62 + - **Splats & Placeholders**: Dynamic path matching (`/blog/:year/:month/:day`, `/news/*`) 63 + - **Query Parameter Matching**: Redirect based on URL parameters 64 + - **Conditional Redirects**: Route by country, language, or cookie presence 65 + - **Force Redirects**: Override existing files with redirects 66 + 67 + Example `_redirects`: 68 + ``` 69 + # Single-page app routing (React, Vue, etc.) 70 + /* /index.html 200 71 + 72 + # Simple redirects 73 + /home / 74 + /old-blog/* /blog/:splat 75 + 76 + # API proxy 77 + /api/* https://api.example.com/:splat 200 78 + 79 + # Country-based routing 80 + / /us/ 302 Country=us 81 + / /uk/ 302 Country=gb 82 + ``` 83 + 84 + ## Limits 85 + 86 + - Max file size: 100MB (PDS limit) 87 + - Max files: 2000 88 + 89 + ## Tech Stack 90 + 91 + - Backend: Bun + Elysia + PostgreSQL 92 + - Frontend: React 19 + Tailwind 4 + Radix UI 93 + - Hosting: Node microservice using Hono 94 + - CLI: Rust + Jacquard (AT Protocol library) 95 + - Protocol: AT Protocol OAuth + custom lexicons 96 + 97 + ## License 98 + 99 + MIT 100 + 101 + ## Links 102 + 103 + - [AT Protocol](https://atproto.com) 104 + - [Jacquard Library](https://tangled.org/@nonbinary.computer/jacquard)
-41
api.md
··· 1 - /** 2 - * AUTHENTICATION ROUTES 3 - * 4 - * Handles OAuth authentication flow for Bluesky/ATProto accounts 5 - * All routes are on the editor.wisp.place subdomain 6 - * 7 - * Routes: 8 - * POST /api/auth/signin - Initiate OAuth sign-in flow 9 - * GET /api/auth/callback - OAuth callback handler (redirect from PDS) 10 - * GET /api/auth/status - Check current authentication status 11 - * POST /api/auth/logout - Sign out and clear session 12 - */ 13 - 14 - /** 15 - * CUSTOM DOMAIN ROUTES 16 - * 17 - * Handles custom domain (BYOD - Bring Your Own Domain) management 18 - * Users can claim custom domains with DNS verification (TXT + CNAME) 19 - * and map them to their sites 20 - * 21 - * Routes: 22 - * GET /api/check-domain - Fast verification check for routing (public) 23 - * GET /api/custom-domains - List user's custom domains 24 - * POST /api/custom-domains/check - Check domain availability and DNS config 25 - * POST /api/custom-domains/claim - Claim a custom domain 26 - * PUT /api/custom-domains/:id/site - Update site mapping 27 - * DELETE /api/custom-domains/:id - Remove a custom domain 28 - * POST /api/custom-domains/:id/verify - Manually trigger verification 29 - */ 30 - 31 - /** 32 - * WISP SITE MANAGEMENT ROUTES 33 - * 34 - * API endpoints for managing user's Wisp sites stored in ATProto repos 35 - * Handles reading site metadata, fetching content, updating sites, and uploads 36 - * All routes are on the editor.wisp.place subdomain 37 - * 38 - * Routes: 39 - * GET /wisp/sites - List all sites for authenticated user 40 - * POST /wisp/upload-files - Upload and deploy files as a site 41 - */
+154 -35
bun.lock
··· 1 1 { 2 2 "lockfileVersion": 1, 3 + "configVersion": 0, 3 4 "workspaces": { 4 5 "": { 5 6 "name": "elysia-static", ··· 13 14 "@elysiajs/openapi": "^1.4.11", 14 15 "@elysiajs/opentelemetry": "^1.4.6", 15 16 "@elysiajs/static": "^1.4.2", 17 + "@radix-ui/react-checkbox": "^1.3.3", 16 18 "@radix-ui/react-dialog": "^1.1.15", 17 19 "@radix-ui/react-label": "^2.1.7", 18 20 "@radix-ui/react-radio-group": "^1.3.8", 19 21 "@radix-ui/react-slot": "^1.2.3", 20 22 "@radix-ui/react-tabs": "^1.1.13", 21 23 "@tanstack/react-query": "^5.90.2", 24 + "actor-typeahead": "^0.1.1", 25 + "atproto-ui": "^0.11.3", 22 26 "class-variance-authority": "^0.7.1", 23 27 "clsx": "^2.1.1", 24 28 "elysia": "latest", 25 29 "iron-session": "^8.0.4", 26 30 "lucide-react": "^0.546.0", 31 + "multiformats": "^13.4.1", 32 + "prismjs": "^1.30.0", 27 33 "react": "^19.2.0", 28 34 "react-dom": "^19.2.0", 29 35 "tailwind-merge": "^3.3.1", ··· 37 43 "@types/react-dom": "^19.2.1", 38 44 "bun-plugin-tailwind": "^0.1.2", 39 45 "bun-types": "latest", 46 + "esbuild": "0.26.0", 40 47 }, 41 48 }, 42 49 }, 43 50 "trustedDependencies": [ 44 51 "core-js", 52 + "cbor-extract", 53 + "bun", 45 54 "protobufjs", 46 55 ], 47 56 "packages": { 57 + "@atcute/atproto": ["@atcute/atproto@3.1.9", "", { "dependencies": { "@atcute/lexicons": "^1.2.2" } }, "sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w=="], 58 + 59 + "@atcute/bluesky": ["@atcute/bluesky@3.2.10", "", { "dependencies": { "@atcute/atproto": "^3.1.9", "@atcute/lexicons": "^1.2.2" } }, "sha512-qwQWTzRf3umnh2u41gdU+xWYkbzGlKDupc3zeOB+YjmuP1N9wEaUhwS8H7vgrqr0xC9SGNDjeUVcjC4m5BPLBg=="], 60 + 61 + "@atcute/client": ["@atcute/client@4.0.5", "", { "dependencies": { "@atcute/identity": "^1.1.1", "@atcute/lexicons": "^1.2.2" } }, "sha512-R8Qen8goGmEkynYGg2m6XFlVmz0GTDvQ+9w+4QqOob+XMk8/WDpF4aImev7WKEde/rV2gjcqW7zM8E6W9NShDA=="], 62 + 63 + "@atcute/identity": ["@atcute/identity@1.1.1", "", { "dependencies": { "@atcute/lexicons": "^1.2.2", "@badrap/valita": "^0.4.6" } }, "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q=="], 64 + 65 + "@atcute/identity-resolver": ["@atcute/identity-resolver@1.1.4", "", { "dependencies": { "@atcute/lexicons": "^1.2.2", "@atcute/util-fetch": "^1.0.3", "@badrap/valita": "^0.4.6" }, "peerDependencies": { "@atcute/identity": "^1.0.0" } }, "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA=="], 66 + 67 + "@atcute/lexicons": ["@atcute/lexicons@1.2.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "esm-env": "^1.2.2" } }, "sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA=="], 68 + 69 + "@atcute/tangled": ["@atcute/tangled@1.0.10", "", { "dependencies": { "@atcute/atproto": "^3.1.8", "@atcute/lexicons": "^1.2.2" } }, "sha512-DGconZIN5TpLBah+aHGbWI1tMsL7XzyVEbr/fW4CbcLWYKICU6SAUZ0YnZ+5GvltjlORWHUy7hfftvoh4zodIA=="], 70 + 71 + "@atcute/util-fetch": ["@atcute/util-fetch@1.0.3", "", { "dependencies": { "@badrap/valita": "^0.4.6" } }, "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ=="], 72 + 48 73 "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.2", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ=="], 49 74 50 75 "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 51 76 52 - "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.1.10", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-o7hGaonA71A6p7O107VhM6UBUN/g9tTyYohMp1q0Kf6xQ4npnuZYRSHSf2g6reSfGQJ1GoFNjBObETTT1ge/jQ=="], 77 + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], 53 78 54 79 "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.2", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-KIerCzh3qb+zZoqWbIvTlvBY0XPq0r56kwViaJY/LTe/3oPO2JaqlYKS/F4dByWBhHK6YoUOJ0sWrh6PMJl40A=="], 55 80 56 - "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.20", "", { "dependencies": { "@atproto-labs/fetch-node": "0.1.10", "@atproto-labs/handle-resolver": "0.3.2", "@atproto/did": "0.2.1" } }, "sha512-094EL61XN9M7vm22cloSOxk/gcTRaCK52vN7BYgXgdoEI8uJJMTFXenQqu+LRGwiCcjvyclcBqbaz0DzJep50Q=="], 81 + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.21", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.2", "@atproto/did": "0.2.1" } }, "sha512-fuJy5Px5pGF3lJX/ATdurbT8tbmaFWtf+PPxAQDFy7ot2no3t+iaAgymhyxYymrssOuWs6BwOP8tyF3VrfdwtQ=="], 57 82 58 83 "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.2", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver": "0.3.2" } }, "sha512-MYxO9pe0WsFyi5HFdKAwqIqHfiF2kBPoVhAIuH/4PYHzGr799ED47xLhNMxR3ZUYrJm5+TQzWXypGZ0Btw1Ffw=="], 59 84 ··· 63 88 64 89 "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 65 90 66 - "@atproto/api": ["@atproto/api@0.17.3", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-pdQXhUAapNPdmN00W0vX5ta/aMkHqfgBHATt20X02XwxQpY2AnrPm2Iog4FyjsZqoHooAtCNV/NWJ4xfddJzsg=="], 91 + "@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="], 67 92 68 93 "@atproto/common": ["@atproto/common@0.4.12", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@ipld/dag-cbor": "^7.0.3", "cbor-x": "^1.5.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-NC+TULLQiqs6MvNymhQS5WDms3SlbIKGLf4n33tpftRJcalh507rI+snbcUb7TLIkKw7VO17qMqxEXtIdd5auQ=="], 69 94 ··· 79 104 80 105 "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 81 106 82 - "@atproto/lex-cli": ["@atproto/lex-cli@0.9.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-zun4jhD1dbjD7IHtLIjh/1UsMx+6E8+OyOT2GXYAKIj9N6wmLKM/v2OeQBKxcyqUmtRi57lxWnGikWjjU7pplQ=="], 107 + "@atproto/lex-cli": ["@atproto/lex-cli@0.9.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-EedEKmURoSP735YwSDHsFrLOhZ4P2it8goCHv5ApWi/R9DFpOKOpmYfIXJ9MAprK8cw+yBnjDJbzpLJy7UXlTg=="], 83 108 84 109 "@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="], 85 110 86 - "@atproto/oauth-client": ["@atproto/oauth-client@0.5.7", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.4.2", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-pDvbvy9DCxrAJv7bAbBUzWrHZKhFy091HvEMZhr+EyZA6gSCGYmmQJG/coDj0oICSVQeafAZd+IxR0YUCWwmEg=="], 111 + "@atproto/oauth-client": ["@atproto/oauth-client@0.5.8", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.5.0", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-7YEym6d97+Dd73qGdkQTXi5La8xvCQxwRUDzzlR/NVAARa9a4YP7MCmqBJVeP2anT0By+DSAPyPDLTsxcjIcCg=="], 87 112 88 - "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.9", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver-node": "0.1.20", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.7", "@atproto/oauth-types": "0.4.2" } }, "sha512-JdzwDQ8Gczl0lgfJNm7lG7omkJ4yu99IuGkkRWixpEvKY/jY/mDZaho+3pfd29SrUvwQOOx4Bm4l7DGeYwxxyA=="], 113 + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.10", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver-node": "0.1.21", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.8", "@atproto/oauth-types": "0.5.0" } }, "sha512-6khKlJqu1Ed5rt3rzcTD5hymB6JUjKdOHWYXwiphw4inkAIo6GxLCighI4eGOqZorYk2j8ueeTNB6KsgH0kcRw=="], 89 114 90 - "@atproto/oauth-types": ["@atproto/oauth-types@0.4.2", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-gcfNTyFsPJcYDf79M0iKHykWqzxloscioKoerdIN3MTS3htiNOSgZjm2p8ho7pdrElLzea3qktuhTQI39j1XFQ=="], 115 + "@atproto/oauth-types": ["@atproto/oauth-types@0.5.0", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-33xz7HcXhbl+XRqbIMVu3GE02iK1nKe2oMWENASsfZEYbCz2b9ZOarOFuwi7g4LKqpGowGp0iRKsQHFcq4SDaQ=="], 91 116 92 117 "@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="], 93 118 94 119 "@atproto/xrpc": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA=="], 95 120 96 121 "@atproto/xrpc-server": ["@atproto/xrpc-server@0.9.5", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@atproto/xrpc": "^0.7.5", "cbor-x": "^1.5.1", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "uint8arrays": "3.0.0", "ws": "^8.12.0", "zod": "^3.23.8" } }, "sha512-V0srjUgy6mQ5yf9+MSNBLs457m4qclEaWZsnqIE7RfYywvntexTAbMoo7J7ONfTNwdmA9Gw4oLak2z2cDAET4w=="], 122 + 123 + "@badrap/valita": ["@badrap/valita@0.4.6", "", {}, "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="], 97 124 98 125 "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], 99 126 ··· 111 138 112 139 "@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-pb0SCzBfFbFSYA/U40HHO7R+YrcXBJXOWgL20eSViK33ol1e20ru2/KUaZYo5IMUn63yaTJI/bQERuQ+77ND8g=="], 113 140 114 - "@elysiajs/eden": ["@elysiajs/eden@1.4.3", "", { "peerDependencies": { "elysia": ">= 1.4.0-exp.0" } }, "sha512-mX0v5cTvTJiDsDWNEEyuoqudOvW5J+tXsvp/ZOJXJF3iCIEJI0Brvm78ymPrvwiOG4nUr3lS8BxUfbNf32DSXA=="], 141 + "@elysiajs/eden": ["@elysiajs/eden@1.4.4", "", { "peerDependencies": { "elysia": ">= 1.4.0-exp.0" } }, "sha512-/LVqflmgUcCiXb8rz1iRq9Rx3SWfIV/EkoNqDFGMx+TvOyo8QHAygFXAVQz7RHs+jk6n6mEgpI6KlKBANoErsQ=="], 115 142 116 143 "@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="], 117 144 118 145 "@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="], 119 146 120 - "@elysiajs/static": ["@elysiajs/static@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lAEvdxeBhU/jX/hTzfoP+1AtqhsKNCwW4Q+tfNwAShWU6s4ZPQxR1hLoHBveeApofJt4HWEq/tBGvfFz3ODuKg=="], 147 + "@elysiajs/static": ["@elysiajs/static@1.4.6", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-cd61aY/DHOVhlnBjzTBX8E1XANIrsCH8MwEGHeLMaZzNrz0gD4Q8Qsde2dFMzu81I7ZDaaZ2Rim9blSLtUrYBg=="], 148 + 149 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.26.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-hj0sKNCQOOo2fgyII3clmJXP28VhgDfU5iy3GNHlWO76KG6N7x4D9ezH5lJtQTG+1J6MFDAJXC1qsI+W+LvZoA=="], 150 + 151 + "@esbuild/android-arm": ["@esbuild/android-arm@0.26.0", "", { "os": "android", "cpu": "arm" }, "sha512-C0hkDsYNHZkBtPxxDx177JN90/1MiCpvBNjz1f5yWJo1+5+c5zr8apjastpEG+wtPjo9FFtGG7owSsAxyKiHxA=="], 152 + 153 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.26.0", "", { "os": "android", "cpu": "arm64" }, "sha512-DDnoJ5eoa13L8zPh87PUlRd/IyFaIKOlRbxiwcSbeumcJ7UZKdtuMCHa1Q27LWQggug6W4m28i4/O2qiQQ5NZQ=="], 154 + 155 + "@esbuild/android-x64": ["@esbuild/android-x64@0.26.0", "", { "os": "android", "cpu": "x64" }, "sha512-bKDkGXGZnj0T70cRpgmv549x38Vr2O3UWLbjT2qmIkdIWcmlg8yebcFWoT9Dku7b5OV3UqPEuNKRzlNhjwUJ9A=="], 156 + 157 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.26.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Z3naJgOuAIB0RLlJkYc81An3rTlQ/IeRdrU3dOea8h/PvZSgitZV+thNuIccw0MuK1GmIAnAmd5TrMZad8FTQ=="], 158 + 159 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.26.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-OPnYj0zpYW0tHusMefyaMvNYQX5pNQuSsHFTHUBNp3vVXupwqpxofcjVsUx11CQhGVkGeXjC3WLjh91hgBG2xw=="], 160 + 161 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.26.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jix2fa6GQeZhO1sCKNaNMjfj5hbOvoL2F5t+w6gEPxALumkpOV/wq7oUBMHBn2hY2dOm+mEV/K+xfZy3mrsxNQ=="], 162 + 163 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.26.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tccJaH5xHJD/239LjbVvJwf6T4kSzbk6wPFerF0uwWlkw/u7HL+wnAzAH5GB2irGhYemDgiNTp8wJzhAHQ64oA=="], 164 + 165 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.26.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JY8NyU31SyRmRpuc5W8PQarAx4TvuYbyxbPIpHAZdr/0g4iBr8KwQBS4kiiamGl2f42BBecHusYCsyxi7Kn8UQ=="], 166 + 167 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.26.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IMJYN7FSkLttYyTbsbme0Ra14cBO5z47kpamo16IwggzzATFY2lcZAwkbcNkWiAduKrTgFJP7fW5cBI7FzcuNQ=="], 168 + 169 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.26.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-XITaGqGVLgk8WOHw8We9Z1L0lbLFip8LyQzKYFKO4zFo1PFaaSKsbNjvkb7O8kEXytmSGRkYpE8LLVpPJpsSlw=="], 170 + 171 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.26.0", "", { "os": "linux", "cpu": "none" }, "sha512-MkggfbDIczStUJwq9wU7gQ7kO33d8j9lWuOCDifN9t47+PeI+9m2QVh51EI/zZQ1spZtFMC1nzBJ+qNGCjJnsg=="], 172 + 173 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.26.0", "", { "os": "linux", "cpu": "none" }, "sha512-fUYup12HZWAeccNLhQ5HwNBPr4zXCPgUWzEq2Rfw7UwqwfQrFZ0SR/JljaURR8xIh9t+o1lNUFTECUTmaP7yKA=="], 174 + 175 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.26.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-MzRKhM0Ip+//VYwC8tialCiwUQ4G65WfALtJEFyU0GKJzfTYoPBw5XNWf0SLbCUYQbxTKamlVwPmcw4DgZzFxg=="], 176 + 177 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.26.0", "", { "os": "linux", "cpu": "none" }, "sha512-QhCc32CwI1I4Jrg1enCv292sm3YJprW8WHHlyxJhae/dVs+KRWkbvz2Nynl5HmZDW/m9ZxrXayHzjzVNvQMGQA=="], 178 + 179 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.26.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-1D6vi6lfI18aNT1aTf2HV+RIlm6fxtlAp8eOJ4mmnbYmZ4boz8zYDar86sIYNh0wmiLJEbW/EocaKAX6Yso2fw=="], 180 + 181 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.26.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rnDcepj7LjrKFvZkx+WrBv6wECeYACcFjdNPvVPojCPJD8nHpb3pv3AuR9CXgdnjH1O23btICj0rsp0L9wAnHA=="], 182 + 183 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.26.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FSWmgGp0mDNjEXXFcsf12BmVrb+sZBBBlyh3LwB/B9ac3Kkc8x5D2WimYW9N7SUkolui8JzVnVlWh7ZmjCpnxw=="], 184 + 185 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.26.0", "", { "os": "none", "cpu": "x64" }, "sha512-0QfciUDFryD39QoSPUDshj4uNEjQhp73+3pbSAaxjV2qGOEDsM67P7KbJq7LzHoVl46oqhIhJ1S+skKGR7lMXA=="], 186 + 187 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.26.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-vmAK+nHhIZWImwJ3RNw9hX3fU4UGN/OqbSE0imqljNbUQC3GvVJ1jpwYoTfD6mmXmQaxdJY6Hn4jQbLGJKg5Yw=="], 188 + 189 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.26.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-GPXF7RMkJ7o9bTyUsnyNtrFMqgM3X+uM/LWw4CeHIjqc32fm0Ir6jKDnWHpj8xHFstgWDUYseSABK9KCkHGnpg=="], 190 + 191 + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.26.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nUHZ5jEYqbBthbiBksbmHTlbb5eElyVfs/s1iHQ8rLBq1eWsd5maOnDpCocw1OM8kFK747d1Xms8dXJHtduxSw=="], 192 + 193 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.26.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-TMg3KCTCYYaVO+R6P5mSORhcNDDlemUVnUbb8QkboUtOhb5JWKAzd5uMIMECJQOxHZ/R+N8HHtDF5ylzLfMiLw=="], 194 + 195 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.26.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-apqYgoAUd6ZCb9Phcs8zN32q6l0ZQzQBdVXOofa6WvHDlSOhwCWgSfVQabGViThS40Y1NA4SCvQickgZMFZRlA=="], 196 + 197 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.26.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-FGJAcImbJNZzLWu7U6WB0iKHl4RuY4TsXEwxJPl9UZLS47agIZuILZEX3Pagfw7I4J3ddflomt9f0apfaJSbaw=="], 198 + 199 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.26.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WAckBKaVnmFqbEhbymrPK7M086DQMpL1XoRbpmN0iW8k5JSXjDRQBhcZNa0VweItknLq9eAeCL34jK7/CDcw7A=="], 121 200 122 - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="], 201 + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="], 123 202 124 203 "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], 125 204 ··· 185 264 186 265 "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="], 187 266 188 - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], 267 + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.38.0", "", {}, "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg=="], 189 268 190 - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="], 269 + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-licBDIbbLP5L5/S0+bwtJynso94XD3KyqSP48K59Sq7Mude6C7dR5ZujZm4Ut4BwZqUFfNOfYNMWBU5nlL7t1A=="], 191 270 192 - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="], 271 + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-hn8lLzsYyyh6ULo2E8v2SqtrWOkdQKJwapeVy1rDw7juTTeHY3KDudGWf4mVYteC9riZU6HD88Fn3nGwyX0eIg=="], 193 272 194 - "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+FSr/ub5vA/EkD3fMhHJUzYioSf/sXd50OGxNDAntVxcDu4tXL/81Ka3R/gkZmjznpLFIzovU/1Ts+b7dlkrfw=="], 273 + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UHxdtbyxdtNJUNcXtIrjx3Lmq8ji3KywlXtIHV/0vn9A8W5mulqOcryqUWMFVH9JTIIzmNn6Q/qVmXHTME63Ww=="], 195 274 196 - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WHthS/eLkCNcp9pk4W8aubRl9fIUgt2XhHyLrP0GClB1FVvmodu/zIOtG0NXNpzlzB8+gglOkGo4dPjfVf4Z+g=="], 275 + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5uZzxzvHU/z+3cZwN/A0H8G+enQ+9FkeJVZkE2fwK2XhiJZFUGAuWajCpy7GepvOWlqV7VjPaKi2+Qmr4IX7nQ=="], 197 276 198 - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HT5sr7N8NDYbQRjAnT7ISpx64y+ewZZRQozOJb0+KQObKvg4UUNXGm4Pn1xA4/WPMZDDazjO8E2vtOQw1nJlAQ=="], 277 + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-OD9DYkjes7WXieBn4zQZGXWhRVZhIEWMDGCetZ3H4vxIuweZ++iul/CNX5jdpNXaJ17myb1ROMvmRbrqW44j3w=="], 199 278 200 - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sGEWoJQXO4GDr0x4t/yJQ/Bq1yNkOdX9tHbZZ+DBGJt3z3r7jeb4Digv8xQUk6gdTFC9vnGHuin+KW3/yD1Aww=="], 279 + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EoEuRP9bxAxVKuvi6tZ0ZENjueP4lvjz0mKsMzdG0kwg/2apGKiirH1l0RIcdmvfDGGuDmNiv/XBpkoXq1x8ug=="], 201 280 202 - "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OmlEH3nlxQyv7HOvTH21vyNAZGv9DIPnrTznzvKiOQxkOphhCyKvPTlF13ydw4s/i18iwaUrhHy+YG9HSSxa4Q=="], 281 + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m9Ov9YH8KjRLui87eNtQQFKVnjGsNk3xgbrR9c8d2FS3NfZSxmVjSeBvEsDjzNf1TXLDriHb/NYOlpiMf/QzDg=="], 203 282 204 - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rtzUEzCynl3Rhgn/iR9DQezSFiZMcAXAbU+xfROqsweMGKwvwIA2ckyyckO08psEP8XcUZTs3LT9CH7PnaMiEA=="], 283 + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-3TuOsRVoG8K+soQWRo+Cp5ACpRs6rTFSu5tAqc/6WrqwbNWmqjov/eWJPTgz3gPXnC7uNKVG7RxxAmV8r2EYTQ=="], 205 284 206 - "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hrr7mDvUjMX1tuJaXz448tMsgKIqGJBY8+rJqztKOw1U5+a/v2w5HuIIW1ce7ut0ZwEn+KIDvAujlPvpH33vpQ=="], 285 + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-q8Hto8hcpofPJjvuvjuwyYvhOaAzPw1F5vRUUeOJDmDwZ4lZhANFM0rUwchMzfWUJCD6jg8/EVQ8MiixnZWU0A=="], 207 286 208 - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xXwtpZVVP7T+vkxcF/TUVVOGRjEfkByO4mKveKYb4xnHWV4u4NnV0oNmzyMKkvmj10to5j2h0oZxA4ZVVv4gfA=="], 287 + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nZJUa5NprPYQ4Ii4cMwtP9PzlJJTp1XhxJ+A9eSn1Jfr6YygVWyN2KLjenyI93IcuBouBAaepDAVZZjH2lFBhg=="], 209 288 210 - "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="], 289 + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-s00T99MjB+xLOWq+t+wVaVBrry+oBOZNiTJijt+bmkp/MJptYS3FGvs7a+nkjLNzoNDoWQcXgKew6AaHES37Bg=="], 211 290 212 291 "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], 213 292 ··· 230 309 "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], 231 310 232 311 "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], 312 + 313 + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], 233 314 234 315 "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], 235 316 ··· 249 330 250 331 "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], 251 332 252 - "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], 333 + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], 253 334 254 335 "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], 255 336 ··· 261 342 262 343 "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], 263 344 264 - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 345 + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], 265 346 266 347 "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], 267 348 ··· 281 362 282 363 "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], 283 364 284 - "@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="], 365 + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], 366 + 367 + "@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="], 285 368 286 - "@tanstack/react-query": ["@tanstack/react-query@5.90.2", "", { "dependencies": { "@tanstack/query-core": "5.90.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw=="], 369 + "@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="], 287 370 288 371 "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], 289 372 ··· 291 374 292 375 "@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="], 293 376 294 - "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], 377 + "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], 295 378 296 379 "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], 297 380 298 - "@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="], 381 + "@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="], 299 382 300 383 "@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="], 301 384 ··· 307 390 308 391 "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], 309 392 393 + "actor-typeahead": ["actor-typeahead@0.1.1", "", {}, "sha512-ilsBwzplKwMSBiO6Tg6RdaZ5xxqgXds5jCQuHV+ib9Aq3ja9g0T7u2Y1PmihotmS7l5RxhpGI/tPm3ljoRDRwg=="], 394 + 310 395 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 311 396 312 397 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], ··· 317 402 318 403 "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], 319 404 405 + "atproto-ui": ["atproto-ui@0.11.3", "", { "dependencies": { "@atcute/atproto": "^3.1.7", "@atcute/bluesky": "^3.2.3", "@atcute/client": "^4.0.3", "@atcute/identity-resolver": "^1.1.3", "@atcute/tangled": "^1.0.10" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-NIBsORuo9lpCpr1SNKcKhNvqOVpsEy9IoHqFe1CM9gNTArpQL1hUcoP1Cou9a1O5qzCul9kaiu5xBHnB81I/WQ=="], 406 + 320 407 "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 321 408 322 409 "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], ··· 329 416 330 417 "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], 331 418 332 - "bun": ["bun@1.3.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.0", "@oven/bun-darwin-x64": "1.3.0", "@oven/bun-darwin-x64-baseline": "1.3.0", "@oven/bun-linux-aarch64": "1.3.0", "@oven/bun-linux-aarch64-musl": "1.3.0", "@oven/bun-linux-x64": "1.3.0", "@oven/bun-linux-x64-baseline": "1.3.0", "@oven/bun-linux-x64-musl": "1.3.0", "@oven/bun-linux-x64-musl-baseline": "1.3.0", "@oven/bun-windows-x64": "1.3.0", "@oven/bun-windows-x64-baseline": "1.3.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-YI7mFs7iWc/VsGsh2aw6eAPD2cjzn1j+LKdYVk09x1CrdTWKYIHyd+dG5iQoN9//3hCDoZj8U6vKpZzEf5UARA=="], 419 + "bun": ["bun@1.3.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.2", "@oven/bun-darwin-x64": "1.3.2", "@oven/bun-darwin-x64-baseline": "1.3.2", "@oven/bun-linux-aarch64": "1.3.2", "@oven/bun-linux-aarch64-musl": "1.3.2", "@oven/bun-linux-x64": "1.3.2", "@oven/bun-linux-x64-baseline": "1.3.2", "@oven/bun-linux-x64-musl": "1.3.2", "@oven/bun-linux-x64-musl-baseline": "1.3.2", "@oven/bun-windows-x64": "1.3.2", "@oven/bun-windows-x64-baseline": "1.3.2" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-x75mPJiEfhO1j4Tfc65+PtW6ZyrAB6yTZInydnjDZXF9u9PRAnr6OK3v0Q9dpDl0dxRHkXlYvJ8tteJxc8t4Sw=="], 333 420 334 421 "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], 335 422 336 - "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], 423 + "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], 337 424 338 425 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 339 426 ··· 391 478 392 479 "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], 393 480 394 - "elysia": ["elysia@1.4.11", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-cphuzQj0fRw1ICRvwHy2H3xQio9bycaZUVHnDHJQnKqBfMNlZ+Hzj6TMmt9lc0Az0mvbCnPXWVF7y1MCRhUuOA=="], 481 + "elysia": ["elysia@1.4.15", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-RaDqqZdLuC4UJetfVRQ4Z5aVpGgEtQ+pZnsbI4ZzEaf3l/MzuHcqSVoL/Fue3d6qE4RV9HMB2rAZaHyPIxkyzg=="], 395 482 396 483 "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 397 484 ··· 403 490 404 491 "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 405 492 493 + "esbuild": ["esbuild@0.26.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.26.0", "@esbuild/android-arm": "0.26.0", "@esbuild/android-arm64": "0.26.0", "@esbuild/android-x64": "0.26.0", "@esbuild/darwin-arm64": "0.26.0", "@esbuild/darwin-x64": "0.26.0", "@esbuild/freebsd-arm64": "0.26.0", "@esbuild/freebsd-x64": "0.26.0", "@esbuild/linux-arm": "0.26.0", "@esbuild/linux-arm64": "0.26.0", "@esbuild/linux-ia32": "0.26.0", "@esbuild/linux-loong64": "0.26.0", "@esbuild/linux-mips64el": "0.26.0", "@esbuild/linux-ppc64": "0.26.0", "@esbuild/linux-riscv64": "0.26.0", "@esbuild/linux-s390x": "0.26.0", "@esbuild/linux-x64": "0.26.0", "@esbuild/netbsd-arm64": "0.26.0", "@esbuild/netbsd-x64": "0.26.0", "@esbuild/openbsd-arm64": "0.26.0", "@esbuild/openbsd-x64": "0.26.0", "@esbuild/openharmony-arm64": "0.26.0", "@esbuild/sunos-x64": "0.26.0", "@esbuild/win32-arm64": "0.26.0", "@esbuild/win32-ia32": "0.26.0", "@esbuild/win32-x64": "0.26.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-3Hq7jri+tRrVWha+ZeIVhl4qJRha/XjRNSopvTsOaCvfPHrflTYTcUFcEjMKdxofsXXsdc4zjg5NOTnL4Gl57Q=="], 494 + 406 495 "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 407 496 408 497 "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 498 + 499 + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], 409 500 410 501 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], 411 502 ··· 489 580 490 581 "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], 491 582 583 + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], 584 + 492 585 "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], 493 586 494 587 "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], ··· 505 598 506 599 "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 507 600 508 - "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 601 + "multiformats": ["multiformats@13.4.1", "", {}, "sha512-VqO6OSvLrFVAYYjgsr8tyv62/rCQhPgsZUXLTqoFLSgdkgiUYKYeArbt1uWLlEpkjxQe+P0+sHlbPEte1Bi06Q=="], 509 602 510 603 "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], 511 604 ··· 536 629 "pino-std-serializers": ["pino-std-serializers@6.2.2", "", {}, "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA=="], 537 630 538 631 "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], 632 + 633 + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], 539 634 540 635 "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], 541 636 ··· 619 714 620 715 "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], 621 716 622 - "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], 717 + "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], 623 718 624 719 "thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw=="], 625 720 626 721 "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], 627 722 628 - "tlds": ["tlds@1.260.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ=="], 723 + "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], 629 724 630 725 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], 631 726 ··· 649 744 650 745 "undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="], 651 746 652 - "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], 747 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 653 748 654 749 "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], 655 750 ··· 677 772 678 773 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 679 774 775 + "@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 776 + 777 + "@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 778 + 779 + "@atproto/common-web/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 780 + 781 + "@atproto/jwk/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 782 + 783 + "@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 784 + 785 + "@atproto/oauth-client/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 786 + 787 + "@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 788 + 789 + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 790 + 791 + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 792 + 793 + "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], 794 + 795 + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 796 + 680 797 "@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 681 798 682 799 "express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], ··· 690 807 "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], 691 808 692 809 "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 810 + 811 + "uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 693 812 694 813 "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 695 814
+25
cli/.gitignore
··· 1 + test/ 2 + .DS_STORE 3 + jacquard/ 4 + binaries/ 5 + # Generated by Cargo 6 + # will have compiled files and executables 7 + debug 8 + target 9 + 10 + # These are backup files generated by rustfmt 11 + **/*.rs.bk 12 + 13 + # MSVC Windows builds of rustc generate these, which store debugging information 14 + *.pdb 15 + 16 + # Generated by cargo mutants 17 + # Contains mutation testing data 18 + **/mutants.out*/ 19 + 20 + # RustRover 21 + # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 22 + # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 23 + # and can be added to the global gitignore or merged into this file. For a more nuclear 24 + # option (not recommended) you can uncomment the following to ignore the entire idea folder. 25 + #.idea/
+581 -170
cli/Cargo.lock
··· 139 139 140 140 [[package]] 141 141 name = "async-compression" 142 - version = "0.4.32" 142 + version = "0.4.33" 143 143 source = "registry+https://github.com/rust-lang/crates.io-index" 144 - checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" 144 + checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" 145 145 dependencies = [ 146 146 "compression-codecs", 147 147 "compression-core", ··· 158 158 dependencies = [ 159 159 "proc-macro2", 160 160 "quote", 161 - "syn 2.0.108", 161 + "syn 2.0.110", 162 162 ] 163 163 164 164 [[package]] ··· 174 174 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 175 175 176 176 [[package]] 177 + name = "axum" 178 + version = "0.7.9" 179 + source = "registry+https://github.com/rust-lang/crates.io-index" 180 + checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" 181 + dependencies = [ 182 + "async-trait", 183 + "axum-core", 184 + "bytes", 185 + "futures-util", 186 + "http", 187 + "http-body", 188 + "http-body-util", 189 + "hyper", 190 + "hyper-util", 191 + "itoa", 192 + "matchit", 193 + "memchr", 194 + "mime", 195 + "percent-encoding", 196 + "pin-project-lite", 197 + "rustversion", 198 + "serde", 199 + "serde_json", 200 + "serde_path_to_error", 201 + "serde_urlencoded", 202 + "sync_wrapper", 203 + "tokio", 204 + "tower 0.5.2", 205 + "tower-layer", 206 + "tower-service", 207 + "tracing", 208 + ] 209 + 210 + [[package]] 211 + name = "axum-core" 212 + version = "0.4.5" 213 + source = "registry+https://github.com/rust-lang/crates.io-index" 214 + checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" 215 + dependencies = [ 216 + "async-trait", 217 + "bytes", 218 + "futures-util", 219 + "http", 220 + "http-body", 221 + "http-body-util", 222 + "mime", 223 + "pin-project-lite", 224 + "rustversion", 225 + "sync_wrapper", 226 + "tower-layer", 227 + "tower-service", 228 + "tracing", 229 + ] 230 + 231 + [[package]] 177 232 name = "backtrace" 178 233 version = "0.3.76" 179 234 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 274 329 "proc-macro2", 275 330 "quote", 276 331 "rustversion", 277 - "syn 2.0.108", 332 + "syn 2.0.110", 278 333 ] 279 334 280 335 [[package]] ··· 348 403 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 349 404 350 405 [[package]] 406 + name = "byteorder" 407 + version = "1.5.0" 408 + source = "registry+https://github.com/rust-lang/crates.io-index" 409 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 410 + 411 + [[package]] 351 412 name = "bytes" 352 413 version = "1.10.1" 353 414 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 367 428 368 429 [[package]] 369 430 name = "cc" 370 - version = "1.2.44" 431 + version = "1.2.45" 371 432 source = "registry+https://github.com/rust-lang/crates.io-index" 372 - checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" 433 + checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" 373 434 dependencies = [ 374 435 "find-msvc-tools", 375 436 "shlex", ··· 494 555 "heck 0.5.0", 495 556 "proc-macro2", 496 557 "quote", 497 - "syn 2.0.108", 558 + "syn 2.0.110", 498 559 ] 499 560 500 561 [[package]] ··· 521 582 522 583 [[package]] 523 584 name = "compression-codecs" 524 - version = "0.4.31" 585 + version = "0.4.32" 525 586 source = "registry+https://github.com/rust-lang/crates.io-index" 526 - checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" 587 + checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" 527 588 dependencies = [ 528 589 "compression-core", 529 590 "flate2", ··· 532 593 533 594 [[package]] 534 595 name = "compression-core" 535 - version = "0.4.29" 596 + version = "0.4.30" 536 597 source = "registry+https://github.com/rust-lang/crates.io-index" 537 - checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" 598 + checksum = "3a9b614a5787ef0c8802a55766480563cb3a93b435898c422ed2a359cf811582" 538 599 539 600 [[package]] 540 601 name = "const-oid" ··· 549 610 checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 550 611 551 612 [[package]] 613 + name = "cordyceps" 614 + version = "0.3.4" 615 + source = "registry+https://github.com/rust-lang/crates.io-index" 616 + checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" 617 + dependencies = [ 618 + "loom", 619 + "tracing", 620 + ] 621 + 622 + [[package]] 552 623 name = "core-foundation" 553 624 version = "0.9.4" 554 625 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 665 736 "proc-macro2", 666 737 "quote", 667 738 "strsim", 668 - "syn 2.0.108", 739 + "syn 2.0.110", 669 740 ] 670 741 671 742 [[package]] ··· 676 747 dependencies = [ 677 748 "darling_core", 678 749 "quote", 679 - "syn 2.0.108", 750 + "syn 2.0.110", 680 751 ] 681 752 682 753 [[package]] ··· 716 787 checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 717 788 dependencies = [ 718 789 "data-encoding", 719 - "syn 2.0.108", 790 + "syn 2.0.110", 720 791 ] 721 792 722 793 [[package]] ··· 751 822 ] 752 823 753 824 [[package]] 825 + name = "derive_more" 826 + version = "1.0.0" 827 + source = "registry+https://github.com/rust-lang/crates.io-index" 828 + checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" 829 + dependencies = [ 830 + "derive_more-impl", 831 + ] 832 + 833 + [[package]] 834 + name = "derive_more-impl" 835 + version = "1.0.0" 836 + source = "registry+https://github.com/rust-lang/crates.io-index" 837 + checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" 838 + dependencies = [ 839 + "proc-macro2", 840 + "quote", 841 + "syn 2.0.110", 842 + "unicode-xid", 843 + ] 844 + 845 + [[package]] 846 + name = "diatomic-waker" 847 + version = "0.2.3" 848 + source = "registry+https://github.com/rust-lang/crates.io-index" 849 + checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" 850 + 851 + [[package]] 754 852 name = "digest" 755 853 version = "0.10.7" 756 854 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 791 889 dependencies = [ 792 890 "proc-macro2", 793 891 "quote", 794 - "syn 2.0.108", 892 + "syn 2.0.110", 795 893 ] 796 894 797 895 [[package]] ··· 852 950 "heck 0.5.0", 853 951 "proc-macro2", 854 952 "quote", 855 - "syn 2.0.108", 953 + "syn 2.0.110", 856 954 ] 857 955 858 956 [[package]] ··· 922 1020 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 923 1021 924 1022 [[package]] 925 - name = "foreign-types" 926 - version = "0.3.2" 927 - source = "registry+https://github.com/rust-lang/crates.io-index" 928 - checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 929 - dependencies = [ 930 - "foreign-types-shared", 931 - ] 932 - 933 - [[package]] 934 - name = "foreign-types-shared" 935 - version = "0.1.1" 936 - source = "registry+https://github.com/rust-lang/crates.io-index" 937 - checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 938 - 939 - [[package]] 940 1023 name = "form_urlencoded" 941 1024 version = "1.2.2" 942 1025 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 971 1054 ] 972 1055 973 1056 [[package]] 1057 + name = "futures-buffered" 1058 + version = "0.2.12" 1059 + source = "registry+https://github.com/rust-lang/crates.io-index" 1060 + checksum = "a8e0e1f38ec07ba4abbde21eed377082f17ccb988be9d988a5adbf4bafc118fd" 1061 + dependencies = [ 1062 + "cordyceps", 1063 + "diatomic-waker", 1064 + "futures-core", 1065 + "pin-project-lite", 1066 + "spin 0.10.0", 1067 + ] 1068 + 1069 + [[package]] 974 1070 name = "futures-channel" 975 1071 version = "0.3.31" 976 1072 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1004 1100 checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 1005 1101 1006 1102 [[package]] 1103 + name = "futures-lite" 1104 + version = "2.6.1" 1105 + source = "registry+https://github.com/rust-lang/crates.io-index" 1106 + checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" 1107 + dependencies = [ 1108 + "fastrand", 1109 + "futures-core", 1110 + "futures-io", 1111 + "parking", 1112 + "pin-project-lite", 1113 + ] 1114 + 1115 + [[package]] 1007 1116 name = "futures-macro" 1008 1117 version = "0.3.31" 1009 1118 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1011 1120 dependencies = [ 1012 1121 "proc-macro2", 1013 1122 "quote", 1014 - "syn 2.0.108", 1123 + "syn 2.0.110", 1015 1124 ] 1016 1125 1017 1126 [[package]] ··· 1042 1151 "pin-project-lite", 1043 1152 "pin-utils", 1044 1153 "slab", 1154 + ] 1155 + 1156 + [[package]] 1157 + name = "generator" 1158 + version = "0.8.7" 1159 + source = "registry+https://github.com/rust-lang/crates.io-index" 1160 + checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" 1161 + dependencies = [ 1162 + "cc", 1163 + "cfg-if", 1164 + "libc", 1165 + "log", 1166 + "rustversion", 1167 + "windows", 1045 1168 ] 1046 1169 1047 1170 [[package]] ··· 1251 1374 "markup5ever", 1252 1375 "proc-macro2", 1253 1376 "quote", 1254 - "syn 2.0.108", 1377 + "syn 2.0.110", 1255 1378 ] 1256 1379 1257 1380 [[package]] ··· 1289 1412 ] 1290 1413 1291 1414 [[package]] 1415 + name = "http-range-header" 1416 + version = "0.4.2" 1417 + source = "registry+https://github.com/rust-lang/crates.io-index" 1418 + checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" 1419 + 1420 + [[package]] 1292 1421 name = "httparse" 1293 1422 version = "1.10.1" 1294 1423 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1302 1431 1303 1432 [[package]] 1304 1433 name = "hyper" 1305 - version = "1.7.0" 1434 + version = "1.8.0" 1306 1435 source = "registry+https://github.com/rust-lang/crates.io-index" 1307 - checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" 1436 + checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" 1308 1437 dependencies = [ 1309 1438 "atomic-waker", 1310 1439 "bytes", ··· 1314 1443 "http", 1315 1444 "http-body", 1316 1445 "httparse", 1446 + "httpdate", 1317 1447 "itoa", 1318 1448 "pin-project-lite", 1319 1449 "pin-utils", ··· 1340 1470 ] 1341 1471 1342 1472 [[package]] 1343 - name = "hyper-tls" 1344 - version = "0.6.0" 1345 - source = "registry+https://github.com/rust-lang/crates.io-index" 1346 - checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" 1347 - dependencies = [ 1348 - "bytes", 1349 - "http-body-util", 1350 - "hyper", 1351 - "hyper-util", 1352 - "native-tls", 1353 - "tokio", 1354 - "tokio-native-tls", 1355 - "tower-service", 1356 - ] 1357 - 1358 - [[package]] 1359 1473 name = "hyper-util" 1360 1474 version = "0.1.17" 1361 1475 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1393 1507 "js-sys", 1394 1508 "log", 1395 1509 "wasm-bindgen", 1396 - "windows-core", 1510 + "windows-core 0.62.2", 1397 1511 ] 1398 1512 1399 1513 [[package]] ··· 1585 1699 1586 1700 [[package]] 1587 1701 name = "iri-string" 1588 - version = "0.7.8" 1702 + version = "0.7.9" 1589 1703 source = "registry+https://github.com/rust-lang/crates.io-index" 1590 - checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" 1704 + checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" 1591 1705 dependencies = [ 1592 1706 "memchr", 1593 1707 "serde", ··· 1614 1728 [[package]] 1615 1729 name = "jacquard" 1616 1730 version = "0.9.0" 1731 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1617 1732 dependencies = [ 1618 1733 "bytes", 1619 1734 "getrandom 0.2.16", ··· 1641 1756 [[package]] 1642 1757 name = "jacquard-api" 1643 1758 version = "0.9.0" 1759 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1644 1760 dependencies = [ 1645 1761 "bon", 1646 1762 "bytes", ··· 1658 1774 [[package]] 1659 1775 name = "jacquard-common" 1660 1776 version = "0.9.0" 1777 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1661 1778 dependencies = [ 1662 1779 "base64 0.22.1", 1663 1780 "bon", 1664 1781 "bytes", 1665 1782 "chrono", 1783 + "ciborium", 1666 1784 "cid", 1785 + "futures", 1667 1786 "getrandom 0.2.16", 1668 1787 "getrandom 0.3.4", 1669 1788 "http", ··· 1673 1792 "miette", 1674 1793 "multibase", 1675 1794 "multihash", 1795 + "n0-future", 1676 1796 "ouroboros", 1677 1797 "p256", 1678 1798 "rand 0.9.2", ··· 1686 1806 "smol_str", 1687 1807 "thiserror 2.0.17", 1688 1808 "tokio", 1809 + "tokio-tungstenite-wasm", 1689 1810 "tokio-util", 1690 1811 "trait-variant", 1691 1812 "url", ··· 1694 1815 [[package]] 1695 1816 name = "jacquard-derive" 1696 1817 version = "0.9.0" 1818 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1697 1819 dependencies = [ 1698 1820 "heck 0.5.0", 1699 1821 "jacquard-lexicon", 1700 1822 "proc-macro2", 1701 1823 "quote", 1702 - "syn 2.0.108", 1824 + "syn 2.0.110", 1703 1825 ] 1704 1826 1705 1827 [[package]] 1706 1828 name = "jacquard-identity" 1707 - version = "0.9.0" 1829 + version = "0.9.1" 1830 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1708 1831 dependencies = [ 1709 1832 "bon", 1710 1833 "bytes", ··· 1729 1852 1730 1853 [[package]] 1731 1854 name = "jacquard-lexicon" 1732 - version = "0.9.0" 1855 + version = "0.9.1" 1856 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1733 1857 dependencies = [ 1734 1858 "cid", 1735 1859 "dashmap", ··· 1747 1871 "serde_repr", 1748 1872 "serde_with", 1749 1873 "sha2", 1750 - "syn 2.0.108", 1874 + "syn 2.0.110", 1751 1875 "thiserror 2.0.17", 1752 1876 "unicode-segmentation", 1753 1877 ] ··· 1755 1879 [[package]] 1756 1880 name = "jacquard-oauth" 1757 1881 version = "0.9.0" 1882 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1758 1883 dependencies = [ 1759 1884 "base64 0.22.1", 1760 1885 "bytes", ··· 1880 2005 source = "registry+https://github.com/rust-lang/crates.io-index" 1881 2006 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1882 2007 dependencies = [ 1883 - "spin", 2008 + "spin 0.9.8", 1884 2009 ] 1885 2010 1886 2011 [[package]] ··· 1940 2065 checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 1941 2066 1942 2067 [[package]] 2068 + name = "loom" 2069 + version = "0.7.2" 2070 + source = "registry+https://github.com/rust-lang/crates.io-index" 2071 + checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" 2072 + dependencies = [ 2073 + "cfg-if", 2074 + "generator", 2075 + "scoped-tls", 2076 + "tracing", 2077 + "tracing-subscriber", 2078 + ] 2079 + 2080 + [[package]] 1943 2081 name = "lru-cache" 1944 2082 version = "0.1.2" 1945 2083 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1998 2136 ] 1999 2137 2000 2138 [[package]] 2139 + name = "matchers" 2140 + version = "0.2.0" 2141 + source = "registry+https://github.com/rust-lang/crates.io-index" 2142 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 2143 + dependencies = [ 2144 + "regex-automata", 2145 + ] 2146 + 2147 + [[package]] 2148 + name = "matchit" 2149 + version = "0.7.3" 2150 + source = "registry+https://github.com/rust-lang/crates.io-index" 2151 + checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" 2152 + 2153 + [[package]] 2001 2154 name = "memchr" 2002 2155 version = "2.7.6" 2003 2156 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2030 2183 dependencies = [ 2031 2184 "proc-macro2", 2032 2185 "quote", 2033 - "syn 2.0.108", 2186 + "syn 2.0.110", 2034 2187 ] 2035 2188 2036 2189 [[package]] ··· 2132 2285 ] 2133 2286 2134 2287 [[package]] 2135 - name = "native-tls" 2136 - version = "0.2.14" 2288 + name = "n0-future" 2289 + version = "0.1.3" 2137 2290 source = "registry+https://github.com/rust-lang/crates.io-index" 2138 - checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" 2291 + checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794" 2139 2292 dependencies = [ 2140 - "libc", 2141 - "log", 2142 - "openssl", 2143 - "openssl-probe", 2144 - "openssl-sys", 2145 - "schannel", 2146 - "security-framework", 2147 - "security-framework-sys", 2148 - "tempfile", 2293 + "cfg_aliases", 2294 + "derive_more", 2295 + "futures-buffered", 2296 + "futures-lite", 2297 + "futures-util", 2298 + "js-sys", 2299 + "pin-project", 2300 + "send_wrapper", 2301 + "tokio", 2302 + "tokio-util", 2303 + "wasm-bindgen", 2304 + "wasm-bindgen-futures", 2305 + "web-time", 2149 2306 ] 2150 2307 2151 2308 [[package]] ··· 2171 2328 ] 2172 2329 2173 2330 [[package]] 2331 + name = "nu-ansi-term" 2332 + version = "0.50.3" 2333 + source = "registry+https://github.com/rust-lang/crates.io-index" 2334 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 2335 + dependencies = [ 2336 + "windows-sys 0.61.2", 2337 + ] 2338 + 2339 + [[package]] 2174 2340 name = "num-bigint-dig" 2175 - version = "0.8.5" 2341 + version = "0.8.6" 2176 2342 source = "registry+https://github.com/rust-lang/crates.io-index" 2177 - checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" 2343 + checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" 2178 2344 dependencies = [ 2179 2345 "lazy_static", 2180 2346 "libm", ··· 2288 2454 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 2289 2455 2290 2456 [[package]] 2291 - name = "openssl" 2292 - version = "0.10.74" 2293 - source = "registry+https://github.com/rust-lang/crates.io-index" 2294 - checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" 2295 - dependencies = [ 2296 - "bitflags", 2297 - "cfg-if", 2298 - "foreign-types", 2299 - "libc", 2300 - "once_cell", 2301 - "openssl-macros", 2302 - "openssl-sys", 2303 - ] 2304 - 2305 - [[package]] 2306 - name = "openssl-macros" 2307 - version = "0.1.1" 2308 - source = "registry+https://github.com/rust-lang/crates.io-index" 2309 - checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 2310 - dependencies = [ 2311 - "proc-macro2", 2312 - "quote", 2313 - "syn 2.0.108", 2314 - ] 2315 - 2316 - [[package]] 2317 2457 name = "openssl-probe" 2318 2458 version = "0.1.6" 2319 2459 source = "registry+https://github.com/rust-lang/crates.io-index" 2320 2460 checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 2321 - 2322 - [[package]] 2323 - name = "openssl-sys" 2324 - version = "0.9.110" 2325 - source = "registry+https://github.com/rust-lang/crates.io-index" 2326 - checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" 2327 - dependencies = [ 2328 - "cc", 2329 - "libc", 2330 - "pkg-config", 2331 - "vcpkg", 2332 - ] 2333 2461 2334 2462 [[package]] 2335 2463 name = "option-ext" ··· 2358 2486 "proc-macro2", 2359 2487 "proc-macro2-diagnostics", 2360 2488 "quote", 2361 - "syn 2.0.108", 2489 + "syn 2.0.110", 2362 2490 ] 2363 2491 2364 2492 [[package]] ··· 2388 2516 "elliptic-curve", 2389 2517 "primeorder", 2390 2518 ] 2519 + 2520 + [[package]] 2521 + name = "parking" 2522 + version = "2.2.1" 2523 + source = "registry+https://github.com/rust-lang/crates.io-index" 2524 + checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 2391 2525 2392 2526 [[package]] 2393 2527 name = "parking_lot" ··· 2466 2600 ] 2467 2601 2468 2602 [[package]] 2603 + name = "pin-project" 2604 + version = "1.1.10" 2605 + source = "registry+https://github.com/rust-lang/crates.io-index" 2606 + checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" 2607 + dependencies = [ 2608 + "pin-project-internal", 2609 + ] 2610 + 2611 + [[package]] 2612 + name = "pin-project-internal" 2613 + version = "1.1.10" 2614 + source = "registry+https://github.com/rust-lang/crates.io-index" 2615 + checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" 2616 + dependencies = [ 2617 + "proc-macro2", 2618 + "quote", 2619 + "syn 2.0.110", 2620 + ] 2621 + 2622 + [[package]] 2469 2623 name = "pin-project-lite" 2470 2624 version = "0.2.16" 2471 2625 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2497 2651 "der", 2498 2652 "spki", 2499 2653 ] 2500 - 2501 - [[package]] 2502 - name = "pkg-config" 2503 - version = "0.3.32" 2504 - source = "registry+https://github.com/rust-lang/crates.io-index" 2505 - checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 2506 2654 2507 2655 [[package]] 2508 2656 name = "potential_utf" ··· 2541 2689 checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 2542 2690 dependencies = [ 2543 2691 "proc-macro2", 2544 - "syn 2.0.108", 2692 + "syn 2.0.110", 2545 2693 ] 2546 2694 2547 2695 [[package]] ··· 2594 2742 dependencies = [ 2595 2743 "proc-macro2", 2596 2744 "quote", 2597 - "syn 2.0.108", 2745 + "syn 2.0.110", 2598 2746 "version_check", 2599 2747 "yansi", 2600 2748 ] ··· 2662 2810 2663 2811 [[package]] 2664 2812 name = "quote" 2665 - version = "1.0.41" 2813 + version = "1.0.42" 2666 2814 source = "registry+https://github.com/rust-lang/crates.io-index" 2667 - checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" 2815 + checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 2668 2816 dependencies = [ 2669 2817 "proc-macro2", 2670 2818 ] ··· 2777 2925 dependencies = [ 2778 2926 "proc-macro2", 2779 2927 "quote", 2780 - "syn 2.0.108", 2928 + "syn 2.0.110", 2781 2929 ] 2782 2930 2783 2931 [[package]] ··· 2827 2975 "http-body-util", 2828 2976 "hyper", 2829 2977 "hyper-rustls", 2830 - "hyper-tls", 2831 2978 "hyper-util", 2832 2979 "js-sys", 2833 2980 "log", 2834 2981 "mime", 2835 - "native-tls", 2836 2982 "percent-encoding", 2837 2983 "pin-project-lite", 2838 2984 "quinn", ··· 2843 2989 "serde_urlencoded", 2844 2990 "sync_wrapper", 2845 2991 "tokio", 2846 - "tokio-native-tls", 2847 2992 "tokio-rustls", 2848 2993 "tokio-util", 2849 - "tower", 2850 - "tower-http", 2994 + "tower 0.5.2", 2995 + "tower-http 0.6.6", 2851 2996 "tower-service", 2852 2997 "url", 2853 2998 "wasm-bindgen", ··· 2958 3103 2959 3104 [[package]] 2960 3105 name = "rustls" 2961 - version = "0.23.34" 3106 + version = "0.23.35" 2962 3107 source = "registry+https://github.com/rust-lang/crates.io-index" 2963 - checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" 3108 + checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" 2964 3109 dependencies = [ 2965 3110 "once_cell", 2966 3111 "ring", ··· 2968 3113 "rustls-webpki", 2969 3114 "subtle", 2970 3115 "zeroize", 3116 + ] 3117 + 3118 + [[package]] 3119 + name = "rustls-native-certs" 3120 + version = "0.8.2" 3121 + source = "registry+https://github.com/rust-lang/crates.io-index" 3122 + checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" 3123 + dependencies = [ 3124 + "openssl-probe", 3125 + "rustls-pki-types", 3126 + "schannel", 3127 + "security-framework", 2971 3128 ] 2972 3129 2973 3130 [[package]] ··· 3041 3198 3042 3199 [[package]] 3043 3200 name = "schemars" 3044 - version = "1.0.4" 3201 + version = "1.1.0" 3045 3202 source = "registry+https://github.com/rust-lang/crates.io-index" 3046 - checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" 3203 + checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" 3047 3204 dependencies = [ 3048 3205 "dyn-clone", 3049 3206 "ref-cast", ··· 3052 3209 ] 3053 3210 3054 3211 [[package]] 3212 + name = "scoped-tls" 3213 + version = "1.0.1" 3214 + source = "registry+https://github.com/rust-lang/crates.io-index" 3215 + checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 3216 + 3217 + [[package]] 3055 3218 name = "scopeguard" 3056 3219 version = "1.2.0" 3057 3220 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3073 3236 3074 3237 [[package]] 3075 3238 name = "security-framework" 3076 - version = "2.11.1" 3239 + version = "3.5.1" 3077 3240 source = "registry+https://github.com/rust-lang/crates.io-index" 3078 - checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 3241 + checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" 3079 3242 dependencies = [ 3080 3243 "bitflags", 3081 - "core-foundation 0.9.4", 3244 + "core-foundation 0.10.1", 3082 3245 "core-foundation-sys", 3083 3246 "libc", 3084 3247 "security-framework-sys", ··· 3095 3258 ] 3096 3259 3097 3260 [[package]] 3261 + name = "send_wrapper" 3262 + version = "0.6.0" 3263 + source = "registry+https://github.com/rust-lang/crates.io-index" 3264 + checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" 3265 + 3266 + [[package]] 3098 3267 name = "serde" 3099 3268 version = "1.0.228" 3100 3269 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3131 3300 dependencies = [ 3132 3301 "proc-macro2", 3133 3302 "quote", 3134 - "syn 2.0.108", 3303 + "syn 2.0.110", 3135 3304 ] 3136 3305 3137 3306 [[package]] ··· 3173 3342 ] 3174 3343 3175 3344 [[package]] 3345 + name = "serde_path_to_error" 3346 + version = "0.1.20" 3347 + source = "registry+https://github.com/rust-lang/crates.io-index" 3348 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 3349 + dependencies = [ 3350 + "itoa", 3351 + "serde", 3352 + "serde_core", 3353 + ] 3354 + 3355 + [[package]] 3176 3356 name = "serde_repr" 3177 3357 version = "0.1.20" 3178 3358 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3180 3360 dependencies = [ 3181 3361 "proc-macro2", 3182 3362 "quote", 3183 - "syn 2.0.108", 3363 + "syn 2.0.110", 3184 3364 ] 3185 3365 3186 3366 [[package]] ··· 3207 3387 "indexmap 1.9.3", 3208 3388 "indexmap 2.12.0", 3209 3389 "schemars 0.9.0", 3210 - "schemars 1.0.4", 3390 + "schemars 1.1.0", 3211 3391 "serde_core", 3212 3392 "serde_json", 3213 3393 "serde_with_macros", ··· 3223 3403 "darling", 3224 3404 "proc-macro2", 3225 3405 "quote", 3226 - "syn 2.0.108", 3406 + "syn 2.0.110", 3407 + ] 3408 + 3409 + [[package]] 3410 + name = "sha1" 3411 + version = "0.10.6" 3412 + source = "registry+https://github.com/rust-lang/crates.io-index" 3413 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 3414 + dependencies = [ 3415 + "cfg-if", 3416 + "cpufeatures", 3417 + "digest", 3227 3418 ] 3228 3419 3229 3420 [[package]] ··· 3241 3432 "cfg-if", 3242 3433 "cpufeatures", 3243 3434 "digest", 3435 + ] 3436 + 3437 + [[package]] 3438 + name = "sharded-slab" 3439 + version = "0.1.7" 3440 + source = "registry+https://github.com/rust-lang/crates.io-index" 3441 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 3442 + dependencies = [ 3443 + "lazy_static", 3244 3444 ] 3245 3445 3246 3446 [[package]] ··· 3338 3538 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 3339 3539 3340 3540 [[package]] 3541 + name = "spin" 3542 + version = "0.10.0" 3543 + source = "registry+https://github.com/rust-lang/crates.io-index" 3544 + checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" 3545 + 3546 + [[package]] 3341 3547 name = "spki" 3342 3548 version = "0.7.3" 3343 3549 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3369 3575 "quote", 3370 3576 "serde", 3371 3577 "sha2", 3372 - "syn 2.0.108", 3578 + "syn 2.0.110", 3373 3579 "thiserror 1.0.69", 3374 3580 ] 3375 3581 ··· 3450 3656 3451 3657 [[package]] 3452 3658 name = "syn" 3453 - version = "2.0.108" 3659 + version = "2.0.110" 3454 3660 source = "registry+https://github.com/rust-lang/crates.io-index" 3455 - checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" 3661 + checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" 3456 3662 dependencies = [ 3457 3663 "proc-macro2", 3458 3664 "quote", ··· 3476 3682 dependencies = [ 3477 3683 "proc-macro2", 3478 3684 "quote", 3479 - "syn 2.0.108", 3685 + "syn 2.0.110", 3480 3686 ] 3481 3687 3482 3688 [[package]] ··· 3576 3782 dependencies = [ 3577 3783 "proc-macro2", 3578 3784 "quote", 3579 - "syn 2.0.108", 3785 + "syn 2.0.110", 3580 3786 ] 3581 3787 3582 3788 [[package]] ··· 3587 3793 dependencies = [ 3588 3794 "proc-macro2", 3589 3795 "quote", 3590 - "syn 2.0.108", 3796 + "syn 2.0.110", 3797 + ] 3798 + 3799 + [[package]] 3800 + name = "thread_local" 3801 + version = "1.1.9" 3802 + source = "registry+https://github.com/rust-lang/crates.io-index" 3803 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 3804 + dependencies = [ 3805 + "cfg-if", 3591 3806 ] 3592 3807 3593 3808 [[package]] ··· 3694 3909 dependencies = [ 3695 3910 "proc-macro2", 3696 3911 "quote", 3697 - "syn 2.0.108", 3912 + "syn 2.0.110", 3698 3913 ] 3699 3914 3700 3915 [[package]] 3701 - name = "tokio-native-tls" 3702 - version = "0.3.1" 3916 + name = "tokio-rustls" 3917 + version = "0.26.4" 3703 3918 source = "registry+https://github.com/rust-lang/crates.io-index" 3704 - checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" 3919 + checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 3705 3920 dependencies = [ 3706 - "native-tls", 3921 + "rustls", 3707 3922 "tokio", 3708 3923 ] 3709 3924 3710 3925 [[package]] 3711 - name = "tokio-rustls" 3712 - version = "0.26.4" 3926 + name = "tokio-tungstenite" 3927 + version = "0.24.0" 3713 3928 source = "registry+https://github.com/rust-lang/crates.io-index" 3714 - checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 3929 + checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" 3715 3930 dependencies = [ 3931 + "futures-util", 3932 + "log", 3716 3933 "rustls", 3934 + "rustls-native-certs", 3935 + "rustls-pki-types", 3717 3936 "tokio", 3937 + "tokio-rustls", 3938 + "tungstenite", 3939 + ] 3940 + 3941 + [[package]] 3942 + name = "tokio-tungstenite-wasm" 3943 + version = "0.4.0" 3944 + source = "registry+https://github.com/rust-lang/crates.io-index" 3945 + checksum = "e21a5c399399c3db9f08d8297ac12b500e86bca82e930253fdc62eaf9c0de6ae" 3946 + dependencies = [ 3947 + "futures-channel", 3948 + "futures-util", 3949 + "http", 3950 + "httparse", 3951 + "js-sys", 3952 + "rustls", 3953 + "thiserror 1.0.69", 3954 + "tokio", 3955 + "tokio-tungstenite", 3956 + "wasm-bindgen", 3957 + "web-sys", 3718 3958 ] 3719 3959 3720 3960 [[package]] 3721 3961 name = "tokio-util" 3722 - version = "0.7.16" 3962 + version = "0.7.17" 3723 3963 source = "registry+https://github.com/rust-lang/crates.io-index" 3724 - checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" 3964 + checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" 3725 3965 dependencies = [ 3726 3966 "bytes", 3727 3967 "futures-core", 3728 3968 "futures-sink", 3969 + "futures-util", 3729 3970 "pin-project-lite", 3730 3971 "tokio", 3972 + ] 3973 + 3974 + [[package]] 3975 + name = "tower" 3976 + version = "0.4.13" 3977 + source = "registry+https://github.com/rust-lang/crates.io-index" 3978 + checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" 3979 + dependencies = [ 3980 + "tower-layer", 3981 + "tower-service", 3982 + "tracing", 3731 3983 ] 3732 3984 3733 3985 [[package]] ··· 3743 3995 "tokio", 3744 3996 "tower-layer", 3745 3997 "tower-service", 3998 + "tracing", 3999 + ] 4000 + 4001 + [[package]] 4002 + name = "tower-http" 4003 + version = "0.5.2" 4004 + source = "registry+https://github.com/rust-lang/crates.io-index" 4005 + checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" 4006 + dependencies = [ 4007 + "async-compression", 4008 + "bitflags", 4009 + "bytes", 4010 + "futures-core", 4011 + "futures-util", 4012 + "http", 4013 + "http-body", 4014 + "http-body-util", 4015 + "http-range-header", 4016 + "httpdate", 4017 + "mime", 4018 + "mime_guess", 4019 + "percent-encoding", 4020 + "pin-project-lite", 4021 + "tokio", 4022 + "tokio-util", 4023 + "tower-layer", 4024 + "tower-service", 4025 + "tracing", 3746 4026 ] 3747 4027 3748 4028 [[package]] ··· 3758 4038 "http-body", 3759 4039 "iri-string", 3760 4040 "pin-project-lite", 3761 - "tower", 4041 + "tower 0.5.2", 3762 4042 "tower-layer", 3763 4043 "tower-service", 3764 4044 ] ··· 3781 4061 source = "registry+https://github.com/rust-lang/crates.io-index" 3782 4062 checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 3783 4063 dependencies = [ 4064 + "log", 3784 4065 "pin-project-lite", 3785 4066 "tracing-attributes", 3786 4067 "tracing-core", ··· 3794 4075 dependencies = [ 3795 4076 "proc-macro2", 3796 4077 "quote", 3797 - "syn 2.0.108", 4078 + "syn 2.0.110", 3798 4079 ] 3799 4080 3800 4081 [[package]] ··· 3804 4085 checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 3805 4086 dependencies = [ 3806 4087 "once_cell", 4088 + "valuable", 4089 + ] 4090 + 4091 + [[package]] 4092 + name = "tracing-log" 4093 + version = "0.2.0" 4094 + source = "registry+https://github.com/rust-lang/crates.io-index" 4095 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 4096 + dependencies = [ 4097 + "log", 4098 + "once_cell", 4099 + "tracing-core", 4100 + ] 4101 + 4102 + [[package]] 4103 + name = "tracing-subscriber" 4104 + version = "0.3.20" 4105 + source = "registry+https://github.com/rust-lang/crates.io-index" 4106 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 4107 + dependencies = [ 4108 + "matchers", 4109 + "nu-ansi-term", 4110 + "once_cell", 4111 + "regex-automata", 4112 + "sharded-slab", 4113 + "smallvec", 4114 + "thread_local", 4115 + "tracing", 4116 + "tracing-core", 4117 + "tracing-log", 3807 4118 ] 3808 4119 3809 4120 [[package]] ··· 3814 4125 dependencies = [ 3815 4126 "proc-macro2", 3816 4127 "quote", 3817 - "syn 2.0.108", 4128 + "syn 2.0.110", 3818 4129 ] 3819 4130 3820 4131 [[package]] ··· 3830 4141 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 3831 4142 3832 4143 [[package]] 4144 + name = "tungstenite" 4145 + version = "0.24.0" 4146 + source = "registry+https://github.com/rust-lang/crates.io-index" 4147 + checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" 4148 + dependencies = [ 4149 + "byteorder", 4150 + "bytes", 4151 + "data-encoding", 4152 + "http", 4153 + "httparse", 4154 + "log", 4155 + "rand 0.8.5", 4156 + "rustls", 4157 + "rustls-pki-types", 4158 + "sha1", 4159 + "thiserror 1.0.69", 4160 + "utf-8", 4161 + ] 4162 + 4163 + [[package]] 3833 4164 name = "twoway" 3834 4165 version = "0.1.8" 3835 4166 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3881 4212 checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 3882 4213 3883 4214 [[package]] 4215 + name = "unicode-xid" 4216 + version = "0.2.6" 4217 + source = "registry+https://github.com/rust-lang/crates.io-index" 4218 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 4219 + 4220 + [[package]] 3884 4221 name = "unsigned-varint" 3885 4222 version = "0.8.0" 3886 4223 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3929 4266 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 3930 4267 3931 4268 [[package]] 3932 - name = "vcpkg" 3933 - version = "0.2.15" 4269 + name = "valuable" 4270 + version = "0.1.1" 3934 4271 source = "registry+https://github.com/rust-lang/crates.io-index" 3935 - checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 4272 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 3936 4273 3937 4274 [[package]] 3938 4275 name = "version_check" ··· 4019 4356 "bumpalo", 4020 4357 "proc-macro2", 4021 4358 "quote", 4022 - "syn 2.0.108", 4359 + "syn 2.0.110", 4023 4360 "wasm-bindgen-shared", 4024 4361 ] 4025 4362 ··· 4118 4455 ] 4119 4456 4120 4457 [[package]] 4458 + name = "windows" 4459 + version = "0.61.3" 4460 + source = "registry+https://github.com/rust-lang/crates.io-index" 4461 + checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" 4462 + dependencies = [ 4463 + "windows-collections", 4464 + "windows-core 0.61.2", 4465 + "windows-future", 4466 + "windows-link 0.1.3", 4467 + "windows-numerics", 4468 + ] 4469 + 4470 + [[package]] 4471 + name = "windows-collections" 4472 + version = "0.2.0" 4473 + source = "registry+https://github.com/rust-lang/crates.io-index" 4474 + checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" 4475 + dependencies = [ 4476 + "windows-core 0.61.2", 4477 + ] 4478 + 4479 + [[package]] 4480 + name = "windows-core" 4481 + version = "0.61.2" 4482 + source = "registry+https://github.com/rust-lang/crates.io-index" 4483 + checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" 4484 + dependencies = [ 4485 + "windows-implement", 4486 + "windows-interface", 4487 + "windows-link 0.1.3", 4488 + "windows-result 0.3.4", 4489 + "windows-strings 0.4.2", 4490 + ] 4491 + 4492 + [[package]] 4121 4493 name = "windows-core" 4122 4494 version = "0.62.2" 4123 4495 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4131 4503 ] 4132 4504 4133 4505 [[package]] 4506 + name = "windows-future" 4507 + version = "0.2.1" 4508 + source = "registry+https://github.com/rust-lang/crates.io-index" 4509 + checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" 4510 + dependencies = [ 4511 + "windows-core 0.61.2", 4512 + "windows-link 0.1.3", 4513 + "windows-threading", 4514 + ] 4515 + 4516 + [[package]] 4134 4517 name = "windows-implement" 4135 4518 version = "0.60.2" 4136 4519 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4138 4521 dependencies = [ 4139 4522 "proc-macro2", 4140 4523 "quote", 4141 - "syn 2.0.108", 4524 + "syn 2.0.110", 4142 4525 ] 4143 4526 4144 4527 [[package]] ··· 4149 4532 dependencies = [ 4150 4533 "proc-macro2", 4151 4534 "quote", 4152 - "syn 2.0.108", 4535 + "syn 2.0.110", 4153 4536 ] 4154 4537 4155 4538 [[package]] ··· 4163 4546 version = "0.2.1" 4164 4547 source = "registry+https://github.com/rust-lang/crates.io-index" 4165 4548 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 4549 + 4550 + [[package]] 4551 + name = "windows-numerics" 4552 + version = "0.2.0" 4553 + source = "registry+https://github.com/rust-lang/crates.io-index" 4554 + checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" 4555 + dependencies = [ 4556 + "windows-core 0.61.2", 4557 + "windows-link 0.1.3", 4558 + ] 4166 4559 4167 4560 [[package]] 4168 4561 name = "windows-registry" ··· 4320 4713 ] 4321 4714 4322 4715 [[package]] 4716 + name = "windows-threading" 4717 + version = "0.1.0" 4718 + source = "registry+https://github.com/rust-lang/crates.io-index" 4719 + checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" 4720 + dependencies = [ 4721 + "windows-link 0.1.3", 4722 + ] 4723 + 4724 + [[package]] 4323 4725 name = "windows_aarch64_gnullvm" 4324 4726 version = "0.42.2" 4325 4727 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4511 4913 4512 4914 [[package]] 4513 4915 name = "wisp-cli" 4514 - version = "0.1.0" 4916 + version = "0.2.0" 4515 4917 dependencies = [ 4918 + "axum", 4516 4919 "base64 0.22.1", 4517 4920 "bytes", 4921 + "chrono", 4518 4922 "clap", 4519 4923 "flate2", 4520 4924 "futures", ··· 4527 4931 "jacquard-oauth", 4528 4932 "miette", 4529 4933 "mime_guess", 4934 + "multibase", 4935 + "multihash", 4936 + "n0-future", 4530 4937 "reqwest", 4531 4938 "rustversion", 4532 4939 "serde", 4533 4940 "serde_json", 4941 + "sha2", 4534 4942 "shellexpand", 4535 4943 "tokio", 4944 + "tower 0.4.13", 4945 + "tower-http 0.5.2", 4946 + "url", 4536 4947 "walkdir", 4537 4948 ] 4538 4949 ··· 4584 4995 dependencies = [ 4585 4996 "proc-macro2", 4586 4997 "quote", 4587 - "syn 2.0.108", 4998 + "syn 2.0.110", 4588 4999 "synstructure", 4589 5000 ] 4590 5001 ··· 4605 5016 dependencies = [ 4606 5017 "proc-macro2", 4607 5018 "quote", 4608 - "syn 2.0.108", 5019 + "syn 2.0.110", 4609 5020 ] 4610 5021 4611 5022 [[package]] ··· 4625 5036 dependencies = [ 4626 5037 "proc-macro2", 4627 5038 "quote", 4628 - "syn 2.0.108", 5039 + "syn 2.0.110", 4629 5040 "synstructure", 4630 5041 ] 4631 5042 ··· 4668 5079 dependencies = [ 4669 5080 "proc-macro2", 4670 5081 "quote", 4671 - "syn 2.0.108", 5082 + "syn 2.0.110", 4672 5083 ]
+19 -9
cli/Cargo.toml
··· 1 1 [package] 2 2 name = "wisp-cli" 3 - version = "0.1.0" 3 + version = "0.2.0" 4 4 edition = "2024" 5 5 6 6 [features] ··· 8 8 place_wisp = [] 9 9 10 10 [dependencies] 11 - jacquard = { path = "jacquard/crates/jacquard", features = ["loopback"] } 12 - jacquard-oauth = { path = "jacquard/crates/jacquard-oauth" } 13 - jacquard-api = { path = "jacquard/crates/jacquard-api" } 14 - jacquard-common = { path = "jacquard/crates/jacquard-common" } 15 - jacquard-identity = { path = "jacquard/crates/jacquard-identity", features = ["dns"] } 16 - jacquard-derive = { path = "jacquard/crates/jacquard-derive" } 17 - jacquard-lexicon = { path = "jacquard/crates/jacquard-lexicon" } 11 + jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] } 12 + jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 13 + jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 14 + jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["websocket"] } 15 + jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] } 16 + jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 17 + jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 18 18 clap = { version = "4.5.51", features = ["derive"] } 19 19 tokio = { version = "1.48", features = ["full"] } 20 20 miette = { version = "7.6.0", features = ["fancy"] } 21 21 serde_json = "1.0.145" 22 22 serde = { version = "1.0", features = ["derive"] } 23 23 shellexpand = "3.1.1" 24 - reqwest = "0.12" 24 + #reqwest = "0.12" 25 + reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } 25 26 rustversion = "1.0" 26 27 flate2 = "1.0" 27 28 base64 = "0.22" ··· 29 30 mime_guess = "2.0" 30 31 bytes = "1.10" 31 32 futures = "0.3.31" 33 + multihash = "0.19.3" 34 + multibase = "0.9" 35 + sha2 = "0.10" 36 + axum = "0.7" 37 + tower-http = { version = "0.5", features = ["fs", "compression-gzip"] } 38 + tower = "0.4" 39 + n0-future = "0.1" 40 + chrono = "0.4" 41 + url = "2.5"
+271
cli/README.md
··· 1 + # Wisp CLI 2 + 3 + A command-line tool for deploying static sites to your AT Protocol repo to be served on [wisp.place](https://wisp.place), an AT indexer to serve such sites. 4 + 5 + ## Why? 6 + 7 + The PDS serves as a way to verfiably, cryptographically prove that you own your site. That it was you (or at least someone who controls your account) who uploaded it. It is also a manifest of each file in the site to ensure file integrity. Keeping hosting seperate ensures that you could move your site across other servers or even serverless solutions to ensure speedy delievery while keeping it backed by an absolute source of truth being the manifest record and the blobs of each file in your repo. 8 + 9 + ## Features 10 + 11 + - Deploy static sites directly to your AT Protocol repo 12 + - Supports both OAuth and app password authentication 13 + - Preserves directory structure and file integrity 14 + 15 + ## Soon 16 + 17 + -- Host sites 18 + -- Manage and delete sites 19 + -- Metrics and logs for self hosting. 20 + 21 + ## Installation 22 + 23 + ### From Source 24 + 25 + ```bash 26 + cargo build --release 27 + ``` 28 + 29 + Check out the build scripts for cross complation using nix-shell. 30 + 31 + The binary will be available at `target/release/wisp-cli`. 32 + 33 + ## Usage 34 + 35 + ### Basic Deployment 36 + 37 + Deploy the current directory: 38 + 39 + ```bash 40 + wisp-cli nekomimi.ppet --path . --site my-site 41 + ``` 42 + 43 + Deploy a specific directory: 44 + 45 + ```bash 46 + wisp-cli alice.bsky.social --path ./dist/ --site my-site 47 + ``` 48 + 49 + ### Authentication Methods 50 + 51 + #### OAuth (Recommended) 52 + 53 + By default, the CLI uses OAuth authentication with a local loopback server: 54 + 55 + ```bash 56 + wisp-cli alice.bsky.social --path ./my-site --site my-site 57 + ``` 58 + 59 + This will: 60 + 1. Open your browser for authentication 61 + 2. Save the session to a file (default: `/tmp/wisp-oauth-session.json`) 62 + 3. Reuse the session for future deployments 63 + 64 + Specify a custom session file location: 65 + 66 + ```bash 67 + wisp-cli alice.bsky.social --path ./my-site --site my-site --store ~/.wisp-session.json 68 + ``` 69 + 70 + #### App Password 71 + 72 + For headless environments or CI/CD, use an app password: 73 + 74 + ```bash 75 + wisp-cli alice.bsky.social --path ./my-site --site my-site --password YOUR_APP_PASSWORD 76 + ``` 77 + 78 + **Note:** When using `--password`, the `--store` option is ignored. 79 + 80 + ## Command-Line Options 81 + 82 + ``` 83 + wisp-cli [OPTIONS] <INPUT> 84 + 85 + Arguments: 86 + <INPUT> Handle (e.g., alice.bsky.social), DID, or PDS URL 87 + 88 + Options: 89 + -p, --path <PATH> Path to the directory containing your static site [default: .] 90 + -s, --site <SITE> Site name (defaults to directory name) 91 + --store <STORE> Path to auth store file (only used with OAuth) [default: /tmp/wisp-oauth-session.json] 92 + --password <PASSWORD> App Password for authentication (alternative to OAuth) 93 + -h, --help Print help 94 + -V, --version Print version 95 + ``` 96 + 97 + ## How It Works 98 + 99 + 1. **Authentication**: Authenticates using OAuth or app password 100 + 2. **File Processing**: 101 + - Recursively walks the directory tree 102 + - Skips hidden files (starting with `.`) 103 + - Detects MIME types automatically 104 + - Compresses files with gzip 105 + - Base64 encodes compressed content 106 + 3. **Upload**: 107 + - Uploads files as blobs to your PDS 108 + - Processes up to 5 files concurrently 109 + - Creates a `place.wisp.fs` record with the site manifest 110 + 4. **Deployment**: Site is immediately available at `https://sites.wisp.place/{did}/{site-name}` 111 + 112 + ## File Processing 113 + 114 + All files are automatically: 115 + 116 + - **Compressed** with gzip (level 9) 117 + - **Base64 encoded** to bypass PDS content sniffing 118 + - **Uploaded** as `application/octet-stream` blobs 119 + - **Stored** with original MIME type metadata 120 + 121 + The hosting service automatically decompresses non HTML/CSS/JS files when serving them. 122 + 123 + ## Limitations 124 + 125 + - **Max file size**: 100MB per file (after compression) (this is a PDS limit, but not enforced by the CLI in case yours is higher) 126 + - **Max file count**: 2000 files 127 + - **Site name** must follow AT Protocol rkey format rules (alphanumeric, hyphens, underscores) 128 + 129 + ## Deploy with CI/CD 130 + 131 + ### GitHub Actions 132 + 133 + ```yaml 134 + name: Deploy to Wisp 135 + on: 136 + push: 137 + branches: [main] 138 + 139 + jobs: 140 + deploy: 141 + runs-on: ubuntu-latest 142 + steps: 143 + - uses: actions/checkout@v3 144 + 145 + - name: Setup Node 146 + uses: actions/setup-node@v3 147 + with: 148 + node-version: '25' 149 + 150 + - name: Install dependencies 151 + run: npm install 152 + 153 + - name: Build site 154 + run: npm run build 155 + 156 + - name: Download Wisp CLI 157 + run: | 158 + curl -L https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 159 + chmod +x wisp-cli 160 + 161 + - name: Deploy to Wisp 162 + env: 163 + WISP_APP_PASSWORD: ${{ secrets.WISP_APP_PASSWORD }} 164 + run: | 165 + ./wisp-cli alice.bsky.social \ 166 + --path ./dist \ 167 + --site my-site \ 168 + --password "$WISP_APP_PASSWORD" 169 + ``` 170 + 171 + ### Tangled.org 172 + 173 + ```yaml 174 + when: 175 + - event: ['push'] 176 + branch: ['main'] 177 + - event: ['manual'] 178 + 179 + engine: 'nixery' 180 + 181 + clone: 182 + skip: false 183 + depth: 1 184 + submodules: false 185 + 186 + dependencies: 187 + nixpkgs: 188 + - nodejs 189 + - coreutils 190 + - curl 191 + github:NixOS/nixpkgs/nixpkgs-unstable: 192 + - bun 193 + 194 + environment: 195 + SITE_PATH: 'dist' 196 + SITE_NAME: 'my-site' 197 + WISP_HANDLE: 'your-handle.bsky.social' 198 + 199 + steps: 200 + - name: build site 201 + command: | 202 + export PATH="$HOME/.nix-profile/bin:$PATH" 203 + 204 + # regenerate lockfile 205 + rm package-lock.json bun.lock 206 + bun install @rolldown/binding-linux-arm64-gnu --save-optional 207 + bun install 208 + 209 + # build with vite 210 + bun node_modules/.bin/vite build 211 + 212 + - name: deploy to wisp 213 + command: | 214 + # Download Wisp CLI 215 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 216 + chmod +x wisp-cli 217 + 218 + # Deploy to Wisp 219 + ./wisp-cli \ 220 + "$WISP_HANDLE" \ 221 + --path "$SITE_PATH" \ 222 + --site "$SITE_NAME" \ 223 + --password "$WISP_APP_PASSWORD" 224 + ``` 225 + 226 + ### Generic Shell Script 227 + 228 + ```bash 229 + # Use app password from environment variable 230 + wisp-cli alice.bsky.social --path ./dist --site my-site --password "$WISP_APP_PASSWORD" 231 + ``` 232 + 233 + ## Output 234 + 235 + Upon successful deployment, you'll see: 236 + 237 + ``` 238 + Deployed site 'my-site': at://did:plc:abc123xyz/place.wisp.fs/my-site 239 + Available at: https://sites.wisp.place/did:plc:abc123xyz/my-site 240 + ``` 241 + 242 + ### Dependencies 243 + 244 + - **jacquard**: AT Protocol client library 245 + - **clap**: Command-line argument parsing 246 + - **tokio**: Async runtime 247 + - **flate2**: Gzip compression 248 + - **base64**: Base64 encoding 249 + - **walkdir**: Directory traversal 250 + - **mime_guess**: MIME type detection 251 + 252 + ## License 253 + 254 + MIT License 255 + 256 + ## Contributing 257 + 258 + Just don't give me entirely claude slop especailly not in the PR description itself. You should be responsible for code you submit and aware of what it even is you're submitting. 259 + 260 + ## Links 261 + 262 + - **Website**: https://wisp.place 263 + - **Main Repository**: https://tangled.org/@nekomimi.pet/wisp.place-monorepo 264 + - **AT Protocol**: https://atproto.com 265 + - **Jacquard Library**: https://tangled.org/@nonbinary.computer/jacquard 266 + 267 + ## Support 268 + 269 + For issues and questions: 270 + - Check the main wisp.place documentation 271 + - Open an issue in the main repository
+23
cli/build-linux.sh
··· 1 + #!/usr/bin/env bash 2 + # Build Linux binaries (statically linked) 3 + set -e 4 + mkdir -p binaries 5 + 6 + # Build Linux binaries 7 + echo "Building Linux binaries..." 8 + 9 + echo "Building Linux ARM64 (static)..." 10 + nix-shell -p rustup --run ' 11 + rustup target add aarch64-unknown-linux-musl 12 + RUSTFLAGS="-C target-feature=+crt-static" cargo zigbuild --release --target aarch64-unknown-linux-musl 13 + ' 14 + cp target/aarch64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-aarch64-linux 15 + 16 + echo "Building Linux x86_64 (static)..." 17 + nix-shell -p rustup --run ' 18 + rustup target add x86_64-unknown-linux-musl 19 + RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target x86_64-unknown-linux-musl 20 + ' 21 + cp target/x86_64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-x86_64-linux 22 + 23 + echo "Done! Binaries in ./binaries/"
+15
cli/build-macos.sh
··· 1 + #!/bin/bash 2 + # Build Linux and macOS binaries 3 + 4 + set -e 5 + 6 + mkdir -p binaries 7 + rm -rf target 8 + 9 + # Build macOS binaries natively 10 + echo "Building macOS binaries..." 11 + rustup target add aarch64-apple-darwin 12 + 13 + echo "Building macOS arm64 binary." 14 + RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target aarch64-apple-darwin 15 + cp target/aarch64-apple-darwin/release/wisp-cli binaries/wisp-cli-macos-arm64
+85
cli/src/blob_map.rs
··· 1 + use jacquard_common::types::blob::BlobRef; 2 + use jacquard_common::IntoStatic; 3 + use std::collections::HashMap; 4 + 5 + use crate::place_wisp::fs::{Directory, EntryNode}; 6 + 7 + /// Extract blob information from a directory tree 8 + /// Returns a map of file paths to their blob refs and CIDs 9 + /// 10 + /// This mirrors the TypeScript implementation in src/lib/wisp-utils.ts lines 275-302 11 + pub fn extract_blob_map( 12 + directory: &Directory, 13 + ) -> HashMap<String, (BlobRef<'static>, String)> { 14 + extract_blob_map_recursive(directory, String::new()) 15 + } 16 + 17 + fn extract_blob_map_recursive( 18 + directory: &Directory, 19 + current_path: String, 20 + ) -> HashMap<String, (BlobRef<'static>, String)> { 21 + let mut blob_map = HashMap::new(); 22 + 23 + for entry in &directory.entries { 24 + let full_path = if current_path.is_empty() { 25 + entry.name.to_string() 26 + } else { 27 + format!("{}/{}", current_path, entry.name) 28 + }; 29 + 30 + match &entry.node { 31 + EntryNode::File(file_node) => { 32 + // Extract CID from blob ref 33 + // BlobRef is an enum with Blob variant, which has a ref field (CidLink) 34 + let blob_ref = &file_node.blob; 35 + let cid_string = blob_ref.blob().r#ref.to_string(); 36 + 37 + // Store with full path (mirrors TypeScript implementation) 38 + blob_map.insert( 39 + full_path, 40 + (blob_ref.clone().into_static(), cid_string) 41 + ); 42 + } 43 + EntryNode::Directory(subdir) => { 44 + let sub_map = extract_blob_map_recursive(subdir, full_path); 45 + blob_map.extend(sub_map); 46 + } 47 + EntryNode::Unknown(_) => { 48 + // Skip unknown node types 49 + } 50 + } 51 + } 52 + 53 + blob_map 54 + } 55 + 56 + /// Normalize file path by removing base folder prefix 57 + /// Example: "cobblemon/index.html" -> "index.html" 58 + /// 59 + /// Note: This function is kept for reference but is no longer used in production code. 60 + /// The TypeScript server has a similar normalization (src/routes/wisp.ts line 291) to handle 61 + /// uploads that include a base folder prefix, but our CLI doesn't need this since we 62 + /// track full paths consistently. 63 + #[allow(dead_code)] 64 + pub fn normalize_path(path: &str) -> String { 65 + // Remove base folder prefix (everything before first /) 66 + if let Some(idx) = path.find('/') { 67 + path[idx + 1..].to_string() 68 + } else { 69 + path.to_string() 70 + } 71 + } 72 + 73 + #[cfg(test)] 74 + mod tests { 75 + use super::*; 76 + 77 + #[test] 78 + fn test_normalize_path() { 79 + assert_eq!(normalize_path("index.html"), "index.html"); 80 + assert_eq!(normalize_path("cobblemon/index.html"), "index.html"); 81 + assert_eq!(normalize_path("folder/subfolder/file.txt"), "subfolder/file.txt"); 82 + assert_eq!(normalize_path("a/b/c/d.txt"), "b/c/d.txt"); 83 + } 84 + } 85 +
+66
cli/src/cid.rs
··· 1 + use jacquard_common::types::cid::IpldCid; 2 + use sha2::{Digest, Sha256}; 3 + 4 + /// Compute CID (Content Identifier) for blob content 5 + /// Uses the same algorithm as AT Protocol: CIDv1 with raw codec (0x55) and SHA-256 6 + /// 7 + /// CRITICAL: This must be called on BASE64-ENCODED GZIPPED content, not just gzipped content 8 + /// 9 + /// Based on @atproto/common/src/ipld.ts sha256RawToCid implementation 10 + pub fn compute_cid(content: &[u8]) -> String { 11 + // Use node crypto to compute sha256 hash (same as AT Protocol) 12 + let hash = Sha256::digest(content); 13 + 14 + // Create multihash (code 0x12 = sha2-256) 15 + let multihash = multihash::Multihash::wrap(0x12, &hash) 16 + .expect("SHA-256 hash should always fit in multihash"); 17 + 18 + // Create CIDv1 with raw codec (0x55) 19 + let cid = IpldCid::new_v1(0x55, multihash); 20 + 21 + // Convert to base32 string representation 22 + cid.to_string_of_base(multibase::Base::Base32Lower) 23 + .unwrap_or_else(|_| cid.to_string()) 24 + } 25 + 26 + #[cfg(test)] 27 + mod tests { 28 + use super::*; 29 + use base64::Engine; 30 + 31 + #[test] 32 + fn test_compute_cid() { 33 + // Test with a simple string: "hello" 34 + let content = b"hello"; 35 + let cid = compute_cid(content); 36 + 37 + // CID should start with 'baf' for raw codec base32 38 + assert!(cid.starts_with("baf")); 39 + } 40 + 41 + #[test] 42 + fn test_compute_cid_base64_encoded() { 43 + // Simulate the actual use case: gzipped then base64 encoded 44 + use flate2::write::GzEncoder; 45 + use flate2::Compression; 46 + use std::io::Write; 47 + 48 + let original = b"hello world"; 49 + 50 + // Gzip compress 51 + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 52 + encoder.write_all(original).unwrap(); 53 + let gzipped = encoder.finish().unwrap(); 54 + 55 + // Base64 encode the gzipped data 56 + let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 57 + 58 + // Compute CID on the base64 bytes 59 + let cid = compute_cid(&base64_bytes); 60 + 61 + // Should be a valid CID 62 + assert!(cid.starts_with("baf")); 63 + assert!(cid.len() > 10); 64 + } 65 + } 66 +
+71
cli/src/download.rs
··· 1 + use base64::Engine; 2 + use bytes::Bytes; 3 + use flate2::read::GzDecoder; 4 + use jacquard_common::types::blob::BlobRef; 5 + use miette::IntoDiagnostic; 6 + use std::io::Read; 7 + use url::Url; 8 + 9 + /// Download a blob from the PDS 10 + pub async fn download_blob(pds_url: &Url, blob_ref: &BlobRef<'_>, did: &str) -> miette::Result<Bytes> { 11 + // Extract CID from blob ref 12 + let cid = blob_ref.blob().r#ref.to_string(); 13 + 14 + // Construct blob download URL 15 + // The correct endpoint is: /xrpc/com.atproto.sync.getBlob?did={did}&cid={cid} 16 + let blob_url = pds_url 17 + .join(&format!("/xrpc/com.atproto.sync.getBlob?did={}&cid={}", did, cid)) 18 + .into_diagnostic()?; 19 + 20 + let client = reqwest::Client::new(); 21 + let response = client 22 + .get(blob_url) 23 + .send() 24 + .await 25 + .into_diagnostic()?; 26 + 27 + if !response.status().is_success() { 28 + return Err(miette::miette!( 29 + "Failed to download blob: {}", 30 + response.status() 31 + )); 32 + } 33 + 34 + let bytes = response.bytes().await.into_diagnostic()?; 35 + Ok(bytes) 36 + } 37 + 38 + /// Decompress and decode a blob (base64 + gzip) 39 + pub fn decompress_blob(data: &[u8], is_base64: bool, is_gzipped: bool) -> miette::Result<Vec<u8>> { 40 + let mut current_data = data.to_vec(); 41 + 42 + // First, decode base64 if needed 43 + if is_base64 { 44 + current_data = base64::prelude::BASE64_STANDARD 45 + .decode(&current_data) 46 + .into_diagnostic()?; 47 + } 48 + 49 + // Then, decompress gzip if needed 50 + if is_gzipped { 51 + let mut decoder = GzDecoder::new(&current_data[..]); 52 + let mut decompressed = Vec::new(); 53 + decoder.read_to_end(&mut decompressed).into_diagnostic()?; 54 + current_data = decompressed; 55 + } 56 + 57 + Ok(current_data) 58 + } 59 + 60 + /// Download and decompress a blob 61 + pub async fn download_and_decompress_blob( 62 + pds_url: &Url, 63 + blob_ref: &BlobRef<'_>, 64 + did: &str, 65 + is_base64: bool, 66 + is_gzipped: bool, 67 + ) -> miette::Result<Vec<u8>> { 68 + let data = download_blob(pds_url, blob_ref, did).await?; 69 + decompress_blob(&data, is_base64, is_gzipped) 70 + } 71 +
+243 -56
cli/src/main.rs
··· 1 1 mod builder_types; 2 2 mod place_wisp; 3 + mod cid; 4 + mod blob_map; 5 + mod metadata; 6 + mod download; 7 + mod pull; 8 + mod serve; 3 9 4 - use clap::Parser; 10 + use clap::{Parser, Subcommand}; 5 11 use jacquard::CowStr; 6 - use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession}; 12 + use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession, AgentSession}; 7 13 use jacquard::oauth::client::OAuthClient; 8 14 use jacquard::oauth::loopback::LoopbackConfig; 9 15 use jacquard::prelude::IdentityResolver; ··· 11 17 use jacquard_common::types::blob::MimeType; 12 18 use miette::IntoDiagnostic; 13 19 use std::path::{Path, PathBuf}; 20 + use std::collections::HashMap; 14 21 use flate2::Compression; 15 22 use flate2::write::GzEncoder; 16 23 use std::io::Write; ··· 20 27 use place_wisp::fs::*; 21 28 22 29 #[derive(Parser, Debug)] 23 - #[command(author, version, about = "Deploy a static site to wisp.place")] 30 + #[command(author, version, about = "wisp.place CLI tool")] 24 31 struct Args { 32 + #[command(subcommand)] 33 + command: Option<Commands>, 34 + 35 + // Deploy arguments (when no subcommand is specified) 25 36 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 26 - input: CowStr<'static>, 37 + #[arg(global = true, conflicts_with = "command")] 38 + input: Option<CowStr<'static>>, 27 39 28 40 /// Path to the directory containing your static site 29 - #[arg(short, long, default_value = ".")] 30 - path: PathBuf, 41 + #[arg(short, long, global = true, conflicts_with = "command")] 42 + path: Option<PathBuf>, 31 43 32 44 /// Site name (defaults to directory name) 33 - #[arg(short, long)] 45 + #[arg(short, long, global = true, conflicts_with = "command")] 34 46 site: Option<String>, 35 47 36 - /// Path to auth store file (will be created if missing, only used with OAuth) 37 - #[arg(long, default_value = "/tmp/wisp-oauth-session.json")] 38 - store: String, 48 + /// Path to auth store file 49 + #[arg(long, global = true, conflicts_with = "command")] 50 + store: Option<String>, 39 51 40 - /// App Password for authentication (alternative to OAuth) 41 - #[arg(long)] 52 + /// App Password for authentication 53 + #[arg(long, global = true, conflicts_with = "command")] 42 54 password: Option<CowStr<'static>>, 43 55 } 44 56 57 + #[derive(Subcommand, Debug)] 58 + enum Commands { 59 + /// Deploy a static site to wisp.place (default command) 60 + Deploy { 61 + /// Handle (e.g., alice.bsky.social), DID, or PDS URL 62 + input: CowStr<'static>, 63 + 64 + /// Path to the directory containing your static site 65 + #[arg(short, long, default_value = ".")] 66 + path: PathBuf, 67 + 68 + /// Site name (defaults to directory name) 69 + #[arg(short, long)] 70 + site: Option<String>, 71 + 72 + /// Path to auth store file (will be created if missing, only used with OAuth) 73 + #[arg(long, default_value = "/tmp/wisp-oauth-session.json")] 74 + store: String, 75 + 76 + /// App Password for authentication (alternative to OAuth) 77 + #[arg(long)] 78 + password: Option<CowStr<'static>>, 79 + }, 80 + /// Pull a site from the PDS to a local directory 81 + Pull { 82 + /// Handle (e.g., alice.bsky.social) or DID 83 + input: CowStr<'static>, 84 + 85 + /// Site name (record key) 86 + #[arg(short, long)] 87 + site: String, 88 + 89 + /// Output directory for the downloaded site 90 + #[arg(short, long, default_value = ".")] 91 + output: PathBuf, 92 + }, 93 + /// Serve a site locally with real-time firehose updates 94 + Serve { 95 + /// Handle (e.g., alice.bsky.social) or DID 96 + input: CowStr<'static>, 97 + 98 + /// Site name (record key) 99 + #[arg(short, long)] 100 + site: String, 101 + 102 + /// Output directory for the site files 103 + #[arg(short, long, default_value = ".")] 104 + output: PathBuf, 105 + 106 + /// Port to serve on 107 + #[arg(short, long, default_value = "8080")] 108 + port: u16, 109 + }, 110 + } 111 + 45 112 #[tokio::main] 46 113 async fn main() -> miette::Result<()> { 47 114 let args = Args::parse(); 48 115 49 - // Dispatch to appropriate authentication method 50 - if let Some(password) = args.password { 51 - run_with_app_password(args.input, password, args.path, args.site).await 52 - } else { 53 - run_with_oauth(args.input, args.store, args.path, args.site).await 116 + match args.command { 117 + Some(Commands::Deploy { input, path, site, store, password }) => { 118 + // Dispatch to appropriate authentication method 119 + if let Some(password) = password { 120 + run_with_app_password(input, password, path, site).await 121 + } else { 122 + run_with_oauth(input, store, path, site).await 123 + } 124 + } 125 + Some(Commands::Pull { input, site, output }) => { 126 + pull::pull_site(input, CowStr::from(site), output).await 127 + } 128 + Some(Commands::Serve { input, site, output, port }) => { 129 + serve::serve_site(input, CowStr::from(site), output, port).await 130 + } 131 + None => { 132 + // Legacy mode: if input is provided, assume deploy command 133 + if let Some(input) = args.input { 134 + let path = args.path.unwrap_or_else(|| PathBuf::from(".")); 135 + let store = args.store.unwrap_or_else(|| "/tmp/wisp-oauth-session.json".to_string()); 136 + 137 + // Dispatch to appropriate authentication method 138 + if let Some(password) = args.password { 139 + run_with_app_password(input, password, path, args.site).await 140 + } else { 141 + run_with_oauth(input, store, path, args.site).await 142 + } 143 + } else { 144 + // No command and no input, show help 145 + use clap::CommandFactory; 146 + Args::command().print_help().into_diagnostic()?; 147 + Ok(()) 148 + } 149 + } 54 150 } 55 151 } 56 152 ··· 107 203 108 204 println!("Deploying site '{}'...", site_name); 109 205 110 - // Build directory tree 111 - let root_dir = build_directory(agent, &path).await?; 206 + // Try to fetch existing manifest for incremental updates 207 + let existing_blob_map: HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)> = { 208 + use jacquard_common::types::string::AtUri; 209 + 210 + // Get the DID for this session 211 + let session_info = agent.session_info().await; 212 + if let Some((did, _)) = session_info { 213 + // Construct the AT URI for the record 214 + let uri_string = format!("at://{}/place.wisp.fs/{}", did, site_name); 215 + if let Ok(uri) = AtUri::new(&uri_string) { 216 + match agent.get_record::<Fs>(&uri).await { 217 + Ok(response) => { 218 + match response.into_output() { 219 + Ok(record_output) => { 220 + let existing_manifest = record_output.value; 221 + let blob_map = blob_map::extract_blob_map(&existing_manifest.root); 222 + println!("Found existing manifest with {} files, checking for changes...", blob_map.len()); 223 + blob_map 224 + } 225 + Err(_) => { 226 + println!("No existing manifest found, uploading all files..."); 227 + HashMap::new() 228 + } 229 + } 230 + } 231 + Err(_) => { 232 + // Record doesn't exist yet - this is a new site 233 + println!("No existing manifest found, uploading all files..."); 234 + HashMap::new() 235 + } 236 + } 237 + } else { 238 + println!("No existing manifest found (invalid URI), uploading all files..."); 239 + HashMap::new() 240 + } 241 + } else { 242 + println!("No existing manifest found (could not get DID), uploading all files..."); 243 + HashMap::new() 244 + } 245 + }; 112 246 113 - // Count total files 114 - let file_count = count_files(&root_dir); 247 + // Build directory tree 248 + let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?; 249 + let uploaded_count = total_files - reused_count; 115 250 116 251 // Create the Fs record 117 252 let fs_record = Fs::new() 118 253 .site(CowStr::from(site_name.clone())) 119 254 .root(root_dir) 120 - .file_count(file_count as i64) 255 + .file_count(total_files as i64) 121 256 .created_at(Datetime::now()) 122 257 .build(); 123 258 ··· 132 267 .and_then(|s| s.split('/').next()) 133 268 .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?; 134 269 135 - println!("Deployed site '{}': {}", site_name, output.uri); 136 - println!("Available at: https://sites.wisp.place/{}/{}", did, site_name); 270 + println!("\nโœ“ Deployed site '{}': {}", site_name, output.uri); 271 + println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count); 272 + println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name); 137 273 138 274 Ok(()) 139 275 } 140 276 141 277 /// Recursively build a Directory from a filesystem path 278 + /// current_path is the path from the root of the site (e.g., "" for root, "config" for config dir) 142 279 fn build_directory<'a>( 143 280 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>, 144 281 dir_path: &'a Path, 145 - ) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>> 282 + existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 283 + current_path: String, 284 + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>> 146 285 { 147 286 Box::pin(async move { 148 287 // Collect all directory entries first ··· 170 309 let metadata = entry.metadata().into_diagnostic()?; 171 310 172 311 if metadata.is_file() { 173 - file_tasks.push((name_str, path)); 312 + // Construct full path for this file (for blob map lookup) 313 + let full_path = if current_path.is_empty() { 314 + name_str.clone() 315 + } else { 316 + format!("{}/{}", current_path, name_str) 317 + }; 318 + file_tasks.push((name_str, path, full_path)); 174 319 } else if metadata.is_dir() { 175 320 dir_tasks.push((name_str, path)); 176 321 } 177 322 } 178 323 179 324 // Process files concurrently with a limit of 5 180 - let file_entries: Vec<Entry> = stream::iter(file_tasks) 181 - .map(|(name, path)| async move { 182 - let file_node = process_file(agent, &path).await?; 183 - Ok::<_, miette::Report>(Entry::new() 325 + let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks) 326 + .map(|(name, path, full_path)| async move { 327 + let (file_node, reused) = process_file(agent, &path, &full_path, existing_blobs).await?; 328 + let entry = Entry::new() 184 329 .name(CowStr::from(name)) 185 330 .node(EntryNode::File(Box::new(file_node))) 186 - .build()) 331 + .build(); 332 + Ok::<_, miette::Report>((entry, reused)) 187 333 }) 188 334 .buffer_unordered(5) 189 335 .collect::<Vec<_>>() 190 336 .await 191 337 .into_iter() 192 338 .collect::<miette::Result<Vec<_>>>()?; 339 + 340 + let mut file_entries = Vec::new(); 341 + let mut reused_count = 0; 342 + let mut total_files = 0; 343 + 344 + for (entry, reused) in file_results { 345 + file_entries.push(entry); 346 + total_files += 1; 347 + if reused { 348 + reused_count += 1; 349 + } 350 + } 193 351 194 352 // Process directories recursively (sequentially to avoid too much nesting) 195 353 let mut dir_entries = Vec::new(); 196 354 for (name, path) in dir_tasks { 197 - let subdir = build_directory(agent, &path).await?; 355 + // Construct full path for subdirectory 356 + let subdir_path = if current_path.is_empty() { 357 + name.clone() 358 + } else { 359 + format!("{}/{}", current_path, name) 360 + }; 361 + let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs, subdir_path).await?; 198 362 dir_entries.push(Entry::new() 199 363 .name(CowStr::from(name)) 200 364 .node(EntryNode::Directory(Box::new(subdir))) 201 365 .build()); 366 + total_files += sub_total; 367 + reused_count += sub_reused; 202 368 } 203 369 204 370 // Combine file and directory entries 205 371 let mut entries = file_entries; 206 372 entries.extend(dir_entries); 207 373 208 - Ok(Directory::new() 374 + let directory = Directory::new() 209 375 .r#type(CowStr::from("directory")) 210 376 .entries(entries) 211 - .build()) 377 + .build(); 378 + 379 + Ok((directory, total_files, reused_count)) 212 380 }) 213 381 } 214 382 215 - /// Process a single file: gzip -> base64 -> upload blob 383 + /// Process a single file: gzip -> base64 -> upload blob (or reuse existing) 384 + /// Returns (File, reused: bool) 385 + /// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup 216 386 async fn process_file( 217 387 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 218 388 file_path: &Path, 219 - ) -> miette::Result<File<'static>> 389 + file_path_key: &str, 390 + existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 391 + ) -> miette::Result<(File<'static>, bool)> 220 392 { 221 393 // Read file 222 394 let file_data = std::fs::read(file_path).into_diagnostic()?; ··· 234 406 // Base64 encode the gzipped data 235 407 let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 236 408 237 - // Upload blob as octet-stream 409 + // Compute CID for this file (CRITICAL: on base64-encoded gzipped content) 410 + let file_cid = cid::compute_cid(&base64_bytes); 411 + 412 + // Check if we have an existing blob with the same CID 413 + let existing_blob = existing_blobs.get(file_path_key); 414 + 415 + if let Some((existing_blob_ref, existing_cid)) = existing_blob { 416 + if existing_cid == &file_cid { 417 + // CIDs match - reuse existing blob 418 + println!(" โœ“ Reusing blob for {} (CID: {})", file_path_key, file_cid); 419 + return Ok(( 420 + File::new() 421 + .r#type(CowStr::from("file")) 422 + .blob(existing_blob_ref.clone()) 423 + .encoding(CowStr::from("gzip")) 424 + .mime_type(CowStr::from(original_mime)) 425 + .base64(true) 426 + .build(), 427 + true 428 + )); 429 + } 430 + } 431 + 432 + // File is new or changed - upload it 433 + println!(" โ†‘ Uploading {} ({} bytes, CID: {})", file_path_key, base64_bytes.len(), file_cid); 238 434 let blob = agent.upload_blob( 239 435 base64_bytes, 240 436 MimeType::new_static("application/octet-stream"), 241 437 ).await?; 242 438 243 - Ok(File::new() 244 - .r#type(CowStr::from("file")) 245 - .blob(blob) 246 - .encoding(CowStr::from("gzip")) 247 - .mime_type(CowStr::from(original_mime)) 248 - .base64(true) 249 - .build()) 439 + Ok(( 440 + File::new() 441 + .r#type(CowStr::from("file")) 442 + .blob(blob) 443 + .encoding(CowStr::from("gzip")) 444 + .mime_type(CowStr::from(original_mime)) 445 + .base64(true) 446 + .build(), 447 + false 448 + )) 250 449 } 251 450 252 - /// Count total files in a directory tree 253 - fn count_files(dir: &Directory) -> usize { 254 - let mut count = 0; 255 - for entry in &dir.entries { 256 - match &entry.node { 257 - EntryNode::File(_) => count += 1, 258 - EntryNode::Directory(subdir) => count += count_files(subdir), 259 - _ => {} // Unknown variants 260 - } 261 - } 262 - count 263 - }
+46
cli/src/metadata.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use std::collections::HashMap; 3 + use std::path::Path; 4 + use miette::IntoDiagnostic; 5 + 6 + /// Metadata tracking file CIDs for incremental updates 7 + #[derive(Debug, Clone, Serialize, Deserialize)] 8 + pub struct SiteMetadata { 9 + /// Record CID from the PDS 10 + pub record_cid: String, 11 + /// Map of file paths to their blob CIDs 12 + pub file_cids: HashMap<String, String>, 13 + /// Timestamp when the site was last synced 14 + pub last_sync: i64, 15 + } 16 + 17 + impl SiteMetadata { 18 + pub fn new(record_cid: String, file_cids: HashMap<String, String>) -> Self { 19 + Self { 20 + record_cid, 21 + file_cids, 22 + last_sync: chrono::Utc::now().timestamp(), 23 + } 24 + } 25 + 26 + /// Load metadata from a directory 27 + pub fn load(dir: &Path) -> miette::Result<Option<Self>> { 28 + let metadata_path = dir.join(".wisp-metadata.json"); 29 + if !metadata_path.exists() { 30 + return Ok(None); 31 + } 32 + 33 + let contents = std::fs::read_to_string(&metadata_path).into_diagnostic()?; 34 + let metadata: SiteMetadata = serde_json::from_str(&contents).into_diagnostic()?; 35 + Ok(Some(metadata)) 36 + } 37 + 38 + /// Save metadata to a directory 39 + pub fn save(&self, dir: &Path) -> miette::Result<()> { 40 + let metadata_path = dir.join(".wisp-metadata.json"); 41 + let contents = serde_json::to_string_pretty(self).into_diagnostic()?; 42 + std::fs::write(&metadata_path, contents).into_diagnostic()?; 43 + Ok(()) 44 + } 45 + } 46 +
+305
cli/src/pull.rs
··· 1 + use crate::blob_map; 2 + use crate::download; 3 + use crate::metadata::SiteMetadata; 4 + use crate::place_wisp::fs::*; 5 + use jacquard::CowStr; 6 + use jacquard::prelude::IdentityResolver; 7 + use jacquard_common::types::string::Did; 8 + use jacquard_common::xrpc::XrpcExt; 9 + use jacquard_identity::PublicResolver; 10 + use miette::IntoDiagnostic; 11 + use std::collections::HashMap; 12 + use std::path::{Path, PathBuf}; 13 + use url::Url; 14 + 15 + /// Pull a site from the PDS to a local directory 16 + pub async fn pull_site( 17 + input: CowStr<'static>, 18 + rkey: CowStr<'static>, 19 + output_dir: PathBuf, 20 + ) -> miette::Result<()> { 21 + println!("Pulling site {} from {}...", rkey, input); 22 + 23 + // Resolve handle to DID if needed 24 + let resolver = PublicResolver::default(); 25 + let did = if input.starts_with("did:") { 26 + Did::new(&input).into_diagnostic()? 27 + } else { 28 + // It's a handle, resolve it 29 + let handle = jacquard_common::types::string::Handle::new(&input).into_diagnostic()?; 30 + resolver.resolve_handle(&handle).await.into_diagnostic()? 31 + }; 32 + 33 + // Resolve PDS endpoint for the DID 34 + let pds_url = resolver.pds_for_did(&did).await.into_diagnostic()?; 35 + println!("Resolved PDS: {}", pds_url); 36 + 37 + // Fetch the place.wisp.fs record 38 + 39 + println!("Fetching record from PDS..."); 40 + let client = reqwest::Client::new(); 41 + 42 + // Use com.atproto.repo.getRecord 43 + use jacquard::api::com_atproto::repo::get_record::GetRecord; 44 + use jacquard_common::types::string::Rkey as RkeyType; 45 + let rkey_parsed = RkeyType::new(&rkey).into_diagnostic()?; 46 + 47 + use jacquard_common::types::ident::AtIdentifier; 48 + use jacquard_common::types::string::RecordKey; 49 + let request = GetRecord::new() 50 + .repo(AtIdentifier::Did(did.clone())) 51 + .collection(CowStr::from("place.wisp.fs")) 52 + .rkey(RecordKey::from(rkey_parsed)) 53 + .build(); 54 + 55 + let response = client 56 + .xrpc(pds_url.clone()) 57 + .send(&request) 58 + .await 59 + .into_diagnostic()?; 60 + 61 + let record_output = response.into_output().into_diagnostic()?; 62 + let record_cid = record_output.cid.as_ref().map(|c| c.to_string()).unwrap_or_default(); 63 + 64 + // Parse the record value as Fs 65 + use jacquard_common::types::value::from_data; 66 + let fs_record: Fs = from_data(&record_output.value).into_diagnostic()?; 67 + 68 + let file_count = fs_record.file_count.map(|c| c.to_string()).unwrap_or_else(|| "?".to_string()); 69 + println!("Found site '{}' with {} files", fs_record.site, file_count); 70 + 71 + // Load existing metadata for incremental updates 72 + let existing_metadata = SiteMetadata::load(&output_dir)?; 73 + let existing_file_cids = existing_metadata 74 + .as_ref() 75 + .map(|m| m.file_cids.clone()) 76 + .unwrap_or_default(); 77 + 78 + // Extract blob map from the new manifest 79 + let new_blob_map = blob_map::extract_blob_map(&fs_record.root); 80 + let new_file_cids: HashMap<String, String> = new_blob_map 81 + .iter() 82 + .map(|(path, (_blob_ref, cid))| (path.clone(), cid.clone())) 83 + .collect(); 84 + 85 + // Clean up any leftover temp directories from previous failed attempts 86 + let parent = output_dir.parent().unwrap_or_else(|| std::path::Path::new(".")); 87 + let output_name = output_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("site")).to_string_lossy(); 88 + let temp_prefix = format!(".tmp-{}-", output_name); 89 + 90 + if let Ok(entries) = parent.read_dir() { 91 + for entry in entries.flatten() { 92 + let name = entry.file_name(); 93 + if name.to_string_lossy().starts_with(&temp_prefix) { 94 + let _ = std::fs::remove_dir_all(entry.path()); 95 + } 96 + } 97 + } 98 + 99 + // Check if we need to update (but only if output directory actually exists with files) 100 + if let Some(metadata) = &existing_metadata { 101 + if metadata.record_cid == record_cid { 102 + // Verify that the output directory actually exists and has content 103 + let has_content = output_dir.exists() && 104 + output_dir.read_dir() 105 + .map(|mut entries| entries.any(|e| { 106 + if let Ok(entry) = e { 107 + !entry.file_name().to_string_lossy().starts_with(".wisp-metadata") 108 + } else { 109 + false 110 + } 111 + })) 112 + .unwrap_or(false); 113 + 114 + if has_content { 115 + println!("Site is already up to date!"); 116 + return Ok(()); 117 + } 118 + } 119 + } 120 + 121 + // Create temporary directory for atomic update 122 + // Place temp dir in parent directory to avoid issues with non-existent output_dir 123 + let parent = output_dir.parent().unwrap_or_else(|| std::path::Path::new(".")); 124 + let temp_dir_name = format!( 125 + ".tmp-{}-{}", 126 + output_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("site")).to_string_lossy(), 127 + chrono::Utc::now().timestamp() 128 + ); 129 + let temp_dir = parent.join(temp_dir_name); 130 + std::fs::create_dir_all(&temp_dir).into_diagnostic()?; 131 + 132 + println!("Downloading files..."); 133 + let mut downloaded = 0; 134 + let mut reused = 0; 135 + 136 + // Download files recursively 137 + let download_result = download_directory( 138 + &fs_record.root, 139 + &temp_dir, 140 + &pds_url, 141 + did.as_str(), 142 + &new_blob_map, 143 + &existing_file_cids, 144 + &output_dir, 145 + String::new(), 146 + &mut downloaded, 147 + &mut reused, 148 + ) 149 + .await; 150 + 151 + // If download failed, clean up temp directory 152 + if let Err(e) = download_result { 153 + let _ = std::fs::remove_dir_all(&temp_dir); 154 + return Err(e); 155 + } 156 + 157 + println!( 158 + "Downloaded {} files, reused {} files", 159 + downloaded, reused 160 + ); 161 + 162 + // Save metadata 163 + let metadata = SiteMetadata::new(record_cid, new_file_cids); 164 + metadata.save(&temp_dir)?; 165 + 166 + // Move files from temp to output directory 167 + let output_abs = std::fs::canonicalize(&output_dir).unwrap_or_else(|_| output_dir.clone()); 168 + let current_dir = std::env::current_dir().into_diagnostic()?; 169 + 170 + // Special handling for pulling to current directory 171 + if output_abs == current_dir { 172 + // Move files from temp to current directory 173 + for entry in std::fs::read_dir(&temp_dir).into_diagnostic()? { 174 + let entry = entry.into_diagnostic()?; 175 + let dest = current_dir.join(entry.file_name()); 176 + 177 + // Remove existing file/dir if it exists 178 + if dest.exists() { 179 + if dest.is_dir() { 180 + std::fs::remove_dir_all(&dest).into_diagnostic()?; 181 + } else { 182 + std::fs::remove_file(&dest).into_diagnostic()?; 183 + } 184 + } 185 + 186 + // Move from temp to current dir 187 + std::fs::rename(entry.path(), dest).into_diagnostic()?; 188 + } 189 + 190 + // Clean up temp directory 191 + std::fs::remove_dir_all(&temp_dir).into_diagnostic()?; 192 + } else { 193 + // If output directory exists and has content, remove it first 194 + if output_dir.exists() { 195 + std::fs::remove_dir_all(&output_dir).into_diagnostic()?; 196 + } 197 + 198 + // Ensure parent directory exists 199 + if let Some(parent) = output_dir.parent() { 200 + if !parent.as_os_str().is_empty() && !parent.exists() { 201 + std::fs::create_dir_all(parent).into_diagnostic()?; 202 + } 203 + } 204 + 205 + // Rename temp to final location 206 + match std::fs::rename(&temp_dir, &output_dir) { 207 + Ok(_) => {}, 208 + Err(e) => { 209 + // Clean up temp directory on failure 210 + let _ = std::fs::remove_dir_all(&temp_dir); 211 + return Err(miette::miette!("Failed to move temp directory: {}", e)); 212 + } 213 + } 214 + } 215 + 216 + println!("โœ“ Site pulled successfully to {}", output_dir.display()); 217 + 218 + Ok(()) 219 + } 220 + 221 + /// Recursively download a directory 222 + fn download_directory<'a>( 223 + dir: &'a Directory<'_>, 224 + output_dir: &'a Path, 225 + pds_url: &'a Url, 226 + did: &'a str, 227 + new_blob_map: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 228 + existing_file_cids: &'a HashMap<String, String>, 229 + existing_output_dir: &'a Path, 230 + path_prefix: String, 231 + downloaded: &'a mut usize, 232 + reused: &'a mut usize, 233 + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send + 'a>> { 234 + Box::pin(async move { 235 + for entry in &dir.entries { 236 + let entry_name = entry.name.as_str(); 237 + let current_path = if path_prefix.is_empty() { 238 + entry_name.to_string() 239 + } else { 240 + format!("{}/{}", path_prefix, entry_name) 241 + }; 242 + 243 + match &entry.node { 244 + EntryNode::File(file) => { 245 + let output_path = output_dir.join(entry_name); 246 + 247 + // Check if file CID matches existing 248 + if let Some((_blob_ref, new_cid)) = new_blob_map.get(&current_path) { 249 + if let Some(existing_cid) = existing_file_cids.get(&current_path) { 250 + if existing_cid == new_cid { 251 + // File unchanged, copy from existing directory 252 + let existing_path = existing_output_dir.join(&current_path); 253 + if existing_path.exists() { 254 + std::fs::copy(&existing_path, &output_path).into_diagnostic()?; 255 + *reused += 1; 256 + println!(" โœ“ Reused {}", current_path); 257 + continue; 258 + } 259 + } 260 + } 261 + } 262 + 263 + // File is new or changed, download it 264 + println!(" โ†“ Downloading {}", current_path); 265 + let data = download::download_and_decompress_blob( 266 + pds_url, 267 + &file.blob, 268 + did, 269 + file.base64.unwrap_or(false), 270 + file.encoding.as_ref().map(|e| e.as_str() == "gzip").unwrap_or(false), 271 + ) 272 + .await?; 273 + 274 + std::fs::write(&output_path, data).into_diagnostic()?; 275 + *downloaded += 1; 276 + } 277 + EntryNode::Directory(subdir) => { 278 + let subdir_path = output_dir.join(entry_name); 279 + std::fs::create_dir_all(&subdir_path).into_diagnostic()?; 280 + 281 + download_directory( 282 + subdir, 283 + &subdir_path, 284 + pds_url, 285 + did, 286 + new_blob_map, 287 + existing_file_cids, 288 + existing_output_dir, 289 + current_path, 290 + downloaded, 291 + reused, 292 + ) 293 + .await?; 294 + } 295 + EntryNode::Unknown(_) => { 296 + // Skip unknown node types 297 + println!(" โš  Skipping unknown node type for {}", current_path); 298 + } 299 + } 300 + } 301 + 302 + Ok(()) 303 + }) 304 + } 305 +
+202
cli/src/serve.rs
··· 1 + use crate::pull::pull_site; 2 + use axum::Router; 3 + use jacquard::CowStr; 4 + use jacquard_common::jetstream::{CommitOperation, JetstreamMessage, JetstreamParams}; 5 + use jacquard_common::types::string::Did; 6 + use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient}; 7 + use miette::IntoDiagnostic; 8 + use n0_future::StreamExt; 9 + use std::path::PathBuf; 10 + use std::sync::Arc; 11 + use tokio::sync::RwLock; 12 + use tower_http::compression::CompressionLayer; 13 + use tower_http::services::ServeDir; 14 + use url::Url; 15 + 16 + /// Shared state for the server 17 + #[derive(Clone)] 18 + struct ServerState { 19 + did: CowStr<'static>, 20 + rkey: CowStr<'static>, 21 + output_dir: PathBuf, 22 + last_cid: Arc<RwLock<Option<String>>>, 23 + } 24 + 25 + /// Serve a site locally with real-time firehose updates 26 + pub async fn serve_site( 27 + input: CowStr<'static>, 28 + rkey: CowStr<'static>, 29 + output_dir: PathBuf, 30 + port: u16, 31 + ) -> miette::Result<()> { 32 + println!("Serving site {} from {} on port {}...", rkey, input, port); 33 + 34 + // Resolve handle to DID if needed 35 + use jacquard_identity::PublicResolver; 36 + use jacquard::prelude::IdentityResolver; 37 + 38 + let resolver = PublicResolver::default(); 39 + let did = if input.starts_with("did:") { 40 + Did::new(&input).into_diagnostic()? 41 + } else { 42 + // It's a handle, resolve it 43 + let handle = jacquard_common::types::string::Handle::new(&input).into_diagnostic()?; 44 + resolver.resolve_handle(&handle).await.into_diagnostic()? 45 + }; 46 + 47 + println!("Resolved to DID: {}", did.as_str()); 48 + 49 + // Create output directory if it doesn't exist 50 + std::fs::create_dir_all(&output_dir).into_diagnostic()?; 51 + 52 + // Initial pull of the site 53 + println!("Performing initial pull..."); 54 + let did_str = CowStr::from(did.as_str().to_string()); 55 + pull_site(did_str.clone(), rkey.clone(), output_dir.clone()).await?; 56 + 57 + // Create shared state 58 + let state = ServerState { 59 + did: did_str.clone(), 60 + rkey: rkey.clone(), 61 + output_dir: output_dir.clone(), 62 + last_cid: Arc::new(RwLock::new(None)), 63 + }; 64 + 65 + // Start firehose listener in background 66 + let firehose_state = state.clone(); 67 + tokio::spawn(async move { 68 + if let Err(e) = watch_firehose(firehose_state).await { 69 + eprintln!("Firehose error: {}", e); 70 + } 71 + }); 72 + 73 + // Create HTTP server with gzip compression 74 + let app = Router::new() 75 + .fallback_service( 76 + ServeDir::new(&output_dir) 77 + .precompressed_gzip() 78 + ) 79 + .layer(CompressionLayer::new()) 80 + .with_state(state); 81 + 82 + let addr = format!("0.0.0.0:{}", port); 83 + let listener = tokio::net::TcpListener::bind(&addr) 84 + .await 85 + .into_diagnostic()?; 86 + 87 + println!("\nโœ“ Server running at http://localhost:{}", port); 88 + println!(" Watching for updates on the firehose...\n"); 89 + 90 + axum::serve(listener, app).await.into_diagnostic()?; 91 + 92 + Ok(()) 93 + } 94 + 95 + /// Watch the firehose for updates to the specific site 96 + fn watch_firehose(state: ServerState) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send>> { 97 + Box::pin(async move { 98 + let jetstream_url = Url::parse("wss://jetstream1.us-east.fire.hose.cam") 99 + .into_diagnostic()?; 100 + 101 + println!("[Firehose] Connecting to Jetstream..."); 102 + 103 + // Create subscription client 104 + let client = TungsteniteSubscriptionClient::from_base_uri(jetstream_url); 105 + 106 + // Subscribe with no filters (we'll filter manually) 107 + // Jetstream doesn't support filtering by collection in the params builder 108 + let params = JetstreamParams::new().build(); 109 + 110 + let stream = client.subscribe(&params).await.into_diagnostic()?; 111 + println!("[Firehose] Connected! Watching for updates..."); 112 + 113 + // Convert to typed message stream 114 + let (_sink, mut messages) = stream.into_stream(); 115 + 116 + loop { 117 + match messages.next().await { 118 + Some(Ok(msg)) => { 119 + if let Err(e) = handle_firehose_message(&state, msg).await { 120 + eprintln!("[Firehose] Error handling message: {}", e); 121 + } 122 + } 123 + Some(Err(e)) => { 124 + eprintln!("[Firehose] Stream error: {}", e); 125 + // Try to reconnect after a delay 126 + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; 127 + return Box::pin(watch_firehose(state)).await; 128 + } 129 + None => { 130 + println!("[Firehose] Stream ended, reconnecting..."); 131 + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; 132 + return Box::pin(watch_firehose(state)).await; 133 + } 134 + } 135 + } 136 + }) 137 + } 138 + 139 + /// Handle a firehose message 140 + async fn handle_firehose_message( 141 + state: &ServerState, 142 + msg: JetstreamMessage<'_>, 143 + ) -> miette::Result<()> { 144 + match msg { 145 + JetstreamMessage::Commit { 146 + did, 147 + commit, 148 + .. 149 + } => { 150 + // Check if this is our site 151 + if did.as_str() == state.did.as_str() 152 + && commit.collection.as_str() == "place.wisp.fs" 153 + && commit.rkey.as_str() == state.rkey.as_str() 154 + { 155 + match commit.operation { 156 + CommitOperation::Create | CommitOperation::Update => { 157 + let new_cid = commit.cid.as_ref().map(|c| c.to_string()); 158 + 159 + // Check if CID changed 160 + let should_update = { 161 + let last_cid = state.last_cid.read().await; 162 + new_cid != *last_cid 163 + }; 164 + 165 + if should_update { 166 + println!("\n[Update] Detected change to site {} (CID: {:?})", state.rkey, new_cid); 167 + println!("[Update] Pulling latest version..."); 168 + 169 + // Pull the updated site 170 + match pull_site( 171 + state.did.clone(), 172 + state.rkey.clone(), 173 + state.output_dir.clone(), 174 + ) 175 + .await 176 + { 177 + Ok(_) => { 178 + // Update last CID 179 + let mut last_cid = state.last_cid.write().await; 180 + *last_cid = new_cid; 181 + println!("[Update] โœ“ Site updated successfully!\n"); 182 + } 183 + Err(e) => { 184 + eprintln!("[Update] Failed to pull site: {}", e); 185 + } 186 + } 187 + } 188 + } 189 + CommitOperation::Delete => { 190 + println!("\n[Update] Site {} was deleted", state.rkey); 191 + } 192 + } 193 + } 194 + } 195 + _ => { 196 + // Ignore identity and account messages 197 + } 198 + } 199 + 200 + Ok(()) 201 + } 202 +
-14
cli/test_headers.rs
··· 1 - use http::Request; 2 - 3 - fn main() { 4 - let builder = Request::builder() 5 - .header(http::header::CONTENT_TYPE, "*/*") 6 - .header(http::header::CONTENT_TYPE, "application/octet-stream"); 7 - 8 - let req = builder.body(()).unwrap(); 9 - 10 - println!("Content-Type headers:"); 11 - for value in req.headers().get_all(http::header::CONTENT_TYPE) { 12 - println!(" {:?}", value); 13 - } 14 - }
+90
crates.nix
··· 1 + {...}: { 2 + perSystem = { 3 + pkgs, 4 + config, 5 + lib, 6 + inputs', 7 + ... 8 + }: { 9 + # declare projects 10 + nci.projects."wisp-place-cli" = { 11 + path = ./cli; 12 + export = false; 13 + }; 14 + nci.toolchains.mkBuild = _: 15 + with inputs'.fenix.packages; 16 + combine [ 17 + minimal.rustc 18 + minimal.cargo 19 + targets.x86_64-pc-windows-gnu.latest.rust-std 20 + targets.x86_64-unknown-linux-gnu.latest.rust-std 21 + targets.aarch64-apple-darwin.latest.rust-std 22 + targets.aarch64-unknown-linux-gnu.latest.rust-std 23 + ]; 24 + # configure crates 25 + nci.crates."wisp-cli" = { 26 + profiles = { 27 + dev.runTests = false; 28 + release.runTests = false; 29 + }; 30 + targets."x86_64-unknown-linux-gnu" = let 31 + targetPkgs = pkgs.pkgsCross.gnu64; 32 + targetCC = targetPkgs.stdenv.cc; 33 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 34 + in rec { 35 + default = true; 36 + depsDrvConfig.mkDerivation = { 37 + nativeBuildInputs = [targetCC]; 38 + }; 39 + depsDrvConfig.env = rec { 40 + TARGET_CC = "${targetCC.targetPrefix}cc"; 41 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 42 + }; 43 + drvConfig = depsDrvConfig; 44 + }; 45 + targets."x86_64-pc-windows-gnu" = let 46 + targetPkgs = pkgs.pkgsCross.mingwW64; 47 + targetCC = targetPkgs.stdenv.cc; 48 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 49 + in rec { 50 + depsDrvConfig.mkDerivation = { 51 + nativeBuildInputs = [targetCC]; 52 + buildInputs = with targetPkgs; [windows.pthreads]; 53 + }; 54 + depsDrvConfig.env = rec { 55 + TARGET_CC = "${targetCC.targetPrefix}cc"; 56 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 57 + }; 58 + drvConfig = depsDrvConfig; 59 + }; 60 + targets."aarch64-apple-darwin" = let 61 + targetPkgs = pkgs.pkgsCross.aarch64-darwin; 62 + targetCC = targetPkgs.stdenv.cc; 63 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 64 + in rec { 65 + depsDrvConfig.mkDerivation = { 66 + nativeBuildInputs = [targetCC]; 67 + }; 68 + depsDrvConfig.env = rec { 69 + TARGET_CC = "${targetCC.targetPrefix}cc"; 70 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 71 + }; 72 + drvConfig = depsDrvConfig; 73 + }; 74 + targets."aarch64-unknown-linux-gnu" = let 75 + targetPkgs = pkgs.pkgsCross.aarch64-multiplatform; 76 + targetCC = targetPkgs.stdenv.cc; 77 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 78 + in rec { 79 + depsDrvConfig.mkDerivation = { 80 + nativeBuildInputs = [targetCC]; 81 + }; 82 + depsDrvConfig.env = rec { 83 + TARGET_CC = "${targetCC.targetPrefix}cc"; 84 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 85 + }; 86 + drvConfig = depsDrvConfig; 87 + }; 88 + }; 89 + }; 90 + }
+318
flake.lock
··· 1 + { 2 + "nodes": { 3 + "crane": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1758758545, 7 + "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", 8 + "owner": "ipetkov", 9 + "repo": "crane", 10 + "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", 11 + "type": "github" 12 + }, 13 + "original": { 14 + "owner": "ipetkov", 15 + "ref": "v0.21.1", 16 + "repo": "crane", 17 + "type": "github" 18 + } 19 + }, 20 + "dream2nix": { 21 + "inputs": { 22 + "nixpkgs": [ 23 + "nci", 24 + "nixpkgs" 25 + ], 26 + "purescript-overlay": "purescript-overlay", 27 + "pyproject-nix": "pyproject-nix" 28 + }, 29 + "locked": { 30 + "lastModified": 1754978539, 31 + "narHash": "sha256-nrDovydywSKRbWim9Ynmgj8SBm8LK3DI2WuhIqzOHYI=", 32 + "owner": "nix-community", 33 + "repo": "dream2nix", 34 + "rev": "fbec3263cb4895ac86ee9506cdc4e6919a1a2214", 35 + "type": "github" 36 + }, 37 + "original": { 38 + "owner": "nix-community", 39 + "repo": "dream2nix", 40 + "type": "github" 41 + } 42 + }, 43 + "fenix": { 44 + "inputs": { 45 + "nixpkgs": [ 46 + "nixpkgs" 47 + ], 48 + "rust-analyzer-src": "rust-analyzer-src" 49 + }, 50 + "locked": { 51 + "lastModified": 1762584108, 52 + "narHash": "sha256-wZUW7dlXMXaRdvNbaADqhF8gg9bAfFiMV+iyFQiDv+Y=", 53 + "owner": "nix-community", 54 + "repo": "fenix", 55 + "rev": "32f3ad3b6c690061173e1ac16708874975ec6056", 56 + "type": "github" 57 + }, 58 + "original": { 59 + "owner": "nix-community", 60 + "repo": "fenix", 61 + "type": "github" 62 + } 63 + }, 64 + "flake-compat": { 65 + "flake": false, 66 + "locked": { 67 + "lastModified": 1696426674, 68 + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 69 + "owner": "edolstra", 70 + "repo": "flake-compat", 71 + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 72 + "type": "github" 73 + }, 74 + "original": { 75 + "owner": "edolstra", 76 + "repo": "flake-compat", 77 + "type": "github" 78 + } 79 + }, 80 + "mk-naked-shell": { 81 + "flake": false, 82 + "locked": { 83 + "lastModified": 1681286841, 84 + "narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=", 85 + "owner": "90-008", 86 + "repo": "mk-naked-shell", 87 + "rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd", 88 + "type": "github" 89 + }, 90 + "original": { 91 + "owner": "90-008", 92 + "repo": "mk-naked-shell", 93 + "type": "github" 94 + } 95 + }, 96 + "nci": { 97 + "inputs": { 98 + "crane": "crane", 99 + "dream2nix": "dream2nix", 100 + "mk-naked-shell": "mk-naked-shell", 101 + "nixpkgs": [ 102 + "nixpkgs" 103 + ], 104 + "parts": "parts", 105 + "rust-overlay": "rust-overlay", 106 + "treefmt": "treefmt" 107 + }, 108 + "locked": { 109 + "lastModified": 1762582646, 110 + "narHash": "sha256-MMzE4xccG+8qbLhdaZoeFDUKWUOn3B4lhp5dZmgukmM=", 111 + "owner": "90-008", 112 + "repo": "nix-cargo-integration", 113 + "rev": "0993c449377049fa8868a664e8290ac6658e0b9a", 114 + "type": "github" 115 + }, 116 + "original": { 117 + "owner": "90-008", 118 + "repo": "nix-cargo-integration", 119 + "type": "github" 120 + } 121 + }, 122 + "nixpkgs": { 123 + "locked": { 124 + "lastModified": 1762361079, 125 + "narHash": "sha256-lz718rr1BDpZBYk7+G8cE6wee3PiBUpn8aomG/vLLiY=", 126 + "owner": "nixos", 127 + "repo": "nixpkgs", 128 + "rev": "ffcdcf99d65c61956d882df249a9be53e5902ea5", 129 + "type": "github" 130 + }, 131 + "original": { 132 + "owner": "nixos", 133 + "ref": "nixpkgs-unstable", 134 + "repo": "nixpkgs", 135 + "type": "github" 136 + } 137 + }, 138 + "parts": { 139 + "inputs": { 140 + "nixpkgs-lib": [ 141 + "nci", 142 + "nixpkgs" 143 + ] 144 + }, 145 + "locked": { 146 + "lastModified": 1762440070, 147 + "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", 148 + "owner": "hercules-ci", 149 + "repo": "flake-parts", 150 + "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", 151 + "type": "github" 152 + }, 153 + "original": { 154 + "owner": "hercules-ci", 155 + "repo": "flake-parts", 156 + "type": "github" 157 + } 158 + }, 159 + "parts_2": { 160 + "inputs": { 161 + "nixpkgs-lib": [ 162 + "nixpkgs" 163 + ] 164 + }, 165 + "locked": { 166 + "lastModified": 1762440070, 167 + "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", 168 + "owner": "hercules-ci", 169 + "repo": "flake-parts", 170 + "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", 171 + "type": "github" 172 + }, 173 + "original": { 174 + "owner": "hercules-ci", 175 + "repo": "flake-parts", 176 + "type": "github" 177 + } 178 + }, 179 + "purescript-overlay": { 180 + "inputs": { 181 + "flake-compat": "flake-compat", 182 + "nixpkgs": [ 183 + "nci", 184 + "dream2nix", 185 + "nixpkgs" 186 + ], 187 + "slimlock": "slimlock" 188 + }, 189 + "locked": { 190 + "lastModified": 1728546539, 191 + "narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=", 192 + "owner": "thomashoneyman", 193 + "repo": "purescript-overlay", 194 + "rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4", 195 + "type": "github" 196 + }, 197 + "original": { 198 + "owner": "thomashoneyman", 199 + "repo": "purescript-overlay", 200 + "type": "github" 201 + } 202 + }, 203 + "pyproject-nix": { 204 + "inputs": { 205 + "nixpkgs": [ 206 + "nci", 207 + "dream2nix", 208 + "nixpkgs" 209 + ] 210 + }, 211 + "locked": { 212 + "lastModified": 1752481895, 213 + "narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=", 214 + "owner": "pyproject-nix", 215 + "repo": "pyproject.nix", 216 + "rev": "16ee295c25107a94e59a7fc7f2e5322851781162", 217 + "type": "github" 218 + }, 219 + "original": { 220 + "owner": "pyproject-nix", 221 + "repo": "pyproject.nix", 222 + "type": "github" 223 + } 224 + }, 225 + "root": { 226 + "inputs": { 227 + "fenix": "fenix", 228 + "nci": "nci", 229 + "nixpkgs": "nixpkgs", 230 + "parts": "parts_2" 231 + } 232 + }, 233 + "rust-analyzer-src": { 234 + "flake": false, 235 + "locked": { 236 + "lastModified": 1762438844, 237 + "narHash": "sha256-ApIKJf6CcMsV2nYBXhGF95BmZMO/QXPhgfSnkA/rVUo=", 238 + "owner": "rust-lang", 239 + "repo": "rust-analyzer", 240 + "rev": "4bf516ee5a960c1e2eee9fedd9b1c9e976a19c86", 241 + "type": "github" 242 + }, 243 + "original": { 244 + "owner": "rust-lang", 245 + "ref": "nightly", 246 + "repo": "rust-analyzer", 247 + "type": "github" 248 + } 249 + }, 250 + "rust-overlay": { 251 + "inputs": { 252 + "nixpkgs": [ 253 + "nci", 254 + "nixpkgs" 255 + ] 256 + }, 257 + "locked": { 258 + "lastModified": 1762569282, 259 + "narHash": "sha256-vINZAJpXQTZd5cfh06Rcw7hesH7sGSvi+Tn+HUieJn8=", 260 + "owner": "oxalica", 261 + "repo": "rust-overlay", 262 + "rev": "a35a6144b976f70827c2fe2f5c89d16d8f9179d8", 263 + "type": "github" 264 + }, 265 + "original": { 266 + "owner": "oxalica", 267 + "repo": "rust-overlay", 268 + "type": "github" 269 + } 270 + }, 271 + "slimlock": { 272 + "inputs": { 273 + "nixpkgs": [ 274 + "nci", 275 + "dream2nix", 276 + "purescript-overlay", 277 + "nixpkgs" 278 + ] 279 + }, 280 + "locked": { 281 + "lastModified": 1688756706, 282 + "narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=", 283 + "owner": "thomashoneyman", 284 + "repo": "slimlock", 285 + "rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c", 286 + "type": "github" 287 + }, 288 + "original": { 289 + "owner": "thomashoneyman", 290 + "repo": "slimlock", 291 + "type": "github" 292 + } 293 + }, 294 + "treefmt": { 295 + "inputs": { 296 + "nixpkgs": [ 297 + "nci", 298 + "nixpkgs" 299 + ] 300 + }, 301 + "locked": { 302 + "lastModified": 1762410071, 303 + "narHash": "sha256-aF5fvoZeoXNPxT0bejFUBXeUjXfHLSL7g+mjR/p5TEg=", 304 + "owner": "numtide", 305 + "repo": "treefmt-nix", 306 + "rev": "97a30861b13c3731a84e09405414398fbf3e109f", 307 + "type": "github" 308 + }, 309 + "original": { 310 + "owner": "numtide", 311 + "repo": "treefmt-nix", 312 + "type": "github" 313 + } 314 + } 315 + }, 316 + "root": "root", 317 + "version": 7 318 + }
+59
flake.nix
··· 1 + { 2 + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 3 + inputs.nci.url = "github:90-008/nix-cargo-integration"; 4 + inputs.nci.inputs.nixpkgs.follows = "nixpkgs"; 5 + inputs.parts.url = "github:hercules-ci/flake-parts"; 6 + inputs.parts.inputs.nixpkgs-lib.follows = "nixpkgs"; 7 + inputs.fenix = { 8 + url = "github:nix-community/fenix"; 9 + inputs.nixpkgs.follows = "nixpkgs"; 10 + }; 11 + 12 + outputs = inputs @ { 13 + parts, 14 + nci, 15 + ... 16 + }: 17 + parts.lib.mkFlake {inherit inputs;} { 18 + systems = ["x86_64-linux" "aarch64-darwin"]; 19 + imports = [ 20 + nci.flakeModule 21 + ./crates.nix 22 + ]; 23 + perSystem = { 24 + pkgs, 25 + config, 26 + ... 27 + }: let 28 + crateOutputs = config.nci.outputs."wisp-cli"; 29 + mkRenamedPackage = name: pkg: isWindows: pkgs.runCommand name {} '' 30 + mkdir -p $out/bin 31 + if [ -f ${pkg}/bin/wisp-cli.exe ]; then 32 + cp ${pkg}/bin/wisp-cli.exe $out/bin/${name} 33 + elif [ -f ${pkg}/bin/wisp-cli ]; then 34 + cp ${pkg}/bin/wisp-cli $out/bin/${name} 35 + else 36 + echo "Error: Could not find wisp-cli binary in ${pkg}/bin/" 37 + ls -la ${pkg}/bin/ || true 38 + exit 1 39 + fi 40 + ''; 41 + in { 42 + devShells.default = crateOutputs.devShell; 43 + packages.default = crateOutputs.packages.release; 44 + packages.wisp-cli-x86_64-linux = mkRenamedPackage "wisp-cli-x86_64-linux" crateOutputs.packages.release false; 45 + packages.wisp-cli-aarch64-linux = mkRenamedPackage "wisp-cli-aarch64-linux" crateOutputs.allTargets."aarch64-unknown-linux-gnu".packages.release false; 46 + packages.wisp-cli-x86_64-windows = mkRenamedPackage "wisp-cli-x86_64-windows.exe" crateOutputs.allTargets."x86_64-pc-windows-gnu".packages.release true; 47 + packages.wisp-cli-aarch64-darwin = mkRenamedPackage "wisp-cli-aarch64-darwin" crateOutputs.allTargets."aarch64-apple-darwin".packages.release false; 48 + packages.all = pkgs.symlinkJoin { 49 + name = "wisp-cli-all"; 50 + paths = [ 51 + config.packages.wisp-cli-x86_64-linux 52 + config.packages.wisp-cli-aarch64-linux 53 + config.packages.wisp-cli-x86_64-windows 54 + config.packages.wisp-cli-aarch64-darwin 55 + ]; 56 + }; 57 + }; 58 + }; 59 + }
+7 -7
hosting-service/Dockerfile
··· 1 - # Use official Bun image 2 - FROM oven/bun:1.3 AS base 1 + # Use official Node.js Alpine image 2 + FROM node:alpine AS base 3 3 4 4 # Set working directory 5 5 WORKDIR /app 6 6 7 7 # Copy package files 8 - COPY package.json bun.lock ./ 8 + COPY package.json ./ 9 9 10 10 # Install dependencies 11 - RUN bun install --frozen-lockfile --production 11 + RUN npm install 12 12 13 13 # Copy source code 14 14 COPY src ./src ··· 25 25 26 26 # Health check 27 27 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 28 - CMD bun -e "fetch('http://localhost:3001/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" 28 + CMD node -e "fetch('http://localhost:3001/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" 29 29 30 - # Start the application 31 - CMD ["bun", "src/index.ts"] 30 + # Start the application (can override with 'npm run backfill' in compose) 31 + CMD ["npm", "run", "start"]
-123
hosting-service/EXAMPLE.md
··· 1 - # HTML Path Rewriting Example 2 - 3 - This document demonstrates how HTML path rewriting works when serving sites via the `/s/:identifier/:site/*` route. 4 - 5 - ## Problem 6 - 7 - When you create a static site with absolute paths like `/style.css` or `/images/logo.png`, these paths work fine when served from the root domain. However, when served from a subdirectory like `/s/alice.bsky.social/mysite/`, these absolute paths break because they resolve to the server root instead of the site root. 8 - 9 - ## Solution 10 - 11 - The hosting service automatically rewrites absolute paths in HTML files to work correctly in the subdirectory context. 12 - 13 - ## Example 14 - 15 - **Original HTML file (index.html):** 16 - ```html 17 - <!DOCTYPE html> 18 - <html> 19 - <head> 20 - <meta charset="UTF-8"> 21 - <title>My Site</title> 22 - <link rel="stylesheet" href="/style.css"> 23 - <link rel="icon" href="/favicon.ico"> 24 - <script src="/app.js"></script> 25 - </head> 26 - <body> 27 - <header> 28 - <img src="/images/logo.png" alt="Logo"> 29 - <nav> 30 - <a href="/">Home</a> 31 - <a href="/about">About</a> 32 - <a href="/contact">Contact</a> 33 - </nav> 34 - </header> 35 - 36 - <main> 37 - <h1>Welcome</h1> 38 - <img src="/images/hero.jpg" 39 - srcset="/images/hero.jpg 1x, /images/hero@2x.jpg 2x" 40 - alt="Hero"> 41 - 42 - <form action="/submit" method="post"> 43 - <input type="text" name="email"> 44 - <button>Submit</button> 45 - </form> 46 - </main> 47 - 48 - <footer> 49 - <a href="https://example.com">External Link</a> 50 - <a href="#top">Back to Top</a> 51 - </footer> 52 - </body> 53 - </html> 54 - ``` 55 - 56 - **When accessed via `/s/alice.bsky.social/mysite/`, the HTML is rewritten to:** 57 - ```html 58 - <!DOCTYPE html> 59 - <html> 60 - <head> 61 - <meta charset="UTF-8"> 62 - <title>My Site</title> 63 - <link rel="stylesheet" href="/s/alice.bsky.social/mysite/style.css"> 64 - <link rel="icon" href="/s/alice.bsky.social/mysite/favicon.ico"> 65 - <script src="/s/alice.bsky.social/mysite/app.js"></script> 66 - </head> 67 - <body> 68 - <header> 69 - <img src="/s/alice.bsky.social/mysite/images/logo.png" alt="Logo"> 70 - <nav> 71 - <a href="/s/alice.bsky.social/mysite/">Home</a> 72 - <a href="/s/alice.bsky.social/mysite/about">About</a> 73 - <a href="/s/alice.bsky.social/mysite/contact">Contact</a> 74 - </nav> 75 - </header> 76 - 77 - <main> 78 - <h1>Welcome</h1> 79 - <img src="/s/alice.bsky.social/mysite/images/hero.jpg" 80 - srcset="/s/alice.bsky.social/mysite/images/hero.jpg 1x, /s/alice.bsky.social/mysite/images/hero@2x.jpg 2x" 81 - alt="Hero"> 82 - 83 - <form action="/s/alice.bsky.social/mysite/submit" method="post"> 84 - <input type="text" name="email"> 85 - <button>Submit</button> 86 - </form> 87 - </main> 88 - 89 - <footer> 90 - <a href="https://example.com">External Link</a> 91 - <a href="#top">Back to Top</a> 92 - </footer> 93 - </body> 94 - </html> 95 - ``` 96 - 97 - ## What's Preserved 98 - 99 - Notice that: 100 - - โœ… Absolute paths are rewritten: `/style.css` โ†’ `/s/alice.bsky.social/mysite/style.css` 101 - - โœ… External URLs are preserved: `https://example.com` stays the same 102 - - โœ… Anchors are preserved: `#top` stays the same 103 - - โœ… The rewriting is safe and won't break your site 104 - 105 - ## Supported Attributes 106 - 107 - The rewriter handles these HTML attributes: 108 - - `src` - images, scripts, iframes, videos, audio 109 - - `href` - links, stylesheets 110 - - `action` - forms 111 - - `data` - objects 112 - - `poster` - video posters 113 - - `srcset` - responsive images 114 - 115 - ## Testing Your Site 116 - 117 - To test if your site works with path rewriting: 118 - 119 - 1. Upload your site to your PDS as a `place.wisp.fs` record 120 - 2. Access it via: `https://hosting.wisp.place/s/YOUR_HANDLE/SITE_NAME/` 121 - 3. Check that all resources load correctly 122 - 123 - If you're using relative paths already (like `./style.css` or `../images/logo.png`), they'll work without any rewriting.
+32
hosting-service/docker-entrypoint.sh
··· 1 + #!/bin/sh 2 + set -e 3 + 4 + # Run different modes based on MODE environment variable 5 + # Modes: 6 + # - server (default): Start the hosting service 7 + # - backfill: Run cache backfill and exit 8 + # - backfill-server: Run cache backfill, then start the server 9 + 10 + MODE="${MODE:-server}" 11 + 12 + case "$MODE" in 13 + backfill) 14 + echo "๐Ÿ”„ Running in backfill-only mode..." 15 + exec npm run backfill 16 + ;; 17 + backfill-server) 18 + echo "๐Ÿ”„ Running backfill, then starting server..." 19 + npm run backfill 20 + echo "โœ… Backfill complete, starting server..." 21 + exec npm run start 22 + ;; 23 + server) 24 + echo "๐Ÿš€ Starting server..." 25 + exec npm run start 26 + ;; 27 + *) 28 + echo "โŒ Unknown MODE: $MODE" 29 + echo "Valid modes: server, backfill, backfill-server" 30 + exit 1 31 + ;; 32 + esac
+134
hosting-service/example-_redirects
··· 1 + # Example _redirects file for Wisp hosting 2 + # Place this file in the root directory of your site as "_redirects" 3 + # Lines starting with # are comments 4 + 5 + # =================================== 6 + # SIMPLE REDIRECTS 7 + # =================================== 8 + 9 + # Redirect home page 10 + # /home / 11 + 12 + # Redirect old URLs to new ones 13 + # /old-blog /blog 14 + # /about-us /about 15 + 16 + # =================================== 17 + # SPLAT REDIRECTS (WILDCARDS) 18 + # =================================== 19 + 20 + # Redirect entire directories 21 + # /news/* /blog/:splat 22 + # /old-site/* /new-site/:splat 23 + 24 + # =================================== 25 + # PLACEHOLDER REDIRECTS 26 + # =================================== 27 + 28 + # Restructure blog URLs 29 + # /blog/:year/:month/:day/:slug /posts/:year-:month-:day/:slug 30 + 31 + # Capture multiple parameters 32 + # /products/:category/:id /shop/:category/item/:id 33 + 34 + # =================================== 35 + # STATUS CODES 36 + # =================================== 37 + 38 + # Permanent redirect (301) - default if not specified 39 + # /permanent-move /new-location 301 40 + 41 + # Temporary redirect (302) 42 + # /temp-redirect /temp-location 302 43 + 44 + # Rewrite (200) - serves different content, URL stays the same 45 + # /api/* /functions/:splat 200 46 + 47 + # Custom 404 page 48 + # /shop/* /shop-closed.html 404 49 + 50 + # =================================== 51 + # FORCE REDIRECTS 52 + # =================================== 53 + 54 + # Force redirect even if file exists (note the ! after status code) 55 + # /override-file /other-file.html 200! 56 + 57 + # =================================== 58 + # CONDITIONAL REDIRECTS 59 + # =================================== 60 + 61 + # Country-based redirects (ISO 3166-1 alpha-2 codes) 62 + # / /us/ 302 Country=us 63 + # / /uk/ 302 Country=gb 64 + # / /anz/ 302 Country=au,nz 65 + 66 + # Language-based redirects 67 + # /products /en/products 301 Language=en 68 + # /products /de/products 301 Language=de 69 + # /products /fr/products 301 Language=fr 70 + 71 + # Cookie-based redirects (checks if cookie exists) 72 + # /* /legacy/:splat 200 Cookie=is_legacy 73 + 74 + # =================================== 75 + # QUERY PARAMETERS 76 + # =================================== 77 + 78 + # Match specific query parameters 79 + # /store id=:id /blog/:id 301 80 + 81 + # Multiple parameters 82 + # /search q=:query category=:cat /find/:cat/:query 301 83 + 84 + # =================================== 85 + # DOMAIN-LEVEL REDIRECTS 86 + # =================================== 87 + 88 + # Redirect to different domain (must include protocol) 89 + # /external https://example.com/path 90 + 91 + # Redirect entire subdomain 92 + # http://blog.example.com/* https://example.com/blog/:splat 301! 93 + # https://blog.example.com/* https://example.com/blog/:splat 301! 94 + 95 + # =================================== 96 + # COMMON PATTERNS 97 + # =================================== 98 + 99 + # Remove .html extensions 100 + # /page.html /page 101 + 102 + # Add trailing slash 103 + # /about /about/ 104 + 105 + # Single-page app fallback (serve index.html for all paths) 106 + # /* /index.html 200 107 + 108 + # API proxy 109 + # /api/* https://api.example.com/:splat 200 110 + 111 + # =================================== 112 + # CUSTOM ERROR PAGES 113 + # =================================== 114 + 115 + # Language-specific 404 pages 116 + # /en/* /en/404.html 404 117 + # /de/* /de/404.html 404 118 + 119 + # Section-specific 404 pages 120 + # /shop/* /shop/not-found.html 404 121 + # /blog/* /blog/404.html 404 122 + 123 + # =================================== 124 + # NOTES 125 + # =================================== 126 + # 127 + # - Rules are processed in order (first match wins) 128 + # - More specific rules should come before general ones 129 + # - Splats (*) can only be used at the end of a path 130 + # - Query parameters are automatically preserved for 200, 301, 302 131 + # - Trailing slashes are normalized (/ and no / are treated the same) 132 + # - Default status code is 301 if not specified 133 + # 134 +
+3 -2
hosting-service/package.json
··· 3 3 "version": "1.0.0", 4 4 "type": "module", 5 5 "scripts": { 6 - "dev": "tsx watch src/index.ts", 6 + "dev": "tsx --env-file=.env watch src/index.ts", 7 7 "build": "tsc", 8 - "start": "tsx src/index.ts" 8 + "start": "tsx src/index.ts", 9 + "backfill": "tsx src/index.ts --backfill" 9 10 }, 10 11 "dependencies": { 11 12 "@atproto/api": "^0.17.4",
+36 -1
hosting-service/src/index.ts
··· 3 3 import { FirehoseWorker } from './lib/firehose'; 4 4 import { logger } from './lib/observability'; 5 5 import { mkdirSync, existsSync } from 'fs'; 6 + import { backfillCache } from './lib/backfill'; 7 + import { startDomainCacheCleanup, stopDomainCacheCleanup, setCacheOnlyMode } from './lib/db'; 6 8 7 9 const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001; 8 - const CACHE_DIR = './cache/sites'; 10 + const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; 11 + 12 + // Parse CLI arguments 13 + const args = process.argv.slice(2); 14 + const hasBackfillFlag = args.includes('--backfill'); 15 + const backfillOnStartup = hasBackfillFlag || process.env.BACKFILL_ON_STARTUP === 'true'; 16 + 17 + // Cache-only mode: service will only cache files locally, no DB writes 18 + const hasCacheOnlyFlag = args.includes('--cache-only'); 19 + export const CACHE_ONLY_MODE = hasCacheOnlyFlag || process.env.CACHE_ONLY_MODE === 'true'; 20 + 21 + // Configure cache-only mode in database module 22 + if (CACHE_ONLY_MODE) { 23 + setCacheOnlyMode(true); 24 + } 9 25 10 26 // Ensure cache directory exists 11 27 if (!existsSync(CACHE_DIR)) { ··· 13 29 console.log('Created cache directory:', CACHE_DIR); 14 30 } 15 31 32 + // Start domain cache cleanup 33 + startDomainCacheCleanup(); 34 + 16 35 // Start firehose worker with observability logger 17 36 const firehose = new FirehoseWorker((msg, data) => { 18 37 logger.info(msg, data); ··· 20 39 21 40 firehose.start(); 22 41 42 + // Run backfill if requested 43 + if (backfillOnStartup) { 44 + console.log('๐Ÿ”„ Backfill requested, starting cache backfill...'); 45 + backfillCache({ 46 + skipExisting: true, 47 + concurrency: 3, 48 + }).then((stats) => { 49 + console.log('โœ… Cache backfill completed'); 50 + }).catch((err) => { 51 + console.error('โŒ Cache backfill error:', err); 52 + }); 53 + } 54 + 23 55 // Add health check endpoint 24 56 app.get('/health', (c) => { 25 57 const firehoseHealth = firehose.getHealth(); ··· 42 74 Health: http://localhost:${PORT}/health 43 75 Cache: ${CACHE_DIR} 44 76 Firehose: Connected to Firehose 77 + Cache-Only: ${CACHE_ONLY_MODE ? 'ENABLED (no DB writes)' : 'DISABLED'} 45 78 `); 46 79 47 80 // Graceful shutdown 48 81 process.on('SIGINT', async () => { 49 82 console.log('\n๐Ÿ›‘ Shutting down...'); 50 83 firehose.stop(); 84 + stopDomainCacheCleanup(); 51 85 server.close(); 52 86 process.exit(0); 53 87 }); ··· 55 89 process.on('SIGTERM', async () => { 56 90 console.log('\n๐Ÿ›‘ Shutting down...'); 57 91 firehose.stop(); 92 + stopDomainCacheCleanup(); 58 93 server.close(); 59 94 process.exit(0); 60 95 });
+136
hosting-service/src/lib/backfill.ts
··· 1 + import { getAllSites } from './db'; 2 + import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils'; 3 + import { logger } from './observability'; 4 + 5 + export interface BackfillOptions { 6 + skipExisting?: boolean; // Skip sites already in cache 7 + concurrency?: number; // Number of sites to cache concurrently 8 + maxSites?: number; // Maximum number of sites to backfill (for testing) 9 + } 10 + 11 + export interface BackfillStats { 12 + total: number; 13 + cached: number; 14 + skipped: number; 15 + failed: number; 16 + duration: number; 17 + } 18 + 19 + /** 20 + * Backfill all sites from the database into the local cache 21 + */ 22 + export async function backfillCache(options: BackfillOptions = {}): Promise<BackfillStats> { 23 + const { 24 + skipExisting = true, 25 + concurrency = 3, 26 + maxSites, 27 + } = options; 28 + 29 + const startTime = Date.now(); 30 + const stats: BackfillStats = { 31 + total: 0, 32 + cached: 0, 33 + skipped: 0, 34 + failed: 0, 35 + duration: 0, 36 + }; 37 + 38 + logger.info('Starting cache backfill', { skipExisting, concurrency, maxSites }); 39 + console.log(` 40 + โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— 41 + โ•‘ CACHE BACKFILL STARTING โ•‘ 42 + โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 43 + `); 44 + 45 + try { 46 + // Get all sites from database 47 + let sites = await getAllSites(); 48 + stats.total = sites.length; 49 + 50 + logger.info(`Found ${sites.length} sites in database`); 51 + console.log(`๐Ÿ“Š Found ${sites.length} sites in database`); 52 + 53 + // Limit if specified 54 + if (maxSites && maxSites > 0) { 55 + sites = sites.slice(0, maxSites); 56 + console.log(`โš™๏ธ Limited to ${maxSites} sites for backfill`); 57 + } 58 + 59 + // Process sites in batches 60 + const batches: typeof sites[] = []; 61 + for (let i = 0; i < sites.length; i += concurrency) { 62 + batches.push(sites.slice(i, i + concurrency)); 63 + } 64 + 65 + let processed = 0; 66 + for (const batch of batches) { 67 + await Promise.all( 68 + batch.map(async (site) => { 69 + try { 70 + // Check if already cached 71 + if (skipExisting && isCached(site.did, site.rkey)) { 72 + stats.skipped++; 73 + processed++; 74 + logger.debug(`Skipping already cached site`, { did: site.did, rkey: site.rkey }); 75 + console.log(`โญ๏ธ [${processed}/${sites.length}] Skipped (cached): ${site.display_name || site.rkey}`); 76 + return; 77 + } 78 + 79 + // Fetch site record 80 + const siteData = await fetchSiteRecord(site.did, site.rkey); 81 + if (!siteData) { 82 + stats.failed++; 83 + processed++; 84 + logger.error('Site record not found during backfill', null, { did: site.did, rkey: site.rkey }); 85 + console.log(`โŒ [${processed}/${sites.length}] Failed (not found): ${site.display_name || site.rkey}`); 86 + return; 87 + } 88 + 89 + // Get PDS endpoint 90 + const pdsEndpoint = await getPdsForDid(site.did); 91 + if (!pdsEndpoint) { 92 + stats.failed++; 93 + processed++; 94 + logger.error('PDS not found during backfill', null, { did: site.did }); 95 + console.log(`โŒ [${processed}/${sites.length}] Failed (no PDS): ${site.display_name || site.rkey}`); 96 + return; 97 + } 98 + 99 + // Download and cache site 100 + await downloadAndCacheSite(site.did, site.rkey, siteData.record, pdsEndpoint, siteData.cid); 101 + stats.cached++; 102 + processed++; 103 + logger.info('Successfully cached site during backfill', { did: site.did, rkey: site.rkey }); 104 + console.log(`โœ… [${processed}/${sites.length}] Cached: ${site.display_name || site.rkey}`); 105 + } catch (err) { 106 + stats.failed++; 107 + processed++; 108 + logger.error('Failed to cache site during backfill', err, { did: site.did, rkey: site.rkey }); 109 + console.log(`โŒ [${processed}/${sites.length}] Failed: ${site.display_name || site.rkey}`); 110 + } 111 + }) 112 + ); 113 + } 114 + 115 + stats.duration = Date.now() - startTime; 116 + 117 + console.log(` 118 + โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— 119 + โ•‘ CACHE BACKFILL COMPLETED โ•‘ 120 + โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 121 + 122 + ๐Ÿ“Š Total Sites: ${stats.total} 123 + โœ… Cached: ${stats.cached} 124 + โญ๏ธ Skipped: ${stats.skipped} 125 + โŒ Failed: ${stats.failed} 126 + โฑ๏ธ Duration: ${(stats.duration / 1000).toFixed(2)}s 127 + `); 128 + 129 + logger.info('Cache backfill completed', stats); 130 + } catch (err) { 131 + logger.error('Cache backfill failed', err); 132 + console.error('โŒ Cache backfill failed:', err); 133 + } 134 + 135 + return stats; 136 + }
+177
hosting-service/src/lib/cache.ts
··· 1 + // In-memory LRU cache for file contents and metadata 2 + 3 + interface CacheEntry<T> { 4 + value: T; 5 + size: number; 6 + timestamp: number; 7 + } 8 + 9 + interface CacheStats { 10 + hits: number; 11 + misses: number; 12 + evictions: number; 13 + currentSize: number; 14 + currentCount: number; 15 + } 16 + 17 + export class LRUCache<T> { 18 + private cache: Map<string, CacheEntry<T>>; 19 + private maxSize: number; 20 + private maxCount: number; 21 + private currentSize: number; 22 + private stats: CacheStats; 23 + 24 + constructor(maxSize: number, maxCount: number) { 25 + this.cache = new Map(); 26 + this.maxSize = maxSize; 27 + this.maxCount = maxCount; 28 + this.currentSize = 0; 29 + this.stats = { 30 + hits: 0, 31 + misses: 0, 32 + evictions: 0, 33 + currentSize: 0, 34 + currentCount: 0, 35 + }; 36 + } 37 + 38 + get(key: string): T | null { 39 + const entry = this.cache.get(key); 40 + if (!entry) { 41 + this.stats.misses++; 42 + return null; 43 + } 44 + 45 + // Move to end (most recently used) 46 + this.cache.delete(key); 47 + this.cache.set(key, entry); 48 + 49 + this.stats.hits++; 50 + return entry.value; 51 + } 52 + 53 + set(key: string, value: T, size: number): void { 54 + // Remove existing entry if present 55 + if (this.cache.has(key)) { 56 + const existing = this.cache.get(key)!; 57 + this.currentSize -= existing.size; 58 + this.cache.delete(key); 59 + } 60 + 61 + // Evict entries if needed 62 + while ( 63 + (this.cache.size >= this.maxCount || this.currentSize + size > this.maxSize) && 64 + this.cache.size > 0 65 + ) { 66 + const firstKey = this.cache.keys().next().value; 67 + if (!firstKey) break; // Should never happen, but satisfy TypeScript 68 + const firstEntry = this.cache.get(firstKey); 69 + if (!firstEntry) break; // Should never happen, but satisfy TypeScript 70 + this.cache.delete(firstKey); 71 + this.currentSize -= firstEntry.size; 72 + this.stats.evictions++; 73 + } 74 + 75 + // Add new entry 76 + this.cache.set(key, { 77 + value, 78 + size, 79 + timestamp: Date.now(), 80 + }); 81 + this.currentSize += size; 82 + 83 + // Update stats 84 + this.stats.currentSize = this.currentSize; 85 + this.stats.currentCount = this.cache.size; 86 + } 87 + 88 + delete(key: string): boolean { 89 + const entry = this.cache.get(key); 90 + if (!entry) return false; 91 + 92 + this.cache.delete(key); 93 + this.currentSize -= entry.size; 94 + this.stats.currentSize = this.currentSize; 95 + this.stats.currentCount = this.cache.size; 96 + return true; 97 + } 98 + 99 + // Invalidate all entries for a specific site 100 + invalidateSite(did: string, rkey: string): number { 101 + const prefix = `${did}:${rkey}:`; 102 + let count = 0; 103 + 104 + for (const key of Array.from(this.cache.keys())) { 105 + if (key.startsWith(prefix)) { 106 + this.delete(key); 107 + count++; 108 + } 109 + } 110 + 111 + return count; 112 + } 113 + 114 + // Get cache size 115 + size(): number { 116 + return this.cache.size; 117 + } 118 + 119 + clear(): void { 120 + this.cache.clear(); 121 + this.currentSize = 0; 122 + this.stats.currentSize = 0; 123 + this.stats.currentCount = 0; 124 + } 125 + 126 + getStats(): CacheStats { 127 + return { ...this.stats }; 128 + } 129 + 130 + // Get cache hit rate 131 + getHitRate(): number { 132 + const total = this.stats.hits + this.stats.misses; 133 + return total === 0 ? 0 : (this.stats.hits / total) * 100; 134 + } 135 + } 136 + 137 + // File metadata cache entry 138 + export interface FileMetadata { 139 + encoding?: 'gzip'; 140 + mimeType: string; 141 + } 142 + 143 + // Global cache instances 144 + const FILE_CACHE_SIZE = 100 * 1024 * 1024; // 100MB 145 + const FILE_CACHE_COUNT = 500; 146 + const METADATA_CACHE_COUNT = 2000; 147 + 148 + export const fileCache = new LRUCache<Buffer>(FILE_CACHE_SIZE, FILE_CACHE_COUNT); 149 + export const metadataCache = new LRUCache<FileMetadata>(1024 * 1024, METADATA_CACHE_COUNT); // 1MB for metadata 150 + export const rewrittenHtmlCache = new LRUCache<Buffer>(50 * 1024 * 1024, 200); // 50MB for rewritten HTML 151 + 152 + // Helper to generate cache keys 153 + export function getCacheKey(did: string, rkey: string, filePath: string, suffix?: string): string { 154 + const base = `${did}:${rkey}:${filePath}`; 155 + return suffix ? `${base}:${suffix}` : base; 156 + } 157 + 158 + // Invalidate all caches for a site 159 + export function invalidateSiteCache(did: string, rkey: string): void { 160 + const fileCount = fileCache.invalidateSite(did, rkey); 161 + const metaCount = metadataCache.invalidateSite(did, rkey); 162 + const htmlCount = rewrittenHtmlCache.invalidateSite(did, rkey); 163 + 164 + console.log(`[Cache] Invalidated site ${did}:${rkey} - ${fileCount} files, ${metaCount} metadata, ${htmlCount} HTML`); 165 + } 166 + 167 + // Get overall cache statistics 168 + export function getCacheStats() { 169 + return { 170 + files: fileCache.getStats(), 171 + fileHitRate: fileCache.getHitRate(), 172 + metadata: metadataCache.getStats(), 173 + metadataHitRate: metadataCache.getHitRate(), 174 + rewrittenHtml: rewrittenHtmlCache.getStats(), 175 + rewrittenHtmlHitRate: rewrittenHtmlCache.getHitRate(), 176 + }; 177 + }
+104
hosting-service/src/lib/db.ts
··· 1 1 import postgres from 'postgres'; 2 2 import { createHash } from 'crypto'; 3 3 4 + // Global cache-only mode flag (set by index.ts) 5 + let cacheOnlyMode = false; 6 + 7 + export function setCacheOnlyMode(enabled: boolean) { 8 + cacheOnlyMode = enabled; 9 + if (enabled) { 10 + console.log('[DB] Cache-only mode enabled - database writes will be skipped'); 11 + } 12 + } 13 + 4 14 const sql = postgres( 5 15 process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp', 6 16 { ··· 9 19 } 10 20 ); 11 21 22 + // Domain lookup cache with TTL 23 + const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 24 + 25 + interface CachedDomain<T> { 26 + value: T; 27 + timestamp: number; 28 + } 29 + 30 + const domainCache = new Map<string, CachedDomain<DomainLookup | null>>(); 31 + const customDomainCache = new Map<string, CachedDomain<CustomDomainLookup | null>>(); 32 + 33 + let cleanupInterval: NodeJS.Timeout | null = null; 34 + 35 + export function startDomainCacheCleanup() { 36 + if (cleanupInterval) return; 37 + 38 + cleanupInterval = setInterval(() => { 39 + const now = Date.now(); 40 + 41 + for (const [key, entry] of domainCache.entries()) { 42 + if (now - entry.timestamp > DOMAIN_CACHE_TTL) { 43 + domainCache.delete(key); 44 + } 45 + } 46 + 47 + for (const [key, entry] of customDomainCache.entries()) { 48 + if (now - entry.timestamp > DOMAIN_CACHE_TTL) { 49 + customDomainCache.delete(key); 50 + } 51 + } 52 + }, 30 * 60 * 1000); // Run every 30 minutes 53 + } 54 + 55 + export function stopDomainCacheCleanup() { 56 + if (cleanupInterval) { 57 + clearInterval(cleanupInterval); 58 + cleanupInterval = null; 59 + } 60 + } 61 + 12 62 export interface DomainLookup { 13 63 did: string; 14 64 rkey: string | null; ··· 27 77 export async function getWispDomain(domain: string): Promise<DomainLookup | null> { 28 78 const key = domain.toLowerCase(); 29 79 80 + // Check cache first 81 + const cached = domainCache.get(key); 82 + if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 83 + return cached.value; 84 + } 85 + 30 86 // Query database 31 87 const result = await sql<DomainLookup[]>` 32 88 SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1 33 89 `; 34 90 const data = result[0] || null; 91 + 92 + // Cache the result 93 + domainCache.set(key, { value: data, timestamp: Date.now() }); 35 94 36 95 return data; 37 96 } 38 97 39 98 export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> { 40 99 const key = domain.toLowerCase(); 100 + 101 + // Check cache first 102 + const cached = customDomainCache.get(key); 103 + if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 104 + return cached.value; 105 + } 41 106 42 107 // Query database 43 108 const result = await sql<CustomDomainLookup[]>` ··· 46 111 `; 47 112 const data = result[0] || null; 48 113 114 + // Cache the result 115 + customDomainCache.set(key, { value: data, timestamp: Date.now() }); 116 + 49 117 return data; 50 118 } 51 119 52 120 export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> { 121 + const key = `hash:${hash}`; 122 + 123 + // Check cache first 124 + const cached = customDomainCache.get(key); 125 + if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 126 + return cached.value; 127 + } 128 + 53 129 // Query database 54 130 const result = await sql<CustomDomainLookup[]>` 55 131 SELECT id, domain, did, rkey, verified FROM custom_domains ··· 57 133 `; 58 134 const data = result[0] || null; 59 135 136 + // Cache the result 137 + customDomainCache.set(key, { value: data, timestamp: Date.now() }); 138 + 60 139 return data; 61 140 } 62 141 63 142 export async function upsertSite(did: string, rkey: string, displayName?: string) { 143 + // Skip database writes in cache-only mode 144 + if (cacheOnlyMode) { 145 + console.log('[DB] Skipping upsertSite (cache-only mode)', { did, rkey }); 146 + return; 147 + } 148 + 64 149 try { 65 150 // Only set display_name if provided (not undefined/null/empty) 66 151 const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null; ··· 78 163 `; 79 164 } catch (err) { 80 165 console.error('Failed to upsert site', err); 166 + } 167 + } 168 + 169 + export interface SiteRecord { 170 + did: string; 171 + rkey: string; 172 + display_name?: string; 173 + } 174 + 175 + export async function getAllSites(): Promise<SiteRecord[]> { 176 + try { 177 + const result = await sql<SiteRecord[]>` 178 + SELECT did, rkey, display_name FROM sites 179 + ORDER BY created_at DESC 180 + `; 181 + return result; 182 + } catch (err) { 183 + console.error('Failed to get all sites', err); 184 + return []; 81 185 } 82 186 } 83 187
+268 -219
hosting-service/src/lib/firehose.ts
··· 1 - import { existsSync, rmSync } from 'fs'; 2 - import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils'; 3 - import { upsertSite, tryAcquireLock, releaseLock } from './db'; 4 - import { safeFetch } from './safe-fetch'; 5 - import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'; 6 - import { Firehose } from '@atproto/sync'; 7 - import { IdResolver } from '@atproto/identity'; 1 + import { existsSync, rmSync } from 'fs' 2 + import { 3 + getPdsForDid, 4 + downloadAndCacheSite, 5 + extractBlobCid, 6 + fetchSiteRecord 7 + } from './utils' 8 + import { upsertSite, tryAcquireLock, releaseLock } from './db' 9 + import { safeFetch } from './safe-fetch' 10 + import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs' 11 + import { Firehose } from '@atproto/sync' 12 + import { IdResolver } from '@atproto/identity' 13 + import { invalidateSiteCache } from './cache' 8 14 9 - const CACHE_DIR = './cache/sites'; 15 + const CACHE_DIR = './cache/sites' 10 16 11 17 export class FirehoseWorker { 12 - private firehose: Firehose | null = null; 13 - private idResolver: IdResolver; 14 - private isShuttingDown = false; 15 - private lastEventTime = Date.now(); 18 + private firehose: Firehose | null = null 19 + private idResolver: IdResolver 20 + private isShuttingDown = false 21 + private lastEventTime = Date.now() 16 22 17 - constructor( 18 - private logger?: (msg: string, data?: Record<string, unknown>) => void, 19 - ) { 20 - this.idResolver = new IdResolver(); 21 - } 23 + constructor( 24 + private logger?: (msg: string, data?: Record<string, unknown>) => void 25 + ) { 26 + this.idResolver = new IdResolver() 27 + } 22 28 23 - private log(msg: string, data?: Record<string, unknown>) { 24 - const log = this.logger || console.log; 25 - log(`[FirehoseWorker] ${msg}`, data || {}); 26 - } 29 + private log(msg: string, data?: Record<string, unknown>) { 30 + const log = this.logger || console.log 31 + log(`[FirehoseWorker] ${msg}`, data || {}) 32 + } 27 33 28 - start() { 29 - this.log('Starting firehose worker'); 30 - this.connect(); 31 - } 34 + start() { 35 + this.log('Starting firehose worker') 36 + this.connect() 37 + } 32 38 33 - stop() { 34 - this.log('Stopping firehose worker'); 35 - this.isShuttingDown = true; 39 + stop() { 40 + this.log('Stopping firehose worker') 41 + this.isShuttingDown = true 36 42 37 - if (this.firehose) { 38 - this.firehose.destroy(); 39 - this.firehose = null; 40 - } 41 - } 43 + if (this.firehose) { 44 + this.firehose.destroy() 45 + this.firehose = null 46 + } 47 + } 42 48 43 - private connect() { 44 - if (this.isShuttingDown) return; 49 + private connect() { 50 + if (this.isShuttingDown) return 45 51 46 - this.log('Connecting to AT Protocol firehose'); 52 + this.log('Connecting to AT Protocol firehose') 47 53 48 - this.firehose = new Firehose({ 49 - idResolver: this.idResolver, 50 - service: 'wss://bsky.network', 51 - filterCollections: ['place.wisp.fs'], 52 - handleEvent: async (evt: any) => { 53 - this.lastEventTime = Date.now(); 54 + this.firehose = new Firehose({ 55 + idResolver: this.idResolver, 56 + service: 'wss://bsky.network', 57 + filterCollections: ['place.wisp.fs'], 58 + handleEvent: async (evt: any) => { 59 + this.lastEventTime = Date.now() 54 60 55 - // Watch for write events 56 - if (evt.event === 'create' || evt.event === 'update') { 57 - const record = evt.record; 61 + // Watch for write events 62 + if (evt.event === 'create' || evt.event === 'update') { 63 + const record = evt.record 58 64 59 - // If the write is a valid place.wisp.fs record 60 - if ( 61 - evt.collection === 'place.wisp.fs' && 62 - isRecord(record) && 63 - validateRecord(record).success 64 - ) { 65 - this.log('Received place.wisp.fs event', { 66 - did: evt.did, 67 - event: evt.event, 68 - rkey: evt.rkey, 69 - }); 65 + // If the write is a valid place.wisp.fs record 66 + if ( 67 + evt.collection === 'place.wisp.fs' && 68 + isRecord(record) && 69 + validateRecord(record).success 70 + ) { 71 + this.log('Received place.wisp.fs event', { 72 + did: evt.did, 73 + event: evt.event, 74 + rkey: evt.rkey 75 + }) 70 76 71 - try { 72 - await this.handleCreateOrUpdate(evt.did, evt.rkey, record, evt.cid?.toString()); 73 - } catch (err) { 74 - this.log('Error handling event', { 75 - did: evt.did, 76 - event: evt.event, 77 - rkey: evt.rkey, 78 - error: err instanceof Error ? err.message : String(err), 79 - }); 80 - } 81 - } 82 - } else if (evt.event === 'delete' && evt.collection === 'place.wisp.fs') { 83 - this.log('Received delete event', { 84 - did: evt.did, 85 - rkey: evt.rkey, 86 - }); 77 + try { 78 + await this.handleCreateOrUpdate( 79 + evt.did, 80 + evt.rkey, 81 + record, 82 + evt.cid?.toString() 83 + ) 84 + } catch (err) { 85 + this.log('Error handling event', { 86 + did: evt.did, 87 + event: evt.event, 88 + rkey: evt.rkey, 89 + error: 90 + err instanceof Error 91 + ? err.message 92 + : String(err) 93 + }) 94 + } 95 + } 96 + } else if ( 97 + evt.event === 'delete' && 98 + evt.collection === 'place.wisp.fs' 99 + ) { 100 + this.log('Received delete event', { 101 + did: evt.did, 102 + rkey: evt.rkey 103 + }) 87 104 88 - try { 89 - await this.handleDelete(evt.did, evt.rkey); 90 - } catch (err) { 91 - this.log('Error handling delete', { 92 - did: evt.did, 93 - rkey: evt.rkey, 94 - error: err instanceof Error ? err.message : String(err), 95 - }); 96 - } 97 - } 98 - }, 99 - onError: (err: any) => { 100 - this.log('Firehose error', { 101 - error: err instanceof Error ? err.message : String(err), 102 - stack: err instanceof Error ? err.stack : undefined, 103 - fullError: err, 104 - }); 105 - console.error('Full firehose error:', err); 106 - }, 107 - }); 105 + try { 106 + await this.handleDelete(evt.did, evt.rkey) 107 + } catch (err) { 108 + this.log('Error handling delete', { 109 + did: evt.did, 110 + rkey: evt.rkey, 111 + error: 112 + err instanceof Error ? err.message : String(err) 113 + }) 114 + } 115 + } 116 + }, 117 + onError: (err: any) => { 118 + this.log('Firehose error', { 119 + error: err instanceof Error ? err.message : String(err), 120 + stack: err instanceof Error ? err.stack : undefined, 121 + fullError: err 122 + }) 123 + console.error('Full firehose error:', err) 124 + } 125 + }) 126 + 127 + this.firehose.start() 128 + this.log('Firehose started') 129 + } 130 + 131 + private async handleCreateOrUpdate( 132 + did: string, 133 + site: string, 134 + record: any, 135 + eventCid?: string 136 + ) { 137 + this.log('Processing create/update', { did, site }) 108 138 109 - this.firehose.start(); 110 - this.log('Firehose started'); 111 - } 139 + // Record is already validated in handleEvent 140 + const fsRecord = record 112 141 113 - private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) { 114 - this.log('Processing create/update', { did, site }); 142 + const pdsEndpoint = await getPdsForDid(did) 143 + if (!pdsEndpoint) { 144 + this.log('Could not resolve PDS for DID', { did }) 145 + return 146 + } 115 147 116 - // Record is already validated in handleEvent 117 - const fsRecord = record; 148 + this.log('Resolved PDS', { did, pdsEndpoint }) 118 149 119 - const pdsEndpoint = await getPdsForDid(did); 120 - if (!pdsEndpoint) { 121 - this.log('Could not resolve PDS for DID', { did }); 122 - return; 123 - } 150 + // Verify record exists on PDS and fetch its CID 151 + let verifiedCid: string 152 + try { 153 + const result = await fetchSiteRecord(did, site) 124 154 125 - this.log('Resolved PDS', { did, pdsEndpoint }); 155 + if (!result) { 156 + this.log('Record not found on PDS, skipping cache', { 157 + did, 158 + site 159 + }) 160 + return 161 + } 126 162 127 - // Verify record exists on PDS and fetch its CID 128 - let verifiedCid: string; 129 - try { 130 - const result = await fetchSiteRecord(did, site); 163 + verifiedCid = result.cid 131 164 132 - if (!result) { 133 - this.log('Record not found on PDS, skipping cache', { did, site }); 134 - return; 135 - } 165 + // Verify event CID matches PDS CID (prevent cache poisoning) 166 + if (eventCid && eventCid !== verifiedCid) { 167 + this.log('CID mismatch detected - potential spoofed event', { 168 + did, 169 + site, 170 + eventCid, 171 + verifiedCid 172 + }) 173 + return 174 + } 136 175 137 - verifiedCid = result.cid; 176 + this.log('Record verified on PDS', { did, site, cid: verifiedCid }) 177 + } catch (err) { 178 + this.log('Failed to verify record on PDS', { 179 + did, 180 + site, 181 + error: err instanceof Error ? err.message : String(err) 182 + }) 183 + return 184 + } 138 185 139 - // Verify event CID matches PDS CID (prevent cache poisoning) 140 - if (eventCid && eventCid !== verifiedCid) { 141 - this.log('CID mismatch detected - potential spoofed event', { 142 - did, 143 - site, 144 - eventCid, 145 - verifiedCid 146 - }); 147 - return; 148 - } 186 + // Invalidate in-memory caches before updating 187 + invalidateSiteCache(did, site) 149 188 150 - this.log('Record verified on PDS', { did, site, cid: verifiedCid }); 151 - } catch (err) { 152 - this.log('Failed to verify record on PDS', { 153 - did, 154 - site, 155 - error: err instanceof Error ? err.message : String(err), 156 - }); 157 - return; 158 - } 189 + // Cache the record with verified CID (uses atomic swap internally) 190 + // All instances cache locally for edge serving 191 + await downloadAndCacheSite( 192 + did, 193 + site, 194 + fsRecord, 195 + pdsEndpoint, 196 + verifiedCid 197 + ) 159 198 160 - // Cache the record with verified CID (uses atomic swap internally) 161 - // All instances cache locally for edge serving 162 - await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid); 199 + // Acquire distributed lock only for database write to prevent duplicate writes 200 + // Note: upsertSite will check cache-only mode internally and skip if needed 201 + const lockKey = `db:upsert:${did}:${site}` 202 + const lockAcquired = await tryAcquireLock(lockKey) 163 203 164 - // Acquire distributed lock only for database write to prevent duplicate writes 165 - const lockKey = `db:upsert:${did}:${site}`; 166 - const lockAcquired = await tryAcquireLock(lockKey); 204 + if (!lockAcquired) { 205 + this.log('Another instance is writing to DB, skipping upsert', { 206 + did, 207 + site 208 + }) 209 + this.log('Successfully processed create/update (cached locally)', { 210 + did, 211 + site 212 + }) 213 + return 214 + } 167 215 168 - if (!lockAcquired) { 169 - this.log('Another instance is writing to DB, skipping upsert', { did, site }); 170 - this.log('Successfully processed create/update (cached locally)', { did, site }); 171 - return; 172 - } 216 + try { 217 + // Upsert site to database (only one instance does this) 218 + // In cache-only mode, this will be a no-op 219 + await upsertSite(did, site, fsRecord.site) 220 + this.log( 221 + 'Successfully processed create/update (cached + DB updated)', 222 + { did, site } 223 + ) 224 + } finally { 225 + // Always release lock, even if DB write fails 226 + await releaseLock(lockKey) 227 + } 228 + } 173 229 174 - try { 175 - // Upsert site to database (only one instance does this) 176 - await upsertSite(did, site, fsRecord.site); 177 - this.log('Successfully processed create/update (cached + DB updated)', { did, site }); 178 - } finally { 179 - // Always release lock, even if DB write fails 180 - await releaseLock(lockKey); 181 - } 182 - } 230 + private async handleDelete(did: string, site: string) { 231 + this.log('Processing delete', { did, site }) 183 232 184 - private async handleDelete(did: string, site: string) { 185 - this.log('Processing delete', { did, site }); 233 + // All instances should delete their local cache (no lock needed) 234 + const pdsEndpoint = await getPdsForDid(did) 235 + if (!pdsEndpoint) { 236 + this.log('Could not resolve PDS for DID', { did }) 237 + return 238 + } 186 239 187 - // All instances should delete their local cache (no lock needed) 188 - const pdsEndpoint = await getPdsForDid(did); 189 - if (!pdsEndpoint) { 190 - this.log('Could not resolve PDS for DID', { did }); 191 - return; 192 - } 240 + // Verify record is actually deleted from PDS 241 + try { 242 + const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}` 243 + const recordRes = await safeFetch(recordUrl) 193 244 194 - // Verify record is actually deleted from PDS 195 - try { 196 - const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`; 197 - const recordRes = await safeFetch(recordUrl); 245 + if (recordRes.ok) { 246 + this.log('Record still exists on PDS, not deleting cache', { 247 + did, 248 + site 249 + }) 250 + return 251 + } 198 252 199 - if (recordRes.ok) { 200 - this.log('Record still exists on PDS, not deleting cache', { 201 - did, 202 - site, 203 - }); 204 - return; 205 - } 253 + this.log('Verified record is deleted from PDS', { 254 + did, 255 + site, 256 + status: recordRes.status 257 + }) 258 + } catch (err) { 259 + this.log('Error verifying deletion on PDS', { 260 + did, 261 + site, 262 + error: err instanceof Error ? err.message : String(err) 263 + }) 264 + } 206 265 207 - this.log('Verified record is deleted from PDS', { 208 - did, 209 - site, 210 - status: recordRes.status, 211 - }); 212 - } catch (err) { 213 - this.log('Error verifying deletion on PDS', { 214 - did, 215 - site, 216 - error: err instanceof Error ? err.message : String(err), 217 - }); 218 - } 266 + // Invalidate in-memory caches 267 + invalidateSiteCache(did, site) 219 268 220 - // Delete cache 221 - this.deleteCache(did, site); 269 + // Delete disk cache 270 + this.deleteCache(did, site) 222 271 223 - this.log('Successfully processed delete', { did, site }); 224 - } 272 + this.log('Successfully processed delete', { did, site }) 273 + } 225 274 226 - private deleteCache(did: string, site: string) { 227 - const cacheDir = `${CACHE_DIR}/${did}/${site}`; 275 + private deleteCache(did: string, site: string) { 276 + const cacheDir = `${CACHE_DIR}/${did}/${site}` 228 277 229 - if (!existsSync(cacheDir)) { 230 - this.log('Cache directory does not exist, nothing to delete', { 231 - did, 232 - site, 233 - }); 234 - return; 235 - } 278 + if (!existsSync(cacheDir)) { 279 + this.log('Cache directory does not exist, nothing to delete', { 280 + did, 281 + site 282 + }) 283 + return 284 + } 236 285 237 - try { 238 - rmSync(cacheDir, { recursive: true, force: true }); 239 - this.log('Cache deleted', { did, site, path: cacheDir }); 240 - } catch (err) { 241 - this.log('Failed to delete cache', { 242 - did, 243 - site, 244 - path: cacheDir, 245 - error: err instanceof Error ? err.message : String(err), 246 - }); 247 - } 248 - } 286 + try { 287 + rmSync(cacheDir, { recursive: true, force: true }) 288 + this.log('Cache deleted', { did, site, path: cacheDir }) 289 + } catch (err) { 290 + this.log('Failed to delete cache', { 291 + did, 292 + site, 293 + path: cacheDir, 294 + error: err instanceof Error ? err.message : String(err) 295 + }) 296 + } 297 + } 249 298 250 - getHealth() { 251 - const isConnected = this.firehose !== null; 252 - const timeSinceLastEvent = Date.now() - this.lastEventTime; 299 + getHealth() { 300 + const isConnected = this.firehose !== null 301 + const timeSinceLastEvent = Date.now() - this.lastEventTime 253 302 254 - return { 255 - connected: isConnected, 256 - lastEventTime: this.lastEventTime, 257 - timeSinceLastEvent, 258 - healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes 259 - }; 260 - } 303 + return { 304 + connected: isConnected, 305 + lastEventTime: this.lastEventTime, 306 + timeSinceLastEvent, 307 + healthy: isConnected && timeSinceLastEvent < 300000 // 5 minutes 308 + } 309 + } 261 310 }
+457
hosting-service/src/lib/html-rewriter.test.ts
··· 1 + import { describe, test, expect } from 'bun:test' 2 + import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter' 3 + 4 + describe('rewriteHtmlPaths', () => { 5 + const basePath = '/identifier/site/' 6 + 7 + describe('absolute paths', () => { 8 + test('rewrites absolute paths with leading slash', () => { 9 + const html = '<img src="/image.png">' 10 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 11 + expect(result).toBe('<img src="/identifier/site/image.png">') 12 + }) 13 + 14 + test('rewrites nested absolute paths', () => { 15 + const html = '<link href="/css/style.css">' 16 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 17 + expect(result).toBe('<link href="/identifier/site/css/style.css">') 18 + }) 19 + }) 20 + 21 + describe('relative paths from root document', () => { 22 + test('rewrites relative paths with ./ prefix', () => { 23 + const html = '<img src="./image.png">' 24 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 25 + expect(result).toBe('<img src="/identifier/site/image.png">') 26 + }) 27 + 28 + test('rewrites relative paths without prefix', () => { 29 + const html = '<img src="image.png">' 30 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 31 + expect(result).toBe('<img src="/identifier/site/image.png">') 32 + }) 33 + 34 + test('rewrites relative paths with ../ (should stay at root)', () => { 35 + const html = '<img src="../image.png">' 36 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 37 + expect(result).toBe('<img src="/identifier/site/image.png">') 38 + }) 39 + }) 40 + 41 + describe('relative paths from nested documents', () => { 42 + test('rewrites relative path from nested document', () => { 43 + const html = '<img src="./photo.jpg">' 44 + const result = rewriteHtmlPaths( 45 + html, 46 + basePath, 47 + 'folder1/folder2/index.html' 48 + ) 49 + expect(result).toBe( 50 + '<img src="/identifier/site/folder1/folder2/photo.jpg">' 51 + ) 52 + }) 53 + 54 + test('rewrites plain filename from nested document', () => { 55 + const html = '<script src="app.js"></script>' 56 + const result = rewriteHtmlPaths( 57 + html, 58 + basePath, 59 + 'folder1/folder2/index.html' 60 + ) 61 + expect(result).toBe( 62 + '<script src="/identifier/site/folder1/folder2/app.js"></script>' 63 + ) 64 + }) 65 + 66 + test('rewrites ../ to go up one level', () => { 67 + const html = '<img src="../image.png">' 68 + const result = rewriteHtmlPaths( 69 + html, 70 + basePath, 71 + 'folder1/folder2/folder3/index.html' 72 + ) 73 + expect(result).toBe( 74 + '<img src="/identifier/site/folder1/folder2/image.png">' 75 + ) 76 + }) 77 + 78 + test('rewrites multiple ../ to go up multiple levels', () => { 79 + const html = '<link href="../../css/style.css">' 80 + const result = rewriteHtmlPaths( 81 + html, 82 + basePath, 83 + 'folder1/folder2/folder3/index.html' 84 + ) 85 + expect(result).toBe( 86 + '<link href="/identifier/site/folder1/css/style.css">' 87 + ) 88 + }) 89 + 90 + test('rewrites ../ with additional path segments', () => { 91 + const html = '<img src="../assets/logo.png">' 92 + const result = rewriteHtmlPaths( 93 + html, 94 + basePath, 95 + 'pages/about/index.html' 96 + ) 97 + expect(result).toBe( 98 + '<img src="/identifier/site/pages/assets/logo.png">' 99 + ) 100 + }) 101 + 102 + test('handles complex nested relative paths', () => { 103 + const html = '<script src="../../lib/vendor/jquery.js"></script>' 104 + const result = rewriteHtmlPaths( 105 + html, 106 + basePath, 107 + 'pages/blog/post/index.html' 108 + ) 109 + expect(result).toBe( 110 + '<script src="/identifier/site/pages/lib/vendor/jquery.js"></script>' 111 + ) 112 + }) 113 + 114 + test('handles ../ going past root (stays at root)', () => { 115 + const html = '<img src="../../../image.png">' 116 + const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html') 117 + expect(result).toBe('<img src="/identifier/site/image.png">') 118 + }) 119 + }) 120 + 121 + describe('external URLs and special schemes', () => { 122 + test('does not rewrite http URLs', () => { 123 + const html = '<img src="http://example.com/image.png">' 124 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 125 + expect(result).toBe('<img src="http://example.com/image.png">') 126 + }) 127 + 128 + test('does not rewrite https URLs', () => { 129 + const html = '<link href="https://cdn.example.com/style.css">' 130 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 131 + expect(result).toBe( 132 + '<link href="https://cdn.example.com/style.css">' 133 + ) 134 + }) 135 + 136 + test('does not rewrite protocol-relative URLs', () => { 137 + const html = '<script src="//cdn.example.com/script.js"></script>' 138 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 139 + expect(result).toBe( 140 + '<script src="//cdn.example.com/script.js"></script>' 141 + ) 142 + }) 143 + 144 + test('does not rewrite data URIs', () => { 145 + const html = 146 + '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">' 147 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 148 + expect(result).toBe( 149 + '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">' 150 + ) 151 + }) 152 + 153 + test('does not rewrite mailto links', () => { 154 + const html = '<a href="mailto:test@example.com">Email</a>' 155 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 156 + expect(result).toBe('<a href="mailto:test@example.com">Email</a>') 157 + }) 158 + 159 + test('does not rewrite tel links', () => { 160 + const html = '<a href="tel:+1234567890">Call</a>' 161 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 162 + expect(result).toBe('<a href="tel:+1234567890">Call</a>') 163 + }) 164 + }) 165 + 166 + describe('different HTML attributes', () => { 167 + test('rewrites src attribute', () => { 168 + const html = '<img src="/image.png">' 169 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 170 + expect(result).toBe('<img src="/identifier/site/image.png">') 171 + }) 172 + 173 + test('rewrites href attribute', () => { 174 + const html = '<a href="/page.html">Link</a>' 175 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 176 + expect(result).toBe('<a href="/identifier/site/page.html">Link</a>') 177 + }) 178 + 179 + test('rewrites action attribute', () => { 180 + const html = '<form action="/submit"></form>' 181 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 182 + expect(result).toBe('<form action="/identifier/site/submit"></form>') 183 + }) 184 + 185 + test('rewrites data attribute', () => { 186 + const html = '<object data="/document.pdf"></object>' 187 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 188 + expect(result).toBe( 189 + '<object data="/identifier/site/document.pdf"></object>' 190 + ) 191 + }) 192 + 193 + test('rewrites poster attribute', () => { 194 + const html = '<video poster="/thumbnail.jpg"></video>' 195 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 196 + expect(result).toBe( 197 + '<video poster="/identifier/site/thumbnail.jpg"></video>' 198 + ) 199 + }) 200 + 201 + test('rewrites srcset attribute with single URL', () => { 202 + const html = '<img srcset="/image.png 1x">' 203 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 204 + expect(result).toBe( 205 + '<img srcset="/identifier/site/image.png 1x">' 206 + ) 207 + }) 208 + 209 + test('rewrites srcset attribute with multiple URLs', () => { 210 + const html = '<img srcset="/image-1x.png 1x, /image-2x.png 2x">' 211 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 212 + expect(result).toBe( 213 + '<img srcset="/identifier/site/image-1x.png 1x, /identifier/site/image-2x.png 2x">' 214 + ) 215 + }) 216 + 217 + test('rewrites srcset with width descriptors', () => { 218 + const html = '<img srcset="/small.jpg 320w, /large.jpg 1024w">' 219 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 220 + expect(result).toBe( 221 + '<img srcset="/identifier/site/small.jpg 320w, /identifier/site/large.jpg 1024w">' 222 + ) 223 + }) 224 + 225 + test('rewrites srcset with relative paths from nested document', () => { 226 + const html = '<img srcset="../img1.png 1x, ../img2.png 2x">' 227 + const result = rewriteHtmlPaths( 228 + html, 229 + basePath, 230 + 'folder1/folder2/index.html' 231 + ) 232 + expect(result).toBe( 233 + '<img srcset="/identifier/site/folder1/img1.png 1x, /identifier/site/folder1/img2.png 2x">' 234 + ) 235 + }) 236 + }) 237 + 238 + describe('quote handling', () => { 239 + test('handles double quotes', () => { 240 + const html = '<img src="/image.png">' 241 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 242 + expect(result).toBe('<img src="/identifier/site/image.png">') 243 + }) 244 + 245 + test('handles single quotes', () => { 246 + const html = "<img src='/image.png'>" 247 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 248 + expect(result).toBe("<img src='/identifier/site/image.png'>") 249 + }) 250 + 251 + test('handles mixed quotes in same document', () => { 252 + const html = '<img src="/img1.png"><link href=\'/style.css\'>' 253 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 254 + expect(result).toBe( 255 + '<img src="/identifier/site/img1.png"><link href=\'/identifier/site/style.css\'>' 256 + ) 257 + }) 258 + }) 259 + 260 + describe('multiple rewrites in same document', () => { 261 + test('rewrites multiple attributes in complex HTML', () => { 262 + const html = ` 263 + <!DOCTYPE html> 264 + <html> 265 + <head> 266 + <link href="/css/style.css" rel="stylesheet"> 267 + <script src="/js/app.js"></script> 268 + </head> 269 + <body> 270 + <img src="/images/logo.png" alt="Logo"> 271 + <a href="/about.html">About</a> 272 + <form action="/submit"> 273 + <button type="submit">Submit</button> 274 + </form> 275 + </body> 276 + </html> 277 + ` 278 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 279 + expect(result).toContain('href="/identifier/site/css/style.css"') 280 + expect(result).toContain('src="/identifier/site/js/app.js"') 281 + expect(result).toContain('src="/identifier/site/images/logo.png"') 282 + expect(result).toContain('href="/identifier/site/about.html"') 283 + expect(result).toContain('action="/identifier/site/submit"') 284 + }) 285 + 286 + test('handles mix of relative and absolute paths', () => { 287 + const html = ` 288 + <img src="/abs/image.png"> 289 + <img src="./rel/image.png"> 290 + <img src="../parent/image.png"> 291 + <img src="https://external.com/image.png"> 292 + ` 293 + const result = rewriteHtmlPaths( 294 + html, 295 + basePath, 296 + 'folder1/folder2/page.html' 297 + ) 298 + expect(result).toContain('src="/identifier/site/abs/image.png"') 299 + expect(result).toContain( 300 + 'src="/identifier/site/folder1/folder2/rel/image.png"' 301 + ) 302 + expect(result).toContain( 303 + 'src="/identifier/site/folder1/parent/image.png"' 304 + ) 305 + expect(result).toContain('src="https://external.com/image.png"') 306 + }) 307 + }) 308 + 309 + describe('edge cases', () => { 310 + test('handles empty src attribute', () => { 311 + const html = '<img src="">' 312 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 313 + expect(result).toBe('<img src="">') 314 + }) 315 + 316 + test('handles basePath without trailing slash', () => { 317 + const html = '<img src="/image.png">' 318 + const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html') 319 + expect(result).toBe('<img src="/identifier/site/image.png">') 320 + }) 321 + 322 + test('handles basePath with trailing slash', () => { 323 + const html = '<img src="/image.png">' 324 + const result = rewriteHtmlPaths( 325 + html, 326 + '/identifier/site/', 327 + 'index.html' 328 + ) 329 + expect(result).toBe('<img src="/identifier/site/image.png">') 330 + }) 331 + 332 + test('handles whitespace around equals sign', () => { 333 + const html = '<img src = "/image.png">' 334 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 335 + expect(result).toBe('<img src="/identifier/site/image.png">') 336 + }) 337 + 338 + test('preserves query strings in URLs', () => { 339 + const html = '<img src="/image.png?v=123">' 340 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 341 + expect(result).toBe('<img src="/identifier/site/image.png?v=123">') 342 + }) 343 + 344 + test('preserves hash fragments in URLs', () => { 345 + const html = '<a href="/page.html#section">Link</a>' 346 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 347 + expect(result).toBe( 348 + '<a href="/identifier/site/page.html#section">Link</a>' 349 + ) 350 + }) 351 + 352 + test('handles paths with special characters', () => { 353 + const html = '<img src="/folder-name/file_name.png">' 354 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 355 + expect(result).toBe( 356 + '<img src="/identifier/site/folder-name/file_name.png">' 357 + ) 358 + }) 359 + }) 360 + 361 + describe('real-world scenario', () => { 362 + test('handles the example from the bug report', () => { 363 + // HTML file at: /folder1/folder2/folder3/index.html 364 + // Image at: /folder1/folder2/img.png 365 + // Reference: src="../img.png" 366 + const html = '<img src="../img.png">' 367 + const result = rewriteHtmlPaths( 368 + html, 369 + basePath, 370 + 'folder1/folder2/folder3/index.html' 371 + ) 372 + expect(result).toBe( 373 + '<img src="/identifier/site/folder1/folder2/img.png">' 374 + ) 375 + }) 376 + 377 + test('handles deeply nested static site structure', () => { 378 + // A typical static site with nested pages and shared assets 379 + const html = ` 380 + <!DOCTYPE html> 381 + <html> 382 + <head> 383 + <link href="../../css/style.css" rel="stylesheet"> 384 + <link href="../../css/theme.css" rel="stylesheet"> 385 + <script src="../../js/main.js"></script> 386 + </head> 387 + <body> 388 + <img src="../../images/logo.png" alt="Logo"> 389 + <img src="./post-image.jpg" alt="Post"> 390 + <a href="../index.html">Back to Blog</a> 391 + <a href="../../index.html">Home</a> 392 + </body> 393 + </html> 394 + ` 395 + const result = rewriteHtmlPaths( 396 + html, 397 + basePath, 398 + 'blog/posts/my-post.html' 399 + ) 400 + 401 + // Assets two levels up 402 + expect(result).toContain('href="/identifier/site/css/style.css"') 403 + expect(result).toContain('href="/identifier/site/css/theme.css"') 404 + expect(result).toContain('src="/identifier/site/js/main.js"') 405 + expect(result).toContain('src="/identifier/site/images/logo.png"') 406 + 407 + // Same directory 408 + expect(result).toContain( 409 + 'src="/identifier/site/blog/posts/post-image.jpg"' 410 + ) 411 + 412 + // One level up 413 + expect(result).toContain('href="/identifier/site/blog/index.html"') 414 + 415 + // Two levels up 416 + expect(result).toContain('href="/identifier/site/index.html"') 417 + }) 418 + }) 419 + }) 420 + 421 + describe('isHtmlContent', () => { 422 + test('identifies HTML by content type', () => { 423 + expect(isHtmlContent('file.txt', 'text/html')).toBe(true) 424 + expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe( 425 + true 426 + ) 427 + }) 428 + 429 + test('identifies HTML by .html extension', () => { 430 + expect(isHtmlContent('index.html')).toBe(true) 431 + expect(isHtmlContent('page.html', undefined)).toBe(true) 432 + expect(isHtmlContent('/path/to/file.html')).toBe(true) 433 + }) 434 + 435 + test('identifies HTML by .htm extension', () => { 436 + expect(isHtmlContent('index.htm')).toBe(true) 437 + expect(isHtmlContent('page.htm', undefined)).toBe(true) 438 + }) 439 + 440 + test('handles case-insensitive extensions', () => { 441 + expect(isHtmlContent('INDEX.HTML')).toBe(true) 442 + expect(isHtmlContent('page.HTM')).toBe(true) 443 + expect(isHtmlContent('File.HtMl')).toBe(true) 444 + }) 445 + 446 + test('returns false for non-HTML files', () => { 447 + expect(isHtmlContent('script.js')).toBe(false) 448 + expect(isHtmlContent('style.css')).toBe(false) 449 + expect(isHtmlContent('image.png')).toBe(false) 450 + expect(isHtmlContent('data.json')).toBe(false) 451 + }) 452 + 453 + test('returns false for files with no extension', () => { 454 + expect(isHtmlContent('README')).toBe(false) 455 + expect(isHtmlContent('Makefile')).toBe(false) 456 + }) 457 + })
+178 -99
hosting-service/src/lib/html-rewriter.ts
··· 4 4 */ 5 5 6 6 const REWRITABLE_ATTRIBUTES = [ 7 - 'src', 8 - 'href', 9 - 'action', 10 - 'data', 11 - 'poster', 12 - 'srcset', 13 - ] as const; 7 + 'src', 8 + 'href', 9 + 'action', 10 + 'data', 11 + 'poster', 12 + 'srcset' 13 + ] as const 14 14 15 15 /** 16 16 * Check if a path should be rewritten 17 17 */ 18 18 function shouldRewritePath(path: string): boolean { 19 - // Don't rewrite empty paths 20 - if (!path) return false; 19 + // Don't rewrite empty paths 20 + if (!path) return false 21 21 22 - // Don't rewrite external URLs (http://, https://, //) 23 - if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) { 24 - return false; 25 - } 22 + // Don't rewrite external URLs (http://, https://, //) 23 + if ( 24 + path.startsWith('http://') || 25 + path.startsWith('https://') || 26 + path.startsWith('//') 27 + ) { 28 + return false 29 + } 26 30 27 - // Don't rewrite data URIs or other schemes (except file paths) 28 - if (path.includes(':') && !path.startsWith('./') && !path.startsWith('../')) { 29 - return false; 30 - } 31 + // Don't rewrite data URIs or other schemes (except file paths) 32 + if ( 33 + path.includes(':') && 34 + !path.startsWith('./') && 35 + !path.startsWith('../') 36 + ) { 37 + return false 38 + } 31 39 32 - // Don't rewrite pure anchors or paths that start with /# 33 - if (path.startsWith('#') || path.startsWith('/#')) return false; 40 + // Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames) 41 + return true 42 + } 43 + 44 + /** 45 + * Normalize a path by resolving . and .. segments 46 + */ 47 + function normalizePath(path: string): string { 48 + const parts = path.split('/') 49 + const result: string[] = [] 50 + 51 + for (const part of parts) { 52 + if (part === '.' || part === '') { 53 + // Skip current directory and empty parts (but keep leading empty for absolute paths) 54 + if (part === '' && result.length === 0) { 55 + result.push(part) 56 + } 57 + continue 58 + } 59 + if (part === '..') { 60 + // Go up one directory (but not past root) 61 + if (result.length > 0 && result[result.length - 1] !== '..') { 62 + result.pop() 63 + } 64 + continue 65 + } 66 + result.push(part) 67 + } 34 68 35 - // Don't rewrite relative paths (./ or ../) 36 - if (path.startsWith('./') || path.startsWith('../')) return false; 69 + return result.join('/') 70 + } 37 71 38 - // Rewrite absolute paths (/) 39 - return true; 72 + /** 73 + * Get the directory path from a file path 74 + * e.g., "folder1/folder2/file.html" -> "folder1/folder2/" 75 + */ 76 + function getDirectory(filepath: string): string { 77 + const lastSlash = filepath.lastIndexOf('/') 78 + if (lastSlash === -1) { 79 + return '' 80 + } 81 + return filepath.substring(0, lastSlash + 1) 40 82 } 41 83 42 84 /** 43 85 * Rewrite a single path 44 86 */ 45 - function rewritePath(path: string, basePath: string): string { 46 - if (!shouldRewritePath(path)) { 47 - return path; 48 - } 87 + function rewritePath( 88 + path: string, 89 + basePath: string, 90 + documentPath: string 91 + ): string { 92 + if (!shouldRewritePath(path)) { 93 + return path 94 + } 95 + 96 + // Handle absolute paths: /file.js -> /base/file.js 97 + if (path.startsWith('/')) { 98 + return basePath + path.slice(1) 99 + } 100 + 101 + // Handle relative paths by resolving against document directory 102 + const documentDir = getDirectory(documentPath) 103 + let resolvedPath: string 49 104 50 - // Handle absolute paths: /file.js -> /base/file.js 51 - if (path.startsWith('/')) { 52 - return basePath + path.slice(1); 53 - } 105 + if (path.startsWith('./')) { 106 + // ./file.js relative to current directory 107 + resolvedPath = documentDir + path.slice(2) 108 + } else if (path.startsWith('../')) { 109 + // ../file.js relative to parent directory 110 + resolvedPath = documentDir + path 111 + } else { 112 + // file.js (no prefix) - treat as relative to current directory 113 + resolvedPath = documentDir + path 114 + } 54 115 55 - // At this point, only plain filenames without ./ or ../ prefix should reach here 56 - // But since we're filtering those in shouldRewritePath, this shouldn't happen 57 - return path; 116 + // Normalize the path to resolve .. and . 117 + resolvedPath = normalizePath(resolvedPath) 118 + 119 + return basePath + resolvedPath 58 120 } 59 121 60 122 /** 61 123 * Rewrite srcset attribute (can contain multiple URLs) 62 124 * Format: "url1 1x, url2 2x" or "url1 100w, url2 200w" 63 125 */ 64 - function rewriteSrcset(srcset: string, basePath: string): string { 65 - return srcset 66 - .split(',') 67 - .map(part => { 68 - const trimmed = part.trim(); 69 - const spaceIndex = trimmed.indexOf(' '); 126 + function rewriteSrcset( 127 + srcset: string, 128 + basePath: string, 129 + documentPath: string 130 + ): string { 131 + return srcset 132 + .split(',') 133 + .map((part) => { 134 + const trimmed = part.trim() 135 + const spaceIndex = trimmed.indexOf(' ') 70 136 71 - if (spaceIndex === -1) { 72 - // No descriptor, just URL 73 - return rewritePath(trimmed, basePath); 74 - } 137 + if (spaceIndex === -1) { 138 + // No descriptor, just URL 139 + return rewritePath(trimmed, basePath, documentPath) 140 + } 75 141 76 - const url = trimmed.substring(0, spaceIndex); 77 - const descriptor = trimmed.substring(spaceIndex); 78 - return rewritePath(url, basePath) + descriptor; 79 - }) 80 - .join(', '); 142 + const url = trimmed.substring(0, spaceIndex) 143 + const descriptor = trimmed.substring(spaceIndex) 144 + return rewritePath(url, basePath, documentPath) + descriptor 145 + }) 146 + .join(', ') 81 147 } 82 148 83 149 /** 84 - * Rewrite absolute paths in HTML content 150 + * Rewrite absolute and relative paths in HTML content 85 151 * Uses simple regex matching for safety (no full HTML parsing) 86 152 */ 87 - export function rewriteHtmlPaths(html: string, basePath: string): string { 88 - // Ensure base path ends with / 89 - const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/'; 153 + export function rewriteHtmlPaths( 154 + html: string, 155 + basePath: string, 156 + documentPath: string 157 + ): string { 158 + // Ensure base path ends with / 159 + const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/' 90 160 91 - let rewritten = html; 161 + let rewritten = html 92 162 93 - // Rewrite each attribute type 94 - // Use more specific patterns to prevent ReDoS attacks 95 - for (const attr of REWRITABLE_ATTRIBUTES) { 96 - if (attr === 'srcset') { 97 - // Special handling for srcset - use possessive quantifiers via atomic grouping simulation 98 - // Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS 99 - const srcsetRegex = new RegExp( 100 - `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 101 - 'gi' 102 - ); 103 - rewritten = rewritten.replace(srcsetRegex, (match, value) => { 104 - const rewrittenValue = rewriteSrcset(value, normalizedBase); 105 - return `${attr}="${rewrittenValue}"`; 106 - }); 107 - } else { 108 - // Regular attributes with quoted values 109 - // Limit whitespace to prevent catastrophic backtracking 110 - const doubleQuoteRegex = new RegExp( 111 - `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 112 - 'gi' 113 - ); 114 - const singleQuoteRegex = new RegExp( 115 - `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`, 116 - 'gi' 117 - ); 163 + // Rewrite each attribute type 164 + // Use more specific patterns to prevent ReDoS attacks 165 + for (const attr of REWRITABLE_ATTRIBUTES) { 166 + if (attr === 'srcset') { 167 + // Special handling for srcset - use possessive quantifiers via atomic grouping simulation 168 + // Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS 169 + const srcsetRegex = new RegExp( 170 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 171 + 'gi' 172 + ) 173 + rewritten = rewritten.replace(srcsetRegex, (match, value) => { 174 + const rewrittenValue = rewriteSrcset( 175 + value, 176 + normalizedBase, 177 + documentPath 178 + ) 179 + return `${attr}="${rewrittenValue}"` 180 + }) 181 + } else { 182 + // Regular attributes with quoted values 183 + // Limit whitespace to prevent catastrophic backtracking 184 + const doubleQuoteRegex = new RegExp( 185 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 186 + 'gi' 187 + ) 188 + const singleQuoteRegex = new RegExp( 189 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`, 190 + 'gi' 191 + ) 118 192 119 - rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => { 120 - const rewrittenValue = rewritePath(value, normalizedBase); 121 - return `${attr}="${rewrittenValue}"`; 122 - }); 193 + rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => { 194 + const rewrittenValue = rewritePath( 195 + value, 196 + normalizedBase, 197 + documentPath 198 + ) 199 + return `${attr}="${rewrittenValue}"` 200 + }) 123 201 124 - rewritten = rewritten.replace(singleQuoteRegex, (match, value) => { 125 - const rewrittenValue = rewritePath(value, normalizedBase); 126 - return `${attr}='${rewrittenValue}'`; 127 - }); 128 - } 129 - } 202 + rewritten = rewritten.replace(singleQuoteRegex, (match, value) => { 203 + const rewrittenValue = rewritePath( 204 + value, 205 + normalizedBase, 206 + documentPath 207 + ) 208 + return `${attr}='${rewrittenValue}'` 209 + }) 210 + } 211 + } 130 212 131 - return rewritten; 213 + return rewritten 132 214 } 133 215 134 216 /** 135 217 * Check if content is HTML based on content or filename 136 218 */ 137 - export function isHtmlContent( 138 - filepath: string, 139 - contentType?: string 140 - ): boolean { 141 - if (contentType && contentType.includes('text/html')) { 142 - return true; 143 - } 219 + export function isHtmlContent(filepath: string, contentType?: string): boolean { 220 + if (contentType && contentType.includes('text/html')) { 221 + return true 222 + } 144 223 145 - const ext = filepath.toLowerCase().split('.').pop(); 146 - return ext === 'html' || ext === 'htm'; 224 + const ext = filepath.toLowerCase().split('.').pop() 225 + return ext === 'html' || ext === 'htm' 147 226 }
+215
hosting-service/src/lib/redirects.test.ts
··· 1 + import { describe, it, expect } from 'bun:test' 2 + import { parseRedirectsFile, matchRedirectRule } from './redirects'; 3 + 4 + describe('parseRedirectsFile', () => { 5 + it('should parse simple redirects', () => { 6 + const content = ` 7 + # Comment line 8 + /old-path /new-path 9 + /home / 301 10 + `; 11 + const rules = parseRedirectsFile(content); 12 + expect(rules).toHaveLength(2); 13 + expect(rules[0]).toMatchObject({ 14 + from: '/old-path', 15 + to: '/new-path', 16 + status: 301, 17 + force: false, 18 + }); 19 + expect(rules[1]).toMatchObject({ 20 + from: '/home', 21 + to: '/', 22 + status: 301, 23 + force: false, 24 + }); 25 + }); 26 + 27 + it('should parse redirects with different status codes', () => { 28 + const content = ` 29 + /temp-redirect /target 302 30 + /rewrite /content 200 31 + /not-found /404 404 32 + `; 33 + const rules = parseRedirectsFile(content); 34 + expect(rules).toHaveLength(3); 35 + expect(rules[0]?.status).toBe(302); 36 + expect(rules[1]?.status).toBe(200); 37 + expect(rules[2]?.status).toBe(404); 38 + }); 39 + 40 + it('should parse force redirects', () => { 41 + const content = `/force-path /target 301!`; 42 + const rules = parseRedirectsFile(content); 43 + expect(rules[0]?.force).toBe(true); 44 + expect(rules[0]?.status).toBe(301); 45 + }); 46 + 47 + it('should parse splat redirects', () => { 48 + const content = `/news/* /blog/:splat`; 49 + const rules = parseRedirectsFile(content); 50 + expect(rules[0]?.from).toBe('/news/*'); 51 + expect(rules[0]?.to).toBe('/blog/:splat'); 52 + }); 53 + 54 + it('should parse placeholder redirects', () => { 55 + const content = `/blog/:year/:month/:day /posts/:year-:month-:day`; 56 + const rules = parseRedirectsFile(content); 57 + expect(rules[0]?.from).toBe('/blog/:year/:month/:day'); 58 + expect(rules[0]?.to).toBe('/posts/:year-:month-:day'); 59 + }); 60 + 61 + it('should parse country-based redirects', () => { 62 + const content = `/ /anz 302 Country=au,nz`; 63 + const rules = parseRedirectsFile(content); 64 + expect(rules[0]?.conditions?.country).toEqual(['au', 'nz']); 65 + }); 66 + 67 + it('should parse language-based redirects', () => { 68 + const content = `/products /en/products 301 Language=en`; 69 + const rules = parseRedirectsFile(content); 70 + expect(rules[0]?.conditions?.language).toEqual(['en']); 71 + }); 72 + 73 + it('should parse cookie-based redirects', () => { 74 + const content = `/* /legacy/:splat 200 Cookie=is_legacy,my_cookie`; 75 + const rules = parseRedirectsFile(content); 76 + expect(rules[0]?.conditions?.cookie).toEqual(['is_legacy', 'my_cookie']); 77 + }); 78 + }); 79 + 80 + describe('matchRedirectRule', () => { 81 + it('should match exact paths', () => { 82 + const rules = parseRedirectsFile('/old-path /new-path'); 83 + const match = matchRedirectRule('/old-path', rules); 84 + expect(match).toBeTruthy(); 85 + expect(match?.targetPath).toBe('/new-path'); 86 + expect(match?.status).toBe(301); 87 + }); 88 + 89 + it('should match paths with trailing slash', () => { 90 + const rules = parseRedirectsFile('/old-path /new-path'); 91 + const match = matchRedirectRule('/old-path/', rules); 92 + expect(match).toBeTruthy(); 93 + expect(match?.targetPath).toBe('/new-path'); 94 + }); 95 + 96 + it('should match splat patterns', () => { 97 + const rules = parseRedirectsFile('/news/* /blog/:splat'); 98 + const match = matchRedirectRule('/news/2024/01/15/my-post', rules); 99 + expect(match).toBeTruthy(); 100 + expect(match?.targetPath).toBe('/blog/2024/01/15/my-post'); 101 + }); 102 + 103 + it('should match placeholder patterns', () => { 104 + const rules = parseRedirectsFile('/blog/:year/:month/:day /posts/:year-:month-:day'); 105 + const match = matchRedirectRule('/blog/2024/01/15', rules); 106 + expect(match).toBeTruthy(); 107 + expect(match?.targetPath).toBe('/posts/2024-01-15'); 108 + }); 109 + 110 + it('should preserve query strings for 301/302 redirects', () => { 111 + const rules = parseRedirectsFile('/old /new 301'); 112 + const match = matchRedirectRule('/old', rules, { 113 + queryParams: { foo: 'bar', baz: 'qux' }, 114 + }); 115 + expect(match?.targetPath).toContain('?'); 116 + expect(match?.targetPath).toContain('foo=bar'); 117 + expect(match?.targetPath).toContain('baz=qux'); 118 + }); 119 + 120 + it('should match based on query parameters', () => { 121 + const rules = parseRedirectsFile('/store id=:id /blog/:id 301'); 122 + const match = matchRedirectRule('/store', rules, { 123 + queryParams: { id: 'my-post' }, 124 + }); 125 + expect(match).toBeTruthy(); 126 + expect(match?.targetPath).toContain('/blog/my-post'); 127 + }); 128 + 129 + it('should not match when query params are missing', () => { 130 + const rules = parseRedirectsFile('/store id=:id /blog/:id 301'); 131 + const match = matchRedirectRule('/store', rules, { 132 + queryParams: {}, 133 + }); 134 + expect(match).toBeNull(); 135 + }); 136 + 137 + it('should match based on country header', () => { 138 + const rules = parseRedirectsFile('/ /aus 302 Country=au'); 139 + const match = matchRedirectRule('/', rules, { 140 + headers: { 'cf-ipcountry': 'AU' }, 141 + }); 142 + expect(match).toBeTruthy(); 143 + expect(match?.targetPath).toBe('/aus'); 144 + }); 145 + 146 + it('should not match wrong country', () => { 147 + const rules = parseRedirectsFile('/ /aus 302 Country=au'); 148 + const match = matchRedirectRule('/', rules, { 149 + headers: { 'cf-ipcountry': 'US' }, 150 + }); 151 + expect(match).toBeNull(); 152 + }); 153 + 154 + it('should match based on language header', () => { 155 + const rules = parseRedirectsFile('/products /en/products 301 Language=en'); 156 + const match = matchRedirectRule('/products', rules, { 157 + headers: { 'accept-language': 'en-US,en;q=0.9' }, 158 + }); 159 + expect(match).toBeTruthy(); 160 + expect(match?.targetPath).toBe('/en/products'); 161 + }); 162 + 163 + it('should match based on cookie presence', () => { 164 + const rules = parseRedirectsFile('/* /legacy/:splat 200 Cookie=is_legacy'); 165 + const match = matchRedirectRule('/some-path', rules, { 166 + cookies: { is_legacy: 'true' }, 167 + }); 168 + expect(match).toBeTruthy(); 169 + expect(match?.targetPath).toBe('/legacy/some-path'); 170 + }); 171 + 172 + it('should return first matching rule', () => { 173 + const content = ` 174 + /path /first 175 + /path /second 176 + `; 177 + const rules = parseRedirectsFile(content); 178 + const match = matchRedirectRule('/path', rules); 179 + expect(match?.targetPath).toBe('/first'); 180 + }); 181 + 182 + it('should match more specific rules before general ones', () => { 183 + const content = ` 184 + /jobs/customer-ninja /careers/support 185 + /jobs/* /careers/:splat 186 + `; 187 + const rules = parseRedirectsFile(content); 188 + 189 + const match1 = matchRedirectRule('/jobs/customer-ninja', rules); 190 + expect(match1?.targetPath).toBe('/careers/support'); 191 + 192 + const match2 = matchRedirectRule('/jobs/developer', rules); 193 + expect(match2?.targetPath).toBe('/careers/developer'); 194 + }); 195 + 196 + it('should handle SPA routing pattern', () => { 197 + const rules = parseRedirectsFile('/* /index.html 200'); 198 + 199 + // Should match any path 200 + const match1 = matchRedirectRule('/about', rules); 201 + expect(match1).toBeTruthy(); 202 + expect(match1?.targetPath).toBe('/index.html'); 203 + expect(match1?.status).toBe(200); 204 + 205 + const match2 = matchRedirectRule('/users/123/profile', rules); 206 + expect(match2).toBeTruthy(); 207 + expect(match2?.targetPath).toBe('/index.html'); 208 + expect(match2?.status).toBe(200); 209 + 210 + const match3 = matchRedirectRule('/', rules); 211 + expect(match3).toBeTruthy(); 212 + expect(match3?.targetPath).toBe('/index.html'); 213 + }); 214 + }); 215 +
+413
hosting-service/src/lib/redirects.ts
··· 1 + import { readFile } from 'fs/promises'; 2 + import { existsSync } from 'fs'; 3 + 4 + export interface RedirectRule { 5 + from: string; 6 + to: string; 7 + status: number; 8 + force: boolean; 9 + conditions?: { 10 + country?: string[]; 11 + language?: string[]; 12 + role?: string[]; 13 + cookie?: string[]; 14 + }; 15 + // For pattern matching 16 + fromPattern?: RegExp; 17 + fromParams?: string[]; // Named parameters from the pattern 18 + queryParams?: Record<string, string>; // Expected query parameters 19 + } 20 + 21 + export interface RedirectMatch { 22 + rule: RedirectRule; 23 + targetPath: string; 24 + status: number; 25 + } 26 + 27 + /** 28 + * Parse a _redirects file into an array of redirect rules 29 + */ 30 + export function parseRedirectsFile(content: string): RedirectRule[] { 31 + const lines = content.split('\n'); 32 + const rules: RedirectRule[] = []; 33 + 34 + for (let lineNum = 0; lineNum < lines.length; lineNum++) { 35 + const lineRaw = lines[lineNum]; 36 + if (!lineRaw) continue; 37 + 38 + const line = lineRaw.trim(); 39 + 40 + // Skip empty lines and comments 41 + if (!line || line.startsWith('#')) { 42 + continue; 43 + } 44 + 45 + try { 46 + const rule = parseRedirectLine(line); 47 + if (rule && rule.fromPattern) { 48 + rules.push(rule); 49 + } 50 + } catch (err) { 51 + console.warn(`Failed to parse redirect rule on line ${lineNum + 1}: ${line}`, err); 52 + } 53 + } 54 + 55 + return rules; 56 + } 57 + 58 + /** 59 + * Parse a single redirect rule line 60 + * Format: /from [query_params] /to [status] [conditions] 61 + */ 62 + function parseRedirectLine(line: string): RedirectRule | null { 63 + // Split by whitespace, but respect quoted strings (though not commonly used) 64 + const parts = line.split(/\s+/); 65 + 66 + if (parts.length < 2) { 67 + return null; 68 + } 69 + 70 + let idx = 0; 71 + const from = parts[idx++]; 72 + 73 + if (!from) { 74 + return null; 75 + } 76 + 77 + let status = 301; // Default status 78 + let force = false; 79 + const conditions: NonNullable<RedirectRule['conditions']> = {}; 80 + const queryParams: Record<string, string> = {}; 81 + 82 + // Parse query parameters that come before the destination path 83 + // They look like: key=:value (and don't start with /) 84 + while (idx < parts.length) { 85 + const part = parts[idx]; 86 + if (!part) { 87 + idx++; 88 + continue; 89 + } 90 + 91 + // If it starts with / or http, it's the destination path 92 + if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) { 93 + break; 94 + } 95 + 96 + // If it contains = and comes before the destination, it's a query param 97 + if (part.includes('=')) { 98 + const splitIndex = part.indexOf('='); 99 + const key = part.slice(0, splitIndex); 100 + const value = part.slice(splitIndex + 1); 101 + 102 + if (key && value) { 103 + queryParams[key] = value; 104 + } 105 + idx++; 106 + } else { 107 + // Not a query param, must be destination or something else 108 + break; 109 + } 110 + } 111 + 112 + // Next part should be the destination 113 + if (idx >= parts.length) { 114 + return null; 115 + } 116 + 117 + const to = parts[idx++]; 118 + if (!to) { 119 + return null; 120 + } 121 + 122 + // Parse remaining parts for status code and conditions 123 + for (let i = idx; i < parts.length; i++) { 124 + const part = parts[i]; 125 + 126 + if (!part) continue; 127 + 128 + // Check for status code (with optional ! for force) 129 + if (/^\d+!?$/.test(part)) { 130 + if (part.endsWith('!')) { 131 + force = true; 132 + status = parseInt(part.slice(0, -1)); 133 + } else { 134 + status = parseInt(part); 135 + } 136 + continue; 137 + } 138 + 139 + // Check for condition parameters (Country=, Language=, Role=, Cookie=) 140 + if (part.includes('=')) { 141 + const splitIndex = part.indexOf('='); 142 + const key = part.slice(0, splitIndex); 143 + const value = part.slice(splitIndex + 1); 144 + 145 + if (!key || !value) continue; 146 + 147 + const keyLower = key.toLowerCase(); 148 + 149 + if (keyLower === 'country') { 150 + conditions.country = value.split(',').map(v => v.trim().toLowerCase()); 151 + } else if (keyLower === 'language') { 152 + conditions.language = value.split(',').map(v => v.trim().toLowerCase()); 153 + } else if (keyLower === 'role') { 154 + conditions.role = value.split(',').map(v => v.trim()); 155 + } else if (keyLower === 'cookie') { 156 + conditions.cookie = value.split(',').map(v => v.trim().toLowerCase()); 157 + } 158 + } 159 + } 160 + 161 + // Parse the 'from' pattern 162 + const { pattern, params } = convertPathToRegex(from); 163 + 164 + return { 165 + from, 166 + to, 167 + status, 168 + force, 169 + conditions: Object.keys(conditions).length > 0 ? conditions : undefined, 170 + queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, 171 + fromPattern: pattern, 172 + fromParams: params, 173 + }; 174 + } 175 + 176 + /** 177 + * Convert a path pattern with placeholders and splats to a regex 178 + * Examples: 179 + * /blog/:year/:month/:day -> captures year, month, day 180 + * /news/* -> captures splat 181 + */ 182 + function convertPathToRegex(pattern: string): { pattern: RegExp; params: string[] } { 183 + const params: string[] = []; 184 + let regexStr = '^'; 185 + 186 + // Split by query string if present 187 + const pathPart = pattern.split('?')[0] || pattern; 188 + 189 + // Escape special regex characters except * and : 190 + let escaped = pathPart.replace(/[.+^${}()|[\]\\]/g, '\\$&'); 191 + 192 + // Replace :param with named capture groups 193 + escaped = escaped.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, paramName) => { 194 + params.push(paramName); 195 + // Match path segment (everything except / and ?) 196 + return '([^/?]+)'; 197 + }); 198 + 199 + // Replace * with splat capture (matches everything including /) 200 + if (escaped.includes('*')) { 201 + escaped = escaped.replace(/\*/g, '(.*)'); 202 + params.push('splat'); 203 + } 204 + 205 + regexStr += escaped; 206 + 207 + // Make trailing slash optional 208 + if (!regexStr.endsWith('.*')) { 209 + regexStr += '/?'; 210 + } 211 + 212 + regexStr += '$'; 213 + 214 + return { 215 + pattern: new RegExp(regexStr), 216 + params, 217 + }; 218 + } 219 + 220 + /** 221 + * Match a request path against redirect rules 222 + */ 223 + export function matchRedirectRule( 224 + requestPath: string, 225 + rules: RedirectRule[], 226 + context?: { 227 + queryParams?: Record<string, string>; 228 + headers?: Record<string, string>; 229 + cookies?: Record<string, string>; 230 + } 231 + ): RedirectMatch | null { 232 + // Normalize path: ensure leading slash, remove trailing slash (except for root) 233 + let normalizedPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; 234 + 235 + for (const rule of rules) { 236 + // Check query parameter conditions first (if any) 237 + if (rule.queryParams) { 238 + // If rule requires query params but none provided, skip this rule 239 + if (!context?.queryParams) { 240 + continue; 241 + } 242 + 243 + const queryMatches = Object.entries(rule.queryParams).every(([key, value]) => { 244 + const actualValue = context.queryParams?.[key]; 245 + return actualValue !== undefined; 246 + }); 247 + 248 + if (!queryMatches) { 249 + continue; 250 + } 251 + } 252 + 253 + // Check conditional redirects (country, language, role, cookie) 254 + if (rule.conditions) { 255 + if (rule.conditions.country && context?.headers) { 256 + const cfCountry = context.headers['cf-ipcountry']; 257 + const xCountry = context.headers['x-country']; 258 + const country = (cfCountry?.toLowerCase() || xCountry?.toLowerCase()); 259 + if (!country || !rule.conditions.country.includes(country)) { 260 + continue; 261 + } 262 + } 263 + 264 + if (rule.conditions.language && context?.headers) { 265 + const acceptLang = context.headers['accept-language']; 266 + if (!acceptLang) { 267 + continue; 268 + } 269 + // Parse accept-language header (simplified) 270 + const langs = acceptLang.split(',').map(l => { 271 + const langPart = l.split(';')[0]; 272 + return langPart ? langPart.trim().toLowerCase() : ''; 273 + }).filter(l => l !== ''); 274 + const hasMatch = rule.conditions.language.some(lang => 275 + langs.some(l => l === lang || l.startsWith(lang + '-')) 276 + ); 277 + if (!hasMatch) { 278 + continue; 279 + } 280 + } 281 + 282 + if (rule.conditions.cookie && context?.cookies) { 283 + const hasCookie = rule.conditions.cookie.some(cookieName => 284 + context.cookies && cookieName in context.cookies 285 + ); 286 + if (!hasCookie) { 287 + continue; 288 + } 289 + } 290 + 291 + // Role-based redirects would need JWT verification - skip for now 292 + if (rule.conditions.role) { 293 + continue; 294 + } 295 + } 296 + 297 + // Match the path pattern 298 + const match = rule.fromPattern?.exec(normalizedPath); 299 + if (!match) { 300 + continue; 301 + } 302 + 303 + // Build the target path by replacing placeholders 304 + let targetPath = rule.to; 305 + 306 + // Replace captured parameters 307 + if (rule.fromParams && match.length > 1) { 308 + for (let i = 0; i < rule.fromParams.length; i++) { 309 + const paramName = rule.fromParams[i]; 310 + const paramValue = match[i + 1]; 311 + 312 + if (!paramName || !paramValue) continue; 313 + 314 + if (paramName === 'splat') { 315 + targetPath = targetPath.replace(':splat', paramValue); 316 + } else { 317 + targetPath = targetPath.replace(`:${paramName}`, paramValue); 318 + } 319 + } 320 + } 321 + 322 + // Handle query parameter replacements 323 + if (rule.queryParams && context?.queryParams) { 324 + for (const [key, placeholder] of Object.entries(rule.queryParams)) { 325 + const actualValue = context.queryParams[key]; 326 + if (actualValue && placeholder && placeholder.startsWith(':')) { 327 + const paramName = placeholder.slice(1); 328 + if (paramName) { 329 + targetPath = targetPath.replace(`:${paramName}`, actualValue); 330 + } 331 + } 332 + } 333 + } 334 + 335 + // Preserve query string for 200, 301, 302 redirects (unless target already has one) 336 + if ([200, 301, 302].includes(rule.status) && context?.queryParams && !targetPath.includes('?')) { 337 + const queryString = Object.entries(context.queryParams) 338 + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) 339 + .join('&'); 340 + if (queryString) { 341 + targetPath += `?${queryString}`; 342 + } 343 + } 344 + 345 + return { 346 + rule, 347 + targetPath, 348 + status: rule.status, 349 + }; 350 + } 351 + 352 + return null; 353 + } 354 + 355 + /** 356 + * Load redirect rules from a cached site 357 + */ 358 + export async function loadRedirectRules(did: string, rkey: string): Promise<RedirectRule[]> { 359 + const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; 360 + const redirectsPath = `${CACHE_DIR}/${did}/${rkey}/_redirects`; 361 + 362 + if (!existsSync(redirectsPath)) { 363 + return []; 364 + } 365 + 366 + try { 367 + const content = await readFile(redirectsPath, 'utf-8'); 368 + return parseRedirectsFile(content); 369 + } catch (err) { 370 + console.error('Failed to load _redirects file', err); 371 + return []; 372 + } 373 + } 374 + 375 + /** 376 + * Parse cookies from Cookie header 377 + */ 378 + export function parseCookies(cookieHeader?: string): Record<string, string> { 379 + if (!cookieHeader) return {}; 380 + 381 + const cookies: Record<string, string> = {}; 382 + const parts = cookieHeader.split(';'); 383 + 384 + for (const part of parts) { 385 + const [key, ...valueParts] = part.split('='); 386 + if (key && valueParts.length > 0) { 387 + cookies[key.trim()] = valueParts.join('=').trim(); 388 + } 389 + } 390 + 391 + return cookies; 392 + } 393 + 394 + /** 395 + * Parse query string into object 396 + */ 397 + export function parseQueryString(url: string): Record<string, string> { 398 + const queryStart = url.indexOf('?'); 399 + if (queryStart === -1) return {}; 400 + 401 + const queryString = url.slice(queryStart + 1); 402 + const params: Record<string, string> = {}; 403 + 404 + for (const pair of queryString.split('&')) { 405 + const [key, value] = pair.split('='); 406 + if (key) { 407 + params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : ''; 408 + } 409 + } 410 + 411 + return params; 412 + } 413 +
+1 -1
hosting-service/src/lib/safe-fetch.ts
··· 25 25 const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads 26 26 const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB 27 27 const MAX_JSON_SIZE = 1024 * 1024; // 1MB 28 - const MAX_BLOB_SIZE = 100 * 1024 * 1024; // 100MB 28 + const MAX_BLOB_SIZE = 500 * 1024 * 1024; // 500MB 29 29 const MAX_REDIRECTS = 10; 30 30 31 31 function isBlockedHost(hostname: string): boolean {
+198 -37
hosting-service/src/lib/utils.ts
··· 5 5 import { safeFetchJson, safeFetchBlob } from './safe-fetch'; 6 6 import { CID } from 'multiformats'; 7 7 8 - const CACHE_DIR = './cache/sites'; 8 + const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; 9 9 const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL 10 10 11 11 interface CacheMetadata { ··· 13 13 cachedAt: number; 14 14 did: string; 15 15 rkey: string; 16 + // Map of file path to blob CID for incremental updates 17 + fileCids?: Record<string, string>; 18 + } 19 + 20 + /** 21 + * Determines if a MIME type should benefit from gzip compression. 22 + * Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG). 23 + * Returns false for already-compressed formats (images, video, audio, PDFs). 24 + * 25 + */ 26 + export function shouldCompressMimeType(mimeType: string | undefined): boolean { 27 + if (!mimeType) return false; 28 + 29 + const mime = mimeType.toLowerCase(); 30 + 31 + // Text-based web assets that benefit from compression 32 + const compressibleTypes = [ 33 + 'text/html', 34 + 'text/css', 35 + 'text/javascript', 36 + 'application/javascript', 37 + 'application/x-javascript', 38 + 'text/xml', 39 + 'application/xml', 40 + 'application/json', 41 + 'text/plain', 42 + 'image/svg+xml', 43 + ]; 44 + 45 + if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) { 46 + return true; 47 + } 48 + 49 + // Already-compressed formats that should NOT be double-compressed 50 + const alreadyCompressedPrefixes = [ 51 + 'video/', 52 + 'audio/', 53 + 'image/', 54 + 'application/pdf', 55 + 'application/zip', 56 + 'application/gzip', 57 + ]; 58 + 59 + if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) { 60 + return false; 61 + } 62 + 63 + // Default to not compressing for unknown types 64 + return false; 16 65 } 17 66 18 67 interface IpldLink { ··· 153 202 throw new Error('Invalid record structure: root missing entries array'); 154 203 } 155 204 205 + // Get existing cache metadata to check for incremental updates 206 + const existingMetadata = await getCacheMetadata(did, rkey); 207 + const existingFileCids = existingMetadata?.fileCids || {}; 208 + 156 209 // Use a temporary directory with timestamp to avoid collisions 157 210 const tempSuffix = `.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; 158 211 const tempDir = `${CACHE_DIR}/${did}/${rkey}${tempSuffix}`; 159 212 const finalDir = `${CACHE_DIR}/${did}/${rkey}`; 160 213 161 214 try { 162 - // Download to temporary directory 163 - await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix); 164 - await saveCacheMetadata(did, rkey, recordCid, tempSuffix); 215 + // Collect file CIDs from the new record 216 + const newFileCids: Record<string, string> = {}; 217 + collectFileCidsFromEntries(record.root.entries, '', newFileCids); 218 + 219 + // Download/copy files to temporary directory (with incremental logic) 220 + await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix, existingFileCids, finalDir); 221 + await saveCacheMetadata(did, rkey, recordCid, tempSuffix, newFileCids); 165 222 166 223 // Atomically replace old cache with new cache 167 224 // On POSIX systems (Linux/macOS), rename is atomic ··· 198 255 } 199 256 } 200 257 258 + /** 259 + * Recursively collect file CIDs from entries for incremental update tracking 260 + */ 261 + function collectFileCidsFromEntries(entries: Entry[], pathPrefix: string, fileCids: Record<string, string>): void { 262 + for (const entry of entries) { 263 + const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name; 264 + const node = entry.node; 265 + 266 + if ('type' in node && node.type === 'directory' && 'entries' in node) { 267 + collectFileCidsFromEntries(node.entries, currentPath, fileCids); 268 + } else if ('type' in node && node.type === 'file' && 'blob' in node) { 269 + const fileNode = node as File; 270 + const cid = extractBlobCid(fileNode.blob); 271 + if (cid) { 272 + fileCids[currentPath] = cid; 273 + } 274 + } 275 + } 276 + } 277 + 201 278 async function cacheFiles( 202 279 did: string, 203 280 site: string, 204 281 entries: Entry[], 205 282 pdsEndpoint: string, 206 283 pathPrefix: string, 207 - dirSuffix: string = '' 284 + dirSuffix: string = '', 285 + existingFileCids: Record<string, string> = {}, 286 + existingCacheDir?: string 208 287 ): Promise<void> { 209 - // Collect all file blob download tasks first 288 + // Collect file tasks, separating unchanged files from new/changed files 210 289 const downloadTasks: Array<() => Promise<void>> = []; 211 - 290 + const copyTasks: Array<() => Promise<void>> = []; 291 + 212 292 function collectFileTasks( 213 293 entries: Entry[], 214 294 currentPathPrefix: string ··· 221 301 collectFileTasks(node.entries, currentPath); 222 302 } else if ('type' in node && node.type === 'file' && 'blob' in node) { 223 303 const fileNode = node as File; 224 - downloadTasks.push(() => cacheFileBlob( 225 - did, 226 - site, 227 - currentPath, 228 - fileNode.blob, 229 - pdsEndpoint, 230 - fileNode.encoding, 231 - fileNode.mimeType, 232 - fileNode.base64, 233 - dirSuffix 234 - )); 304 + const cid = extractBlobCid(fileNode.blob); 305 + 306 + // Check if file is unchanged (same CID as existing cache) 307 + if (cid && existingFileCids[currentPath] === cid && existingCacheDir) { 308 + // File unchanged - copy from existing cache instead of downloading 309 + copyTasks.push(() => copyExistingFile( 310 + did, 311 + site, 312 + currentPath, 313 + dirSuffix, 314 + existingCacheDir 315 + )); 316 + } else { 317 + // File new or changed - download it 318 + downloadTasks.push(() => cacheFileBlob( 319 + did, 320 + site, 321 + currentPath, 322 + fileNode.blob, 323 + pdsEndpoint, 324 + fileNode.encoding, 325 + fileNode.mimeType, 326 + fileNode.base64, 327 + dirSuffix 328 + )); 329 + } 235 330 } 236 331 } 237 332 } 238 333 239 334 collectFileTasks(entries, pathPrefix); 240 335 241 - // Execute downloads concurrently with a limit of 3 at a time 242 - const concurrencyLimit = 3; 243 - for (let i = 0; i < downloadTasks.length; i += concurrencyLimit) { 244 - const batch = downloadTasks.slice(i, i + concurrencyLimit); 336 + console.log(`[Incremental Update] Files to copy: ${copyTasks.length}, Files to download: ${downloadTasks.length}`); 337 + 338 + // Copy unchanged files in parallel (fast local operations) 339 + const copyLimit = 10; 340 + for (let i = 0; i < copyTasks.length; i += copyLimit) { 341 + const batch = copyTasks.slice(i, i + copyLimit); 342 + await Promise.all(batch.map(task => task())); 343 + } 344 + 345 + // Download new/changed files concurrently with a limit of 3 at a time 346 + const downloadLimit = 3; 347 + for (let i = 0; i < downloadTasks.length; i += downloadLimit) { 348 + const batch = downloadTasks.slice(i, i + downloadLimit); 245 349 await Promise.all(batch.map(task => task())); 246 350 } 247 351 } 248 352 353 + /** 354 + * Copy an unchanged file from existing cache to new cache location 355 + */ 356 + async function copyExistingFile( 357 + did: string, 358 + site: string, 359 + filePath: string, 360 + dirSuffix: string, 361 + existingCacheDir: string 362 + ): Promise<void> { 363 + const { copyFile } = await import('fs/promises'); 364 + 365 + const sourceFile = `${existingCacheDir}/${filePath}`; 366 + const destFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`; 367 + const destDir = destFile.substring(0, destFile.lastIndexOf('/')); 368 + 369 + // Create destination directory if needed 370 + if (destDir && !existsSync(destDir)) { 371 + mkdirSync(destDir, { recursive: true }); 372 + } 373 + 374 + try { 375 + // Copy the file 376 + await copyFile(sourceFile, destFile); 377 + 378 + // Copy metadata file if it exists 379 + const sourceMetaFile = `${sourceFile}.meta`; 380 + const destMetaFile = `${destFile}.meta`; 381 + if (existsSync(sourceMetaFile)) { 382 + await copyFile(sourceMetaFile, destMetaFile); 383 + } 384 + 385 + console.log(`[Incremental] Copied unchanged file: ${filePath}`); 386 + } catch (err) { 387 + console.error(`[Incremental] Failed to copy file ${filePath}, will attempt download:`, err); 388 + throw err; 389 + } 390 + } 391 + 249 392 async function cacheFileBlob( 250 393 did: string, 251 394 site: string, ··· 265 408 266 409 const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 267 410 268 - // Allow up to 100MB per file blob, with 2 minute timeout 269 - let content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024, timeout: 120000 }); 411 + // Allow up to 500MB per file blob, with 5 minute timeout 412 + let content = await safeFetchBlob(blobUrl, { maxSize: 500 * 1024 * 1024, timeout: 300000 }); 270 413 271 414 console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`); 272 415 273 - // If content is base64-encoded, decode it back to binary (gzipped or not) 416 + // If content is base64-encoded, decode it back to raw binary (gzipped or not) 274 417 if (base64) { 275 418 const originalSize = content.length; 276 - // The content from the blob is base64 text, decode it directly to binary 277 - const buffer = Buffer.from(content); 278 - const base64String = buffer.toString('ascii'); // Use ascii for base64 text, not utf-8 279 - console.log(`[DEBUG] ${filePath}: base64 string first 100 chars: ${base64String.substring(0, 100)}`); 419 + // Decode base64 directly from raw bytes - no string conversion 420 + // The blob contains base64-encoded text as raw bytes, decode it in-place 421 + const textDecoder = new TextDecoder(); 422 + const base64String = textDecoder.decode(content); 280 423 content = Buffer.from(base64String, 'base64'); 281 - console.log(`[DEBUG] ${filePath}: decoded from ${originalSize} bytes to ${content.length} bytes`); 424 + console.log(`[DEBUG] ${filePath}: decoded base64 from ${originalSize} bytes to ${content.length} bytes`); 282 425 283 426 // Check if it's actually gzipped by looking at magic bytes 284 427 if (content.length >= 2) { 285 - const magic = content[0] === 0x1f && content[1] === 0x8b; 286 - const byte0 = content[0]; 287 - const byte1 = content[1]; 288 - console.log(`[DEBUG] ${filePath}: has gzip magic bytes: ${magic} (0x${byte0?.toString(16)}, 0x${byte1?.toString(16)})`); 428 + const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b; 429 + console.log(`[DEBUG] ${filePath}: has gzip magic bytes: ${hasGzipMagic}`); 289 430 } 290 431 } 291 432 ··· 296 437 mkdirSync(fileDir, { recursive: true }); 297 438 } 298 439 440 + // Use the shared function to determine if this should remain compressed 441 + const shouldStayCompressed = shouldCompressMimeType(mimeType); 442 + 443 + // Decompress files that shouldn't be stored compressed 444 + if (encoding === 'gzip' && !shouldStayCompressed && content.length >= 2 && 445 + content[0] === 0x1f && content[1] === 0x8b) { 446 + console.log(`[DEBUG] ${filePath}: decompressing non-compressible type (${mimeType}) before caching`); 447 + try { 448 + const { gunzipSync } = await import('zlib'); 449 + const decompressed = gunzipSync(content); 450 + console.log(`[DEBUG] ${filePath}: decompressed from ${content.length} to ${decompressed.length} bytes`); 451 + content = decompressed; 452 + // Clear the encoding flag since we're storing decompressed 453 + encoding = undefined; 454 + } catch (error) { 455 + console.log(`[DEBUG] ${filePath}: failed to decompress, storing original gzipped content. Error:`, error); 456 + } 457 + } 458 + 299 459 await writeFile(cacheFile, content); 300 460 301 - // Store metadata if file is compressed 461 + // Store metadata only if file is still compressed 302 462 if (encoding === 'gzip' && mimeType) { 303 463 const metaFile = `${cacheFile}.meta`; 304 464 await writeFile(metaFile, JSON.stringify({ encoding, mimeType })); ··· 340 500 return existsSync(`${CACHE_DIR}/${did}/${site}`); 341 501 } 342 502 343 - async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = ''): Promise<void> { 503 + async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = '', fileCids?: Record<string, string>): Promise<void> { 344 504 const metadata: CacheMetadata = { 345 505 recordCid, 346 506 cachedAt: Date.now(), 347 507 did, 348 - rkey 508 + rkey, 509 + fileCids 349 510 }; 350 511 351 512 const metadataPath = `${CACHE_DIR}/${did}/${rkey}${dirSuffix}/.metadata.json`;
+364 -149
hosting-service/src/server.ts
··· 1 1 import { Hono } from 'hono'; 2 2 import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 3 - import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils'; 3 + import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils'; 4 4 import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 5 - import { existsSync, readFileSync } from 'fs'; 5 + import { existsSync } from 'fs'; 6 + import { readFile, access } from 'fs/promises'; 6 7 import { lookup } from 'mime-types'; 7 8 import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 9 + import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata } from './lib/cache'; 10 + import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects'; 8 11 9 12 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 10 13 ··· 21 24 return validRkeyPattern.test(rkey); 22 25 } 23 26 27 + /** 28 + * Async file existence check 29 + */ 30 + async function fileExists(path: string): Promise<boolean> { 31 + try { 32 + await access(path); 33 + return true; 34 + } catch { 35 + return false; 36 + } 37 + } 38 + 39 + // Cache for redirect rules (per site) 40 + const redirectRulesCache = new Map<string, RedirectRule[]>(); 41 + 42 + /** 43 + * Clear redirect rules cache for a specific site 44 + * Should be called when a site is updated/recached 45 + */ 46 + export function clearRedirectRulesCache(did: string, rkey: string) { 47 + const cacheKey = `${did}:${rkey}`; 48 + redirectRulesCache.delete(cacheKey); 49 + } 50 + 24 51 // Helper to serve files from cache 25 - async function serveFromCache(did: string, rkey: string, filePath: string) { 52 + async function serveFromCache( 53 + did: string, 54 + rkey: string, 55 + filePath: string, 56 + fullUrl?: string, 57 + headers?: Record<string, string> 58 + ) { 59 + // Check for redirect rules first 60 + const redirectCacheKey = `${did}:${rkey}`; 61 + let redirectRules = redirectRulesCache.get(redirectCacheKey); 62 + 63 + if (redirectRules === undefined) { 64 + // Load rules for the first time 65 + redirectRules = await loadRedirectRules(did, rkey); 66 + redirectRulesCache.set(redirectCacheKey, redirectRules); 67 + } 68 + 69 + // Apply redirect rules if any exist 70 + if (redirectRules.length > 0) { 71 + const requestPath = '/' + (filePath || ''); 72 + const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 73 + const cookies = parseCookies(headers?.['cookie']); 74 + 75 + const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 76 + queryParams, 77 + headers, 78 + cookies, 79 + }); 80 + 81 + if (redirectMatch) { 82 + const { targetPath, status } = redirectMatch; 83 + 84 + // Handle different status codes 85 + if (status === 200) { 86 + // Rewrite: serve different content but keep URL the same 87 + // Remove leading slash for internal path resolution 88 + const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 89 + return serveFileInternal(did, rkey, rewritePath); 90 + } else if (status === 301 || status === 302) { 91 + // External redirect: change the URL 92 + return new Response(null, { 93 + status, 94 + headers: { 95 + 'Location': targetPath, 96 + 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 97 + }, 98 + }); 99 + } else if (status === 404) { 100 + // Custom 404 page 101 + const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 102 + const response = await serveFileInternal(did, rkey, custom404Path); 103 + // Override status to 404 104 + return new Response(response.body, { 105 + status: 404, 106 + headers: response.headers, 107 + }); 108 + } 109 + } 110 + } 111 + 112 + // No redirect matched, serve normally 113 + return serveFileInternal(did, rkey, filePath); 114 + } 115 + 116 + // Internal function to serve a file (used by both normal serving and rewrites) 117 + async function serveFileInternal(did: string, rkey: string, filePath: string) { 26 118 // Default to index.html if path is empty or ends with / 27 119 let requestPath = filePath || 'index.html'; 28 120 if (requestPath.endsWith('/')) { 29 121 requestPath += 'index.html'; 30 122 } 31 123 124 + const cacheKey = getCacheKey(did, rkey, requestPath); 32 125 const cachedFile = getCachedFilePath(did, rkey, requestPath); 33 126 34 - if (existsSync(cachedFile)) { 35 - const content = readFileSync(cachedFile); 127 + // Check in-memory cache first 128 + let content = fileCache.get(cacheKey); 129 + let meta = metadataCache.get(cacheKey); 130 + 131 + if (!content && await fileExists(cachedFile)) { 132 + // Read from disk and cache 133 + content = await readFile(cachedFile); 134 + fileCache.set(cacheKey, content, content.length); 135 + 36 136 const metaFile = `${cachedFile}.meta`; 137 + if (await fileExists(metaFile)) { 138 + const metaJson = await readFile(metaFile, 'utf-8'); 139 + meta = JSON.parse(metaJson); 140 + metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 141 + } 142 + } 37 143 38 - console.log(`[DEBUG SERVE] ${requestPath}: file size=${content.length} bytes, metaFile exists=${existsSync(metaFile)}`); 144 + if (content) { 145 + // Build headers with caching 146 + const headers: Record<string, string> = {}; 39 147 40 - // Check if file has compression metadata 41 - if (existsSync(metaFile)) { 42 - const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 43 - console.log(`[DEBUG SERVE] ${requestPath}: meta=${JSON.stringify(meta)}`); 44 - 45 - // Check actual content for gzip magic bytes 46 - if (content.length >= 2) { 47 - const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b; 48 - const byte0 = content[0]; 49 - const byte1 = content[1]; 50 - console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic} (0x${byte0?.toString(16)}, 0x${byte1?.toString(16)})`); 148 + if (meta && meta.encoding === 'gzip' && meta.mimeType) { 149 + const shouldServeCompressed = shouldCompressMimeType(meta.mimeType); 150 + 151 + if (!shouldServeCompressed) { 152 + const { gunzipSync } = await import('zlib'); 153 + const decompressed = gunzipSync(content); 154 + headers['Content-Type'] = meta.mimeType; 155 + headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 156 + return new Response(decompressed, { headers }); 51 157 } 52 - 53 - if (meta.encoding === 'gzip' && meta.mimeType) { 54 - // Don't serve already-compressed media formats with Content-Encoding: gzip 55 - // These formats (video, audio, images) are already compressed and the browser 56 - // can't decode them if we add another layer of compression 57 - const alreadyCompressedTypes = [ 58 - 'video/', 'audio/', 'image/jpeg', 'image/jpg', 'image/png', 59 - 'image/gif', 'image/webp', 'application/pdf' 60 - ]; 61 - 62 - const isAlreadyCompressed = alreadyCompressedTypes.some(type => 63 - meta.mimeType.toLowerCase().startsWith(type) 64 - ); 65 - 66 - if (isAlreadyCompressed) { 67 - // Decompress the file before serving 68 - console.log(`[DEBUG SERVE] ${requestPath}: decompressing already-compressed media type`); 69 - const { gunzipSync } = await import('zlib'); 70 - const decompressed = gunzipSync(content); 71 - console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`); 72 - return new Response(decompressed, { 73 - headers: { 74 - 'Content-Type': meta.mimeType, 75 - }, 76 - }); 77 - } 78 - 79 - // Serve gzipped content with proper headers (for HTML, CSS, JS, etc.) 80 - console.log(`[DEBUG SERVE] ${requestPath}: serving as gzipped with Content-Encoding header`); 81 - return new Response(content, { 82 - headers: { 83 - 'Content-Type': meta.mimeType, 84 - 'Content-Encoding': 'gzip', 85 - }, 86 - }); 87 - } 158 + 159 + headers['Content-Type'] = meta.mimeType; 160 + headers['Content-Encoding'] = 'gzip'; 161 + headers['Cache-Control'] = meta.mimeType.startsWith('text/html') 162 + ? 'public, max-age=300' 163 + : 'public, max-age=31536000, immutable'; 164 + return new Response(content, { headers }); 88 165 } 89 166 90 - // Serve non-compressed files normally 167 + // Non-compressed files 91 168 const mimeType = lookup(cachedFile) || 'application/octet-stream'; 92 - return new Response(content, { 93 - headers: { 94 - 'Content-Type': mimeType, 95 - }, 96 - }); 169 + headers['Content-Type'] = mimeType; 170 + headers['Cache-Control'] = mimeType.startsWith('text/html') 171 + ? 'public, max-age=300' 172 + : 'public, max-age=31536000, immutable'; 173 + return new Response(content, { headers }); 97 174 } 98 175 99 176 // Try index.html for directory-like paths 100 177 if (!requestPath.includes('.')) { 101 - const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 102 - if (existsSync(indexFile)) { 103 - const content = readFileSync(indexFile); 104 - const metaFile = `${indexFile}.meta`; 178 + const indexPath = `${requestPath}/index.html`; 179 + const indexCacheKey = getCacheKey(did, rkey, indexPath); 180 + const indexFile = getCachedFilePath(did, rkey, indexPath); 105 181 106 - // Check if file has compression metadata 107 - if (existsSync(metaFile)) { 108 - const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 109 - if (meta.encoding === 'gzip' && meta.mimeType) { 110 - return new Response(content, { 111 - headers: { 112 - 'Content-Type': meta.mimeType, 113 - 'Content-Encoding': 'gzip', 114 - }, 115 - }); 116 - } 182 + let indexContent = fileCache.get(indexCacheKey); 183 + let indexMeta = metadataCache.get(indexCacheKey); 184 + 185 + if (!indexContent && await fileExists(indexFile)) { 186 + indexContent = await readFile(indexFile); 187 + fileCache.set(indexCacheKey, indexContent, indexContent.length); 188 + 189 + const indexMetaFile = `${indexFile}.meta`; 190 + if (await fileExists(indexMetaFile)) { 191 + const metaJson = await readFile(indexMetaFile, 'utf-8'); 192 + indexMeta = JSON.parse(metaJson); 193 + metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 117 194 } 195 + } 118 196 119 - return new Response(content, { 120 - headers: { 121 - 'Content-Type': 'text/html; charset=utf-8', 122 - }, 123 - }); 197 + if (indexContent) { 198 + const headers: Record<string, string> = { 199 + 'Content-Type': 'text/html; charset=utf-8', 200 + 'Cache-Control': 'public, max-age=300', 201 + }; 202 + 203 + if (indexMeta && indexMeta.encoding === 'gzip') { 204 + headers['Content-Encoding'] = 'gzip'; 205 + } 206 + 207 + return new Response(indexContent, { headers }); 124 208 } 125 209 } 126 210 ··· 132 216 did: string, 133 217 rkey: string, 134 218 filePath: string, 135 - basePath: string 219 + basePath: string, 220 + fullUrl?: string, 221 + headers?: Record<string, string> 136 222 ) { 223 + // Check for redirect rules first 224 + const redirectCacheKey = `${did}:${rkey}`; 225 + let redirectRules = redirectRulesCache.get(redirectCacheKey); 226 + 227 + if (redirectRules === undefined) { 228 + // Load rules for the first time 229 + redirectRules = await loadRedirectRules(did, rkey); 230 + redirectRulesCache.set(redirectCacheKey, redirectRules); 231 + } 232 + 233 + // Apply redirect rules if any exist 234 + if (redirectRules.length > 0) { 235 + const requestPath = '/' + (filePath || ''); 236 + const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 237 + const cookies = parseCookies(headers?.['cookie']); 238 + 239 + const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 240 + queryParams, 241 + headers, 242 + cookies, 243 + }); 244 + 245 + if (redirectMatch) { 246 + const { targetPath, status } = redirectMatch; 247 + 248 + // Handle different status codes 249 + if (status === 200) { 250 + // Rewrite: serve different content but keep URL the same 251 + const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 252 + return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath); 253 + } else if (status === 301 || status === 302) { 254 + // External redirect: change the URL 255 + // For sites.wisp.place, we need to adjust the target path to include the base path 256 + // unless it's an absolute URL 257 + let redirectTarget = targetPath; 258 + if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) { 259 + redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath); 260 + } 261 + return new Response(null, { 262 + status, 263 + headers: { 264 + 'Location': redirectTarget, 265 + 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 266 + }, 267 + }); 268 + } else if (status === 404) { 269 + // Custom 404 page 270 + const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 271 + const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath); 272 + // Override status to 404 273 + return new Response(response.body, { 274 + status: 404, 275 + headers: response.headers, 276 + }); 277 + } 278 + } 279 + } 280 + 281 + // No redirect matched, serve normally 282 + return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 283 + } 284 + 285 + // Internal function to serve a file with rewriting 286 + async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) { 137 287 // Default to index.html if path is empty or ends with / 138 288 let requestPath = filePath || 'index.html'; 139 289 if (requestPath.endsWith('/')) { 140 290 requestPath += 'index.html'; 141 291 } 142 292 293 + const cacheKey = getCacheKey(did, rkey, requestPath); 143 294 const cachedFile = getCachedFilePath(did, rkey, requestPath); 144 295 145 - if (existsSync(cachedFile)) { 146 - const metaFile = `${cachedFile}.meta`; 147 - let mimeType = lookup(cachedFile) || 'application/octet-stream'; 148 - let isGzipped = false; 296 + // Check for rewritten HTML in cache first (if it's HTML) 297 + const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream'; 298 + if (isHtmlContent(requestPath, mimeTypeGuess)) { 299 + const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 300 + const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 301 + if (rewrittenContent) { 302 + return new Response(rewrittenContent, { 303 + headers: { 304 + 'Content-Type': 'text/html; charset=utf-8', 305 + 'Content-Encoding': 'gzip', 306 + 'Cache-Control': 'public, max-age=300', 307 + }, 308 + }); 309 + } 310 + } 149 311 150 - // Check if file has compression metadata 151 - if (existsSync(metaFile)) { 152 - const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 153 - if (meta.encoding === 'gzip' && meta.mimeType) { 154 - mimeType = meta.mimeType; 155 - isGzipped = true; 156 - } 312 + // Check in-memory file cache 313 + let content = fileCache.get(cacheKey); 314 + let meta = metadataCache.get(cacheKey); 315 + 316 + if (!content && await fileExists(cachedFile)) { 317 + // Read from disk and cache 318 + content = await readFile(cachedFile); 319 + fileCache.set(cacheKey, content, content.length); 320 + 321 + const metaFile = `${cachedFile}.meta`; 322 + if (await fileExists(metaFile)) { 323 + const metaJson = await readFile(metaFile, 'utf-8'); 324 + meta = JSON.parse(metaJson); 325 + metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 157 326 } 327 + } 328 + 329 + if (content) { 330 + const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream'; 331 + const isGzipped = meta?.encoding === 'gzip'; 158 332 159 333 // Check if this is HTML content that needs rewriting 160 - // Note: For gzipped HTML with path rewriting, we need to decompress, rewrite, and serve uncompressed 161 - // This is a trade-off for the sites.wisp.place domain which needs path rewriting 162 334 if (isHtmlContent(requestPath, mimeType)) { 163 - let content: string; 335 + let htmlContent: string; 164 336 if (isGzipped) { 165 337 const { gunzipSync } = await import('zlib'); 166 - const compressed = readFileSync(cachedFile); 167 - content = gunzipSync(compressed).toString('utf-8'); 338 + htmlContent = gunzipSync(content).toString('utf-8'); 168 339 } else { 169 - content = readFileSync(cachedFile, 'utf-8'); 340 + htmlContent = content.toString('utf-8'); 170 341 } 171 - const rewritten = rewriteHtmlPaths(content, basePath); 172 - return new Response(rewritten, { 342 + const rewritten = rewriteHtmlPaths(htmlContent, basePath, requestPath); 343 + 344 + // Recompress and cache the rewritten HTML 345 + const { gzipSync } = await import('zlib'); 346 + const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 347 + 348 + const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 349 + rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 350 + 351 + return new Response(recompressed, { 173 352 headers: { 174 353 'Content-Type': 'text/html; charset=utf-8', 354 + 'Content-Encoding': 'gzip', 355 + 'Cache-Control': 'public, max-age=300', 175 356 }, 176 357 }); 177 358 } 178 359 179 - // Non-HTML files: serve gzipped content as-is with proper headers 180 - const content = readFileSync(cachedFile); 360 + // Non-HTML files: serve as-is 361 + const headers: Record<string, string> = { 362 + 'Content-Type': mimeType, 363 + 'Cache-Control': 'public, max-age=31536000, immutable', 364 + }; 365 + 181 366 if (isGzipped) { 182 - // Don't serve already-compressed media formats with Content-Encoding: gzip 183 - const alreadyCompressedTypes = [ 184 - 'video/', 'audio/', 'image/jpeg', 'image/jpg', 'image/png', 185 - 'image/gif', 'image/webp', 'application/pdf' 186 - ]; 187 - 188 - const isAlreadyCompressed = alreadyCompressedTypes.some(type => 189 - mimeType.toLowerCase().startsWith(type) 190 - ); 191 - 192 - if (isAlreadyCompressed) { 193 - // Decompress the file before serving 367 + const shouldServeCompressed = shouldCompressMimeType(mimeType); 368 + if (!shouldServeCompressed) { 194 369 const { gunzipSync } = await import('zlib'); 195 370 const decompressed = gunzipSync(content); 196 - return new Response(decompressed, { 197 - headers: { 198 - 'Content-Type': mimeType, 199 - }, 200 - }); 371 + return new Response(decompressed, { headers }); 201 372 } 202 - 203 - return new Response(content, { 373 + headers['Content-Encoding'] = 'gzip'; 374 + } 375 + 376 + return new Response(content, { headers }); 377 + } 378 + 379 + // Try index.html for directory-like paths 380 + if (!requestPath.includes('.')) { 381 + const indexPath = `${requestPath}/index.html`; 382 + const indexCacheKey = getCacheKey(did, rkey, indexPath); 383 + const indexFile = getCachedFilePath(did, rkey, indexPath); 384 + 385 + // Check for rewritten index.html in cache 386 + const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`); 387 + const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 388 + if (rewrittenContent) { 389 + return new Response(rewrittenContent, { 204 390 headers: { 205 - 'Content-Type': mimeType, 391 + 'Content-Type': 'text/html; charset=utf-8', 206 392 'Content-Encoding': 'gzip', 393 + 'Cache-Control': 'public, max-age=300', 207 394 }, 208 395 }); 209 396 } 210 - return new Response(content, { 211 - headers: { 212 - 'Content-Type': mimeType, 213 - }, 214 - }); 215 - } 216 397 217 - // Try index.html for directory-like paths 218 - if (!requestPath.includes('.')) { 219 - const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 220 - if (existsSync(indexFile)) { 221 - const metaFile = `${indexFile}.meta`; 222 - let isGzipped = false; 398 + let indexContent = fileCache.get(indexCacheKey); 399 + let indexMeta = metadataCache.get(indexCacheKey); 223 400 224 - if (existsSync(metaFile)) { 225 - const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 226 - if (meta.encoding === 'gzip') { 227 - isGzipped = true; 228 - } 401 + if (!indexContent && await fileExists(indexFile)) { 402 + indexContent = await readFile(indexFile); 403 + fileCache.set(indexCacheKey, indexContent, indexContent.length); 404 + 405 + const indexMetaFile = `${indexFile}.meta`; 406 + if (await fileExists(indexMetaFile)) { 407 + const metaJson = await readFile(indexMetaFile, 'utf-8'); 408 + indexMeta = JSON.parse(metaJson); 409 + metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 229 410 } 411 + } 230 412 231 - // HTML needs path rewriting, so decompress if needed 232 - let content: string; 413 + if (indexContent) { 414 + const isGzipped = indexMeta?.encoding === 'gzip'; 415 + 416 + let htmlContent: string; 233 417 if (isGzipped) { 234 418 const { gunzipSync } = await import('zlib'); 235 - const compressed = readFileSync(indexFile); 236 - content = gunzipSync(compressed).toString('utf-8'); 419 + htmlContent = gunzipSync(indexContent).toString('utf-8'); 237 420 } else { 238 - content = readFileSync(indexFile, 'utf-8'); 421 + htmlContent = indexContent.toString('utf-8'); 239 422 } 240 - const rewritten = rewriteHtmlPaths(content, basePath); 241 - return new Response(rewritten, { 423 + const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath); 424 + 425 + const { gzipSync } = await import('zlib'); 426 + const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 427 + 428 + rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 429 + 430 + return new Response(recompressed, { 242 431 headers: { 243 432 'Content-Type': 'text/html; charset=utf-8', 433 + 'Content-Encoding': 'gzip', 434 + 'Cache-Control': 'public, max-age=300', 244 435 }, 245 436 }); 246 437 } ··· 270 461 271 462 try { 272 463 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 464 + // Clear redirect rules cache since the site was updated 465 + clearRedirectRulesCache(did, rkey); 273 466 logger.info('Site cached successfully', { did, rkey }); 274 467 return true; 275 468 } catch (err) { ··· 337 530 338 531 // Serve with HTML path rewriting to handle absolute paths 339 532 const basePath = `/${identifier}/${site}/`; 340 - return serveFromCacheWithRewrite(did, site, filePath, basePath); 533 + const headers: Record<string, string> = {}; 534 + c.req.raw.headers.forEach((value, key) => { 535 + headers[key.toLowerCase()] = value; 536 + }); 537 + return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers); 341 538 } 342 539 343 540 // Check if this is a DNS hash subdomain ··· 373 570 return c.text('Site not found', 404); 374 571 } 375 572 376 - return serveFromCache(customDomain.did, rkey, path); 573 + const headers: Record<string, string> = {}; 574 + c.req.raw.headers.forEach((value, key) => { 575 + headers[key.toLowerCase()] = value; 576 + }); 577 + return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 377 578 } 378 579 379 580 // Route 2: Registered subdomains - /*.wisp.place/* ··· 397 598 return c.text('Site not found', 404); 398 599 } 399 600 400 - return serveFromCache(domainInfo.did, rkey, path); 601 + const headers: Record<string, string> = {}; 602 + c.req.raw.headers.forEach((value, key) => { 603 + headers[key.toLowerCase()] = value; 604 + }); 605 + return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers); 401 606 } 402 607 403 608 // Route 1: Custom domains - /* ··· 420 625 return c.text('Site not found', 404); 421 626 } 422 627 423 - return serveFromCache(customDomain.did, rkey, path); 628 + const headers: Record<string, string> = {}; 629 + c.req.raw.headers.forEach((value, key) => { 630 + headers[key.toLowerCase()] = value; 631 + }); 632 + return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 424 633 }); 425 634 426 635 // Internal observability endpoints (for admin panel) ··· 448 657 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 449 658 const stats = metricsCollector.getStats('hosting-service', timeWindow); 450 659 return c.json({ stats, timeWindow }); 660 + }); 661 + 662 + app.get('/__internal__/observability/cache', async (c) => { 663 + const { getCacheStats } = await import('./lib/cache'); 664 + const stats = getCacheStats(); 665 + return c.json({ cache: stats }); 451 666 }); 452 667 453 668 export default app;
+3 -1
hosting-service/tsconfig.json
··· 24 24 25 25 /* Code doesn't run in DOM */ 26 26 "lib": ["es2022"], 27 - } 27 + }, 28 + "include": ["src/**/*"], 29 + "exclude": ["node_modules", "cache", "dist"] 28 30 }
+9 -1
package.json
··· 17 17 "@elysiajs/openapi": "^1.4.11", 18 18 "@elysiajs/opentelemetry": "^1.4.6", 19 19 "@elysiajs/static": "^1.4.2", 20 + "@radix-ui/react-checkbox": "^1.3.3", 20 21 "@radix-ui/react-dialog": "^1.1.15", 21 22 "@radix-ui/react-label": "^2.1.7", 22 23 "@radix-ui/react-radio-group": "^1.3.8", 23 24 "@radix-ui/react-slot": "^1.2.3", 24 25 "@radix-ui/react-tabs": "^1.1.13", 25 26 "@tanstack/react-query": "^5.90.2", 27 + "actor-typeahead": "^0.1.1", 28 + "atproto-ui": "^0.11.3", 26 29 "class-variance-authority": "^0.7.1", 27 30 "clsx": "^2.1.1", 28 31 "elysia": "latest", 29 32 "iron-session": "^8.0.4", 30 33 "lucide-react": "^0.546.0", 34 + "multiformats": "^13.4.1", 35 + "prismjs": "^1.30.0", 31 36 "react": "^19.2.0", 32 37 "react-dom": "^19.2.0", 33 38 "tailwind-merge": "^3.3.1", ··· 40 45 "@types/react": "^19.2.2", 41 46 "@types/react-dom": "^19.2.1", 42 47 "bun-plugin-tailwind": "^0.1.2", 43 - "bun-types": "latest" 48 + "bun-types": "latest", 49 + "esbuild": "0.26.0" 44 50 }, 45 51 "module": "src/index.js", 46 52 "trustedDependencies": [ 53 + "bun", 54 + "cbor-extract", 47 55 "core-js", 48 56 "protobufjs" 49 57 ]
+379
public/acceptable-use/acceptable-use.tsx
··· 1 + import { createRoot } from 'react-dom/client' 2 + import Layout from '@public/layouts' 3 + import { Button } from '@public/components/ui/button' 4 + import { Card } from '@public/components/ui/card' 5 + import { ArrowLeft, Shield, AlertCircle, CheckCircle, Scale } from 'lucide-react' 6 + 7 + function AcceptableUsePage() { 8 + return ( 9 + <div className="min-h-screen bg-background"> 10 + {/* Header */} 11 + <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 12 + <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 13 + <div className="flex items-center gap-2"> 14 + <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 15 + <span className="text-xl font-semibold text-foreground"> 16 + wisp.place 17 + </span> 18 + </div> 19 + <Button 20 + variant="ghost" 21 + size="sm" 22 + onClick={() => window.location.href = '/'} 23 + > 24 + <ArrowLeft className="w-4 h-4 mr-2" /> 25 + Back to Home 26 + </Button> 27 + </div> 28 + </header> 29 + 30 + {/* Hero Section */} 31 + <div className="bg-gradient-to-b from-accent/10 to-background border-b border-border/40"> 32 + <div className="container mx-auto px-4 py-16 max-w-4xl text-center"> 33 + <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-accent/20 mb-6"> 34 + <Shield className="w-8 h-8 text-accent" /> 35 + </div> 36 + <h1 className="text-4xl md:text-5xl font-bold mb-4">Acceptable Use Policy</h1> 37 + <div className="flex items-center justify-center gap-6 text-sm text-muted-foreground"> 38 + <div className="flex items-center gap-2"> 39 + <span className="font-medium">Effective:</span> 40 + <span>November 10, 2025</span> 41 + </div> 42 + <div className="h-4 w-px bg-border"></div> 43 + <div className="flex items-center gap-2"> 44 + <span className="font-medium">Last Updated:</span> 45 + <span>November 10, 2025</span> 46 + </div> 47 + </div> 48 + </div> 49 + </div> 50 + 51 + {/* Content */} 52 + <div className="container mx-auto px-4 py-12 max-w-4xl"> 53 + <article className="space-y-12"> 54 + {/* Our Philosophy */} 55 + <section> 56 + <h2 className="text-3xl font-bold mb-6 text-foreground">Our Philosophy</h2> 57 + <div className="space-y-4 text-lg leading-relaxed text-muted-foreground"> 58 + <p> 59 + wisp.place exists to give you a corner of the internet that's truly yoursโ€”a place to create, experiment, and express yourself freely. We believe in the open web and the fundamental importance of free expression. We're not here to police your thoughts, moderate your aesthetics, or judge your taste. 60 + </p> 61 + <p> 62 + That said, we're also real people running real servers in real jurisdictions (the United States and the Netherlands), and there are legal and practical limits to what we can host. This policy aims to be as permissive as possible while keeping the lights on and staying on the right side of the law. 63 + </p> 64 + </div> 65 + </section> 66 + 67 + {/* What You Can Do */} 68 + <Card className="bg-green-500/5 border-green-500/20 p-8"> 69 + <div className="flex items-start gap-4"> 70 + <div className="flex-shrink-0"> 71 + <CheckCircle className="w-8 h-8 text-green-500" /> 72 + </div> 73 + <div className="space-y-4"> 74 + <h2 className="text-3xl font-bold text-foreground">What You Can Do</h2> 75 + <div className="space-y-4 text-lg leading-relaxed text-muted-foreground"> 76 + <p> 77 + <strong className="text-green-600 dark:text-green-400">Almost anything.</strong> Seriously. Build weird art projects. Write controversial essays. Create spaces that would make corporate platforms nervous. Express unpopular opinions. Make things that are strange, provocative, uncomfortable, or just plain yours. 78 + </p> 79 + <p> 80 + We support creative and personal expression in all its forms, including adult content, political speech, counter-cultural work, and experimental projects. 81 + </p> 82 + </div> 83 + </div> 84 + </div> 85 + </Card> 86 + 87 + {/* What You Can't Do */} 88 + <section> 89 + <div className="flex items-center gap-3 mb-6"> 90 + <AlertCircle className="w-8 h-8 text-red-500" /> 91 + <h2 className="text-3xl font-bold text-foreground">What You Can't Do</h2> 92 + </div> 93 + 94 + <div className="space-y-8"> 95 + <Card className="p-6 border-2"> 96 + <h3 className="text-2xl font-semibold mb-4 text-foreground">Illegal Content</h3> 97 + <p className="text-muted-foreground mb-4"> 98 + Don't host content that's illegal in the United States or the Netherlands. This includes but isn't limited to: 99 + </p> 100 + <ul className="space-y-3 text-muted-foreground"> 101 + <li className="flex items-start gap-3"> 102 + <span className="text-red-500 mt-1">โ€ข</span> 103 + <span><strong>Child sexual abuse material (CSAM)</strong> involving real minors in any form</span> 104 + </li> 105 + <li className="flex items-start gap-3"> 106 + <span className="text-red-500 mt-1">โ€ข</span> 107 + <span><strong>Realistic or AI-generated depictions</strong> of minors in sexual contexts, including photorealistic renders, deepfakes, or AI-generated imagery</span> 108 + </li> 109 + <li className="flex items-start gap-3"> 110 + <span className="text-red-500 mt-1">โ€ข</span> 111 + <span><strong>Non-consensual intimate imagery</strong> (revenge porn, deepfakes, hidden camera footage, etc.)</span> 112 + </li> 113 + <li className="flex items-start gap-3"> 114 + <span className="text-red-500 mt-1">โ€ข</span> 115 + <span>Content depicting or facilitating human trafficking, sexual exploitation, or sexual violence</span> 116 + </li> 117 + <li className="flex items-start gap-3"> 118 + <span className="text-red-500 mt-1">โ€ข</span> 119 + <span>Instructions for manufacturing explosives, biological weapons, or other instruments designed for mass harm</span> 120 + </li> 121 + <li className="flex items-start gap-3"> 122 + <span className="text-red-500 mt-1">โ€ข</span> 123 + <span>Content that facilitates imminent violence or terrorism</span> 124 + </li> 125 + <li className="flex items-start gap-3"> 126 + <span className="text-red-500 mt-1">โ€ข</span> 127 + <span>Stolen financial information, credentials, or personal data used for fraud</span> 128 + </li> 129 + </ul> 130 + </Card> 131 + 132 + <Card className="p-6 border-2"> 133 + <h3 className="text-2xl font-semibold mb-4 text-foreground">Intellectual Property Violations</h3> 134 + <div className="space-y-4 text-muted-foreground"> 135 + <p> 136 + Don't host content that clearly violates someone else's copyright, trademark, or other intellectual property rights. We're required to respond to valid DMCA takedown notices. 137 + </p> 138 + <p> 139 + We understand that copyright law is complicated and sometimes ridiculous. We're not going to proactively scan your site or nitpick over fair use. But if we receive a legitimate legal complaint, we'll have to act on it. 140 + </p> 141 + </div> 142 + </Card> 143 + 144 + <Card className="p-6 border-2 border-red-500/30 bg-red-500/5"> 145 + <h3 className="text-2xl font-semibold mb-4 text-foreground">Hate Content</h3> 146 + <div className="space-y-4 text-muted-foreground"> 147 + <p> 148 + You can express controversial ideas. You can be offensive. You can make people uncomfortable. But pure hateโ€”content that exists solely to dehumanize, threaten, or incite violence against people based on race, ethnicity, religion, gender, sexual orientation, disability, or similar characteristicsโ€”isn't welcome here. 149 + </p> 150 + <p> 151 + There's a difference between "I have deeply unpopular opinions about X" and "People like X should be eliminated." The former is protected expression. The latter isn't. 152 + </p> 153 + <div className="bg-background/50 border-l-4 border-red-500 p-4 rounded"> 154 + <p className="font-medium text-foreground"> 155 + <strong>A note on enforcement:</strong> While we're generally permissive and believe in giving people the benefit of the doubt, hate content is where we draw a hard line. I will be significantly more aggressive in moderating this type of content than anything else on this list. If your site exists primarily to spread hate or recruit people into hateful ideologies, you will be removed swiftly and without extensive appeals. This is non-negotiable. 156 + </p> 157 + </div> 158 + </div> 159 + </Card> 160 + 161 + <Card className="p-6 border-2"> 162 + <h3 className="text-2xl font-semibold mb-4 text-foreground">Adult Content Guidelines</h3> 163 + <div className="space-y-4 text-muted-foreground"> 164 + <p> 165 + Adult content is allowed. This includes sexually explicit material, erotica, adult artwork, and NSFW creative expression. 166 + </p> 167 + <p className="font-medium">However:</p> 168 + <ul className="space-y-2"> 169 + <li className="flex items-start gap-3"> 170 + <span className="text-red-500 mt-1">โ€ข</span> 171 + <span>No content involving real minors in any sexual context whatsoever</span> 172 + </li> 173 + <li className="flex items-start gap-3"> 174 + <span className="text-red-500 mt-1">โ€ข</span> 175 + <span>No photorealistic, AI-generated, or otherwise realistic depictions of minors in sexual contexts</span> 176 + </li> 177 + <li className="flex items-start gap-3"> 178 + <span className="text-green-500 mt-1">โ€ข</span> 179 + <span>Clearly stylized drawings and written fiction are permitted, provided they remain obviously non-photographic in nature</span> 180 + </li> 181 + <li className="flex items-start gap-3"> 182 + <span className="text-red-500 mt-1">โ€ข</span> 183 + <span>No non-consensual content (revenge porn, voyeurism, etc.)</span> 184 + </li> 185 + <li className="flex items-start gap-3"> 186 + <span className="text-red-500 mt-1">โ€ข</span> 187 + <span>No content depicting illegal sexual acts (bestiality, necrophilia, etc.)</span> 188 + </li> 189 + <li className="flex items-start gap-3"> 190 + <span className="text-yellow-500 mt-1">โ€ข</span> 191 + <span>Adult content should be clearly marked as such if discoverable through public directories or search</span> 192 + </li> 193 + </ul> 194 + </div> 195 + </Card> 196 + 197 + <Card className="p-6 border-2"> 198 + <h3 className="text-2xl font-semibold mb-4 text-foreground">Malicious Technical Activity</h3> 199 + <p className="text-muted-foreground mb-4">Don't use your site to:</p> 200 + <ul className="space-y-2 text-muted-foreground"> 201 + <li className="flex items-start gap-3"> 202 + <span className="text-red-500 mt-1">โ€ข</span> 203 + <span>Distribute malware, viruses, or exploits</span> 204 + </li> 205 + <li className="flex items-start gap-3"> 206 + <span className="text-red-500 mt-1">โ€ข</span> 207 + <span>Conduct phishing or social engineering attacks</span> 208 + </li> 209 + <li className="flex items-start gap-3"> 210 + <span className="text-red-500 mt-1">โ€ข</span> 211 + <span>Launch DDoS attacks or network abuse</span> 212 + </li> 213 + <li className="flex items-start gap-3"> 214 + <span className="text-red-500 mt-1">โ€ข</span> 215 + <span>Mine cryptocurrency without explicit user consent</span> 216 + </li> 217 + <li className="flex items-start gap-3"> 218 + <span className="text-red-500 mt-1">โ€ข</span> 219 + <span>Scrape, spam, or abuse other services</span> 220 + </li> 221 + </ul> 222 + </Card> 223 + </div> 224 + </section> 225 + 226 + {/* Our Approach to Enforcement */} 227 + <section> 228 + <div className="flex items-center gap-3 mb-6"> 229 + <Scale className="w-8 h-8 text-accent" /> 230 + <h2 className="text-3xl font-bold text-foreground">Our Approach to Enforcement</h2> 231 + </div> 232 + <div className="space-y-6"> 233 + <div className="space-y-4 text-lg leading-relaxed text-muted-foreground"> 234 + <p> 235 + <strong>We actively monitor for obvious violations.</strong> Not to censor your creativity or police your opinions, but to catch the clear-cut stuff that threatens the service's existence and makes this a worse place for everyone. We're looking for the blatantly illegal, the obviously harmfulโ€”the stuff that would get servers seized and communities destroyed. 236 + </p> 237 + <p> 238 + We're not reading your blog posts looking for wrongthink. We're making sure this platform doesn't become a haven for the kind of content that ruins good things. 239 + </p> 240 + </div> 241 + 242 + <Card className="p-6 bg-muted/30"> 243 + <p className="font-semibold mb-3 text-foreground">We take action when:</p> 244 + <ol className="space-y-2 text-muted-foreground"> 245 + <li className="flex items-start gap-3"> 246 + <span className="font-bold text-accent">1.</span> 247 + <span>We identify content that clearly violates this policy during routine monitoring</span> 248 + </li> 249 + <li className="flex items-start gap-3"> 250 + <span className="font-bold text-accent">2.</span> 251 + <span>We receive a valid legal complaint (DMCA, court order, etc.)</span> 252 + </li> 253 + <li className="flex items-start gap-3"> 254 + <span className="font-bold text-accent">3.</span> 255 + <span>Someone reports content that violates this policy and we can verify the violation</span> 256 + </li> 257 + <li className="flex items-start gap-3"> 258 + <span className="font-bold text-accent">4.</span> 259 + <span>Your site is causing technical problems for the service or other users</span> 260 + </li> 261 + </ol> 262 + </Card> 263 + 264 + <Card className="p-6 bg-muted/30"> 265 + <p className="font-semibold mb-3 text-foreground">When we do need to take action, we'll try to:</p> 266 + <ul className="space-y-2 text-muted-foreground"> 267 + <li className="flex items-start gap-3"> 268 + <span className="text-accent">โ€ข</span> 269 + <span>Contact you first when legally and practically possible</span> 270 + </li> 271 + <li className="flex items-start gap-3"> 272 + <span className="text-accent">โ€ข</span> 273 + <span>Be transparent about what's happening and why</span> 274 + </li> 275 + <li className="flex items-start gap-3"> 276 + <span className="text-accent">โ€ข</span> 277 + <span>Give you an opportunity to address the issue if appropriate</span> 278 + </li> 279 + </ul> 280 + </Card> 281 + 282 + <p className="text-muted-foreground"> 283 + For serious or repeated violations, we may suspend or terminate your account. 284 + </p> 285 + </div> 286 + </section> 287 + 288 + {/* Regional Compliance */} 289 + <Card className="p-6 bg-blue-500/5 border-blue-500/20"> 290 + <h2 className="text-2xl font-bold mb-4 text-foreground">Regional Compliance</h2> 291 + <p className="text-muted-foreground"> 292 + Our servers are located in the United States and the Netherlands. Content hosted on wisp.place must comply with the laws of both jurisdictions. While we aim to provide broad creative freedom, these legal requirements are non-negotiable. 293 + </p> 294 + </Card> 295 + 296 + {/* Changes to This Policy */} 297 + <section> 298 + <h2 className="text-2xl font-bold mb-4 text-foreground">Changes to This Policy</h2> 299 + <p className="text-muted-foreground"> 300 + We may update this policy as legal requirements or service realities change. If we make significant changes, we'll notify active users. 301 + </p> 302 + </section> 303 + 304 + {/* Questions or Reports */} 305 + <section> 306 + <h2 className="text-2xl font-bold mb-4 text-foreground">Questions or Reports</h2> 307 + <p className="text-muted-foreground"> 308 + If you have questions about this policy or need to report a violation, contact us at{' '} 309 + <a 310 + href="mailto:contact@wisp.place" 311 + className="text-accent hover:text-accent/80 transition-colors font-medium" 312 + > 313 + contact@wisp.place 314 + </a> 315 + . 316 + </p> 317 + </section> 318 + 319 + {/* Final Message */} 320 + <Card className="p-8 bg-accent/10 border-accent/30 border-2"> 321 + <p className="text-lg leading-relaxed text-foreground"> 322 + <strong>Remember:</strong> This policy exists to keep the service running and this community healthy, not to limit your creativity. When in doubt, ask yourself: "Is this likely to get real-world authorities knocking on doors or make this place worse for everyone?" If the answer is yes, it probably doesn't belong here. Everything else? Go wild. 323 + </p> 324 + </Card> 325 + </article> 326 + </div> 327 + 328 + {/* Footer */} 329 + <footer className="border-t border-border/40 bg-muted/20 mt-12"> 330 + <div className="container mx-auto px-4 py-8"> 331 + <div className="text-center text-sm text-muted-foreground"> 332 + <p> 333 + Built by{' '} 334 + <a 335 + href="https://bsky.app/profile/nekomimi.pet" 336 + target="_blank" 337 + rel="noopener noreferrer" 338 + className="text-accent hover:text-accent/80 transition-colors font-medium" 339 + > 340 + @nekomimi.pet 341 + </a> 342 + {' โ€ข '} 343 + Contact:{' '} 344 + <a 345 + href="mailto:contact@wisp.place" 346 + className="text-accent hover:text-accent/80 transition-colors font-medium" 347 + > 348 + contact@wisp.place 349 + </a> 350 + {' โ€ข '} 351 + Legal/DMCA:{' '} 352 + <a 353 + href="mailto:legal@wisp.place" 354 + className="text-accent hover:text-accent/80 transition-colors font-medium" 355 + > 356 + legal@wisp.place 357 + </a> 358 + </p> 359 + <p className="mt-2"> 360 + <a 361 + href="/acceptable-use" 362 + className="text-accent hover:text-accent/80 transition-colors font-medium" 363 + > 364 + Acceptable Use Policy 365 + </a> 366 + </p> 367 + </div> 368 + </div> 369 + </footer> 370 + </div> 371 + ) 372 + } 373 + 374 + const root = createRoot(document.getElementById('elysia')!) 375 + root.render( 376 + <Layout className="gap-6"> 377 + <AcceptableUsePage /> 378 + </Layout> 379 + )
+35
public/acceptable-use/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Acceptable Use Policy - wisp.place</title> 7 + <meta name="description" content="Acceptable Use Policy for wisp.place - Guidelines for hosting content on our decentralized static site hosting platform." /> 8 + 9 + <!-- Open Graph / Facebook --> 10 + <meta property="og:type" content="website" /> 11 + <meta property="og:url" content="https://wisp.place/acceptable-use" /> 12 + <meta property="og:title" content="Acceptable Use Policy - wisp.place" /> 13 + <meta property="og:description" content="Acceptable Use Policy for wisp.place - Guidelines for hosting content on our decentralized static site hosting platform." /> 14 + <meta property="og:site_name" content="wisp.place" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary_large_image" /> 18 + <meta name="twitter:url" content="https://wisp.place/acceptable-use" /> 19 + <meta name="twitter:title" content="Acceptable Use Policy - wisp.place" /> 20 + <meta name="twitter:description" content="Acceptable Use Policy for wisp.place - Guidelines for hosting content on our decentralized static site hosting platform." /> 21 + 22 + <!-- Theme --> 23 + <meta name="theme-color" content="#7c3aed" /> 24 + 25 + <link rel="icon" type="image/x-icon" href="../favicon.ico"> 26 + <link rel="icon" type="image/png" sizes="32x32" href="../favicon-32x32.png"> 27 + <link rel="icon" type="image/png" sizes="16x16" href="../favicon-16x16.png"> 28 + <link rel="apple-touch-icon" sizes="180x180" href="../apple-touch-icon.png"> 29 + <link rel="manifest" href="../site.webmanifest"> 30 + </head> 31 + <body> 32 + <div id="elysia"></div> 33 + <script type="module" src="./acceptable-use.tsx"></script> 34 + </body> 35 + </html>
+7 -1
public/admin/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>Admin Dashboard - Wisp.place</title> 6 + <title>wisp.place</title> 7 + <meta name="description" content="Admin dashboard for wisp.place decentralized static site hosting." /> 8 + <meta name="robots" content="noindex, nofollow" /> 9 + 10 + <!-- Theme --> 11 + <meta name="theme-color" content="#7c3aed" /> 12 + 7 13 <link rel="stylesheet" href="./styles.css" /> 8 14 </head> 9 15 <body>
public/android-chrome-192x192.png

This is a binary file and will not be displayed.

public/android-chrome-512x512.png

This is a binary file and will not be displayed.

public/apple-touch-icon.png

This is a binary file and will not be displayed.

+30
public/components/ui/checkbox.tsx
··· 1 + import * as React from "react" 2 + import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 + import { CheckIcon } from "lucide-react" 4 + 5 + import { cn } from "@public/lib/utils" 6 + 7 + function Checkbox({ 8 + className, 9 + ...props 10 + }: React.ComponentProps<typeof CheckboxPrimitive.Root>) { 11 + return ( 12 + <CheckboxPrimitive.Root 13 + data-slot="checkbox" 14 + className={cn( 15 + "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", 16 + className 17 + )} 18 + {...props} 19 + > 20 + <CheckboxPrimitive.Indicator 21 + data-slot="checkbox-indicator" 22 + className="grid place-content-center text-current transition-none" 23 + > 24 + <CheckIcon className="size-3.5" /> 25 + </CheckboxPrimitive.Indicator> 26 + </CheckboxPrimitive.Root> 27 + ) 28 + } 29 + 30 + export { Checkbox }
+104
public/components/ui/code-block.tsx
··· 1 + import { useEffect, useRef, useState } from 'react' 2 + 3 + declare global { 4 + interface Window { 5 + Prism: { 6 + languages: Record<string, any> 7 + highlightElement: (element: HTMLElement) => void 8 + highlightAll: () => void 9 + } 10 + } 11 + } 12 + 13 + interface CodeBlockProps { 14 + code: string 15 + language?: 'bash' | 'yaml' 16 + className?: string 17 + } 18 + 19 + export function CodeBlock({ code, language = 'bash', className = '' }: CodeBlockProps) { 20 + const [isThemeLoaded, setIsThemeLoaded] = useState(false) 21 + const codeRef = useRef<HTMLElement>(null) 22 + 23 + useEffect(() => { 24 + // Load Catppuccin theme CSS 25 + const loadTheme = async () => { 26 + // Detect if user prefers dark mode 27 + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches 28 + const theme = prefersDark ? 'mocha' : 'latte' 29 + 30 + // Remove any existing theme CSS 31 + const existingTheme = document.querySelector('link[data-prism-theme]') 32 + if (existingTheme) { 33 + existingTheme.remove() 34 + } 35 + 36 + // Load the appropriate Catppuccin theme 37 + const link = document.createElement('link') 38 + link.rel = 'stylesheet' 39 + link.href = `https://prismjs.catppuccin.com/${theme}.css` 40 + link.setAttribute('data-prism-theme', theme) 41 + document.head.appendChild(link) 42 + 43 + // Load PrismJS if not already loaded 44 + if (!window.Prism) { 45 + const script = document.createElement('script') 46 + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js' 47 + script.onload = () => { 48 + // Load language support if needed 49 + if (language === 'yaml' && !window.Prism.languages.yaml) { 50 + const yamlScript = document.createElement('script') 51 + yamlScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-yaml.min.js' 52 + yamlScript.onload = () => setIsThemeLoaded(true) 53 + document.head.appendChild(yamlScript) 54 + } else { 55 + setIsThemeLoaded(true) 56 + } 57 + } 58 + document.head.appendChild(script) 59 + } else { 60 + setIsThemeLoaded(true) 61 + } 62 + } 63 + 64 + loadTheme() 65 + 66 + // Listen for theme changes 67 + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') 68 + const handleThemeChange = () => loadTheme() 69 + mediaQuery.addEventListener('change', handleThemeChange) 70 + 71 + return () => { 72 + mediaQuery.removeEventListener('change', handleThemeChange) 73 + } 74 + }, [language]) 75 + 76 + // Highlight code when Prism is loaded and component is mounted 77 + useEffect(() => { 78 + if (isThemeLoaded && codeRef.current && window.Prism) { 79 + window.Prism.highlightElement(codeRef.current) 80 + } 81 + }, [isThemeLoaded, code]) 82 + 83 + if (!isThemeLoaded) { 84 + return ( 85 + <pre className={`p-4 bg-muted rounded-lg overflow-x-auto ${className}`}> 86 + <code>{code.trim()}</code> 87 + </pre> 88 + ) 89 + } 90 + 91 + // Map language to Prism language class 92 + const languageMap = { 93 + 'bash': 'language-bash', 94 + 'yaml': 'language-yaml' 95 + } 96 + 97 + const prismLanguage = languageMap[language] || 'language-bash' 98 + 99 + return ( 100 + <pre className={`p-4 rounded-lg overflow-x-auto ${className}`}> 101 + <code ref={codeRef} className={prismLanguage}>{code.trim()}</code> 102 + </pre> 103 + ) 104 + }
+1 -1
public/components/ui/radio-group.tsx
··· 27 27 <RadioGroupPrimitive.Item 28 28 data-slot="radio-group-item" 29 29 className={cn( 30 - "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", 30 + "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/50 aspect-square size-4 shrink-0 rounded-full border border-black/30 dark:border-white/30 shadow-inner transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", 31 31 className 32 32 )} 33 33 {...props}
+2 -2
public/components/ui/tabs.tsx
··· 24 24 <TabsPrimitive.List 25 25 data-slot="tabs-list" 26 26 className={cn( 27 - "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", 27 + "bg-muted dark:bg-muted/80 text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", 28 28 className 29 29 )} 30 30 {...props} ··· 40 40 <TabsPrimitive.Trigger 41 41 data-slot="tabs-trigger" 42 42 className={cn( 43 - "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 43 + "data-[state=active]:bg-background dark:data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-border focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-border dark:data-[state=active]:shadow-sm text-foreground dark:text-muted-foreground/70 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 44 44 className 45 45 )} 46 46 {...props}
+65
public/editor/components/TabSkeleton.tsx
··· 1 + import { 2 + Card, 3 + CardContent, 4 + CardDescription, 5 + CardHeader, 6 + CardTitle 7 + } from '@public/components/ui/card' 8 + 9 + // Shimmer animation for skeleton loading 10 + const Shimmer = () => ( 11 + <div className="animate-pulse"> 12 + <div className="h-4 bg-muted rounded w-3/4 mb-2"></div> 13 + <div className="h-4 bg-muted rounded w-1/2"></div> 14 + </div> 15 + ) 16 + 17 + const SkeletonLine = ({ className = '' }: { className?: string }) => ( 18 + <div className={`animate-pulse bg-muted rounded ${className}`}></div> 19 + ) 20 + 21 + export function TabSkeleton() { 22 + return ( 23 + <div className="space-y-4 min-h-[400px]"> 24 + <Card> 25 + <CardHeader> 26 + <div className="space-y-2"> 27 + <SkeletonLine className="h-6 w-1/3" /> 28 + <SkeletonLine className="h-4 w-2/3" /> 29 + </div> 30 + </CardHeader> 31 + <CardContent className="space-y-4"> 32 + {/* Skeleton content items */} 33 + <div className="p-4 border border-border rounded-lg"> 34 + <SkeletonLine className="h-5 w-1/2 mb-3" /> 35 + <SkeletonLine className="h-4 w-3/4 mb-2" /> 36 + <SkeletonLine className="h-4 w-2/3" /> 37 + </div> 38 + <div className="p-4 border border-border rounded-lg"> 39 + <SkeletonLine className="h-5 w-1/2 mb-3" /> 40 + <SkeletonLine className="h-4 w-3/4 mb-2" /> 41 + <SkeletonLine className="h-4 w-2/3" /> 42 + </div> 43 + <div className="p-4 border border-border rounded-lg"> 44 + <SkeletonLine className="h-5 w-1/2 mb-3" /> 45 + <SkeletonLine className="h-4 w-3/4 mb-2" /> 46 + <SkeletonLine className="h-4 w-2/3" /> 47 + </div> 48 + </CardContent> 49 + </Card> 50 + 51 + <Card> 52 + <CardHeader> 53 + <div className="space-y-2"> 54 + <SkeletonLine className="h-6 w-1/4" /> 55 + <SkeletonLine className="h-4 w-1/2" /> 56 + </div> 57 + </CardHeader> 58 + <CardContent className="space-y-3"> 59 + <SkeletonLine className="h-10 w-full" /> 60 + <SkeletonLine className="h-4 w-3/4" /> 61 + </CardContent> 62 + </Card> 63 + </div> 64 + ) 65 + }
+249 -1144
public/editor/editor.tsx
··· 2 2 import { createRoot } from 'react-dom/client' 3 3 import { Button } from '@public/components/ui/button' 4 4 import { 5 - Card, 6 - CardContent, 7 - CardDescription, 8 - CardHeader, 9 - CardTitle 10 - } from '@public/components/ui/card' 11 - import { Input } from '@public/components/ui/input' 12 - import { Label } from '@public/components/ui/label' 13 - import { 14 5 Tabs, 15 6 TabsContent, 16 7 TabsList, 17 8 TabsTrigger 18 9 } from '@public/components/ui/tabs' 19 - import { Badge } from '@public/components/ui/badge' 20 10 import { 21 11 Dialog, 22 12 DialogContent, ··· 25 15 DialogTitle, 26 16 DialogFooter 27 17 } from '@public/components/ui/dialog' 18 + import { Checkbox } from '@public/components/ui/checkbox' 19 + import { Label } from '@public/components/ui/label' 20 + import { Badge } from '@public/components/ui/badge' 28 21 import { 29 - Globe, 30 - Upload, 31 - ExternalLink, 32 - CheckCircle2, 33 - XCircle, 34 - AlertCircle, 35 22 Loader2, 36 23 Trash2, 37 - RefreshCw, 38 - Settings 24 + LogOut 39 25 } from 'lucide-react' 40 - import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 41 - 42 26 import Layout from '@public/layouts' 43 - 44 - interface UserInfo { 45 - did: string 46 - handle: string 47 - } 48 - 49 - interface Site { 50 - did: string 51 - rkey: string 52 - display_name: string | null 53 - created_at: number 54 - updated_at: number 55 - } 56 - 57 - interface CustomDomain { 58 - id: string 59 - domain: string 60 - did: string 61 - rkey: string 62 - verified: boolean 63 - last_verified_at: number | null 64 - created_at: number 65 - } 66 - 67 - interface WispDomain { 68 - domain: string 69 - rkey: string | null 70 - } 27 + import { useUserInfo } from './hooks/useUserInfo' 28 + import { useSiteData, type SiteWithDomains } from './hooks/useSiteData' 29 + import { useDomainData } from './hooks/useDomainData' 30 + import { SitesTab } from './tabs/SitesTab' 31 + import { DomainsTab } from './tabs/DomainsTab' 32 + import { UploadTab } from './tabs/UploadTab' 33 + import { CLITab } from './tabs/CLITab' 71 34 72 35 function Dashboard() { 73 - // User state 74 - const [userInfo, setUserInfo] = useState<UserInfo | null>(null) 75 - const [loading, setLoading] = useState(true) 76 - 77 - // Sites state 78 - const [sites, setSites] = useState<Site[]>([]) 79 - const [sitesLoading, setSitesLoading] = useState(true) 80 - const [isSyncing, setIsSyncing] = useState(false) 36 + // Use custom hooks 37 + const { userInfo, loading, fetchUserInfo } = useUserInfo() 38 + const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData() 39 + const { 40 + wispDomains, 41 + customDomains, 42 + domainsLoading, 43 + verificationStatus, 44 + fetchDomains, 45 + addCustomDomain, 46 + verifyDomain, 47 + deleteCustomDomain, 48 + mapWispDomain, 49 + deleteWispDomain, 50 + mapCustomDomain, 51 + claimWispDomain, 52 + checkWispAvailability 53 + } = useDomainData() 81 54 82 - // Domains state 83 - const [wispDomain, setWispDomain] = useState<WispDomain | null>(null) 84 - const [customDomains, setCustomDomains] = useState<CustomDomain[]>([]) 85 - const [domainsLoading, setDomainsLoading] = useState(true) 86 - 87 - // Site configuration state 88 - const [configuringSite, setConfiguringSite] = useState<Site | null>(null) 89 - const [selectedDomain, setSelectedDomain] = useState<string>('') 55 + // Site configuration modal state (shared across components) 56 + const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null) 57 + const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set()) 90 58 const [isSavingConfig, setIsSavingConfig] = useState(false) 91 59 const [isDeletingSite, setIsDeletingSite] = useState(false) 92 60 93 - // Upload state 94 - const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing') 95 - const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('') 96 - const [newSiteName, setNewSiteName] = useState('') 97 - const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 98 - const [isUploading, setIsUploading] = useState(false) 99 - const [uploadProgress, setUploadProgress] = useState('') 100 - const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 101 - const [uploadedCount, setUploadedCount] = useState(0) 102 - 103 - // Custom domain modal state 104 - const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) 105 - const [customDomain, setCustomDomain] = useState('') 106 - const [isAddingDomain, setIsAddingDomain] = useState(false) 107 - const [verificationStatus, setVerificationStatus] = useState<{ 108 - [id: string]: 'idle' | 'verifying' | 'success' | 'error' 109 - }>({}) 110 - const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null) 111 - 112 - // Wisp domain claim state 113 - const [wispHandle, setWispHandle] = useState('') 114 - const [isClaimingWisp, setIsClaimingWisp] = useState(false) 115 - const [wispAvailability, setWispAvailability] = useState<{ 116 - available: boolean | null 117 - checking: boolean 118 - }>({ available: null, checking: false }) 119 - 120 - // Fetch user info on mount 61 + // Fetch initial data on mount 121 62 useEffect(() => { 122 63 fetchUserInfo() 123 64 fetchSites() 124 65 fetchDomains() 125 66 }, []) 126 67 127 - // Auto-switch to 'new' mode if no sites exist 128 - useEffect(() => { 129 - if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { 130 - setSiteMode('new') 131 - } 132 - }, [sites, sitesLoading, siteMode]) 68 + // Handle site configuration modal 69 + const handleConfigureSite = (site: SiteWithDomains) => { 70 + setConfiguringSite(site) 133 71 134 - const fetchUserInfo = async () => { 135 - try { 136 - const response = await fetch('/api/user/info') 137 - const data = await response.json() 138 - setUserInfo(data) 139 - } catch (err) { 140 - console.error('Failed to fetch user info:', err) 141 - } finally { 142 - setLoading(false) 143 - } 144 - } 72 + // Build set of currently mapped domains 73 + const mappedDomains = new Set<string>() 145 74 146 - const fetchSites = async () => { 147 - try { 148 - const response = await fetch('/api/user/sites') 149 - const data = await response.json() 150 - setSites(data.sites || []) 151 - } catch (err) { 152 - console.error('Failed to fetch sites:', err) 153 - } finally { 154 - setSitesLoading(false) 155 - } 156 - } 157 - 158 - const syncSites = async () => { 159 - setIsSyncing(true) 160 - try { 161 - const response = await fetch('/api/user/sync', { 162 - method: 'POST' 75 + if (site.domains) { 76 + site.domains.forEach(domainInfo => { 77 + if (domainInfo.type === 'wisp') { 78 + // For wisp domains, use the domain itself as the identifier 79 + mappedDomains.add(`wisp:${domainInfo.domain}`) 80 + } else if (domainInfo.id) { 81 + mappedDomains.add(domainInfo.id) 82 + } 163 83 }) 164 - const data = await response.json() 165 - if (data.success) { 166 - console.log(`Synced ${data.synced} sites from PDS`) 167 - // Refresh sites list 168 - await fetchSites() 169 - } 170 - } catch (err) { 171 - console.error('Failed to sync sites:', err) 172 - alert('Failed to sync sites from PDS') 173 - } finally { 174 - setIsSyncing(false) 175 84 } 176 - } 177 85 178 - const fetchDomains = async () => { 179 - try { 180 - const response = await fetch('/api/user/domains') 181 - const data = await response.json() 182 - setWispDomain(data.wispDomain) 183 - setCustomDomains(data.customDomains || []) 184 - } catch (err) { 185 - console.error('Failed to fetch domains:', err) 186 - } finally { 187 - setDomainsLoading(false) 188 - } 86 + setSelectedDomains(mappedDomains) 189 87 } 190 88 191 - const getSiteUrl = (site: Site) => { 192 - // Check if this site is mapped to the wisp.place domain 193 - if (wispDomain && wispDomain.rkey === site.rkey) { 194 - return `https://${wispDomain.domain}` 195 - } 196 - 197 - // Check if this site is mapped to any custom domain 198 - const customDomain = customDomains.find((d) => d.rkey === site.rkey) 199 - if (customDomain) { 200 - return `https://${customDomain.domain}` 201 - } 202 - 203 - // Default fallback URL 204 - if (!userInfo) return '#' 205 - return `https://sites.wisp.place/${site.did}/${site.rkey}` 206 - } 89 + const handleSaveSiteConfig = async () => { 90 + if (!configuringSite) return 207 91 208 - const getSiteDomainName = (site: Site) => { 209 - if (wispDomain && wispDomain.rkey === site.rkey) { 210 - return wispDomain.domain 211 - } 212 - 213 - const customDomain = customDomains.find((d) => d.rkey === site.rkey) 214 - if (customDomain) { 215 - return customDomain.domain 216 - } 217 - 218 - return `sites.wisp.place/${site.did}/${site.rkey}` 219 - } 220 - 221 - const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 222 - if (e.target.files && e.target.files.length > 0) { 223 - setSelectedFiles(e.target.files) 224 - } 225 - } 226 - 227 - const handleUpload = async () => { 228 - const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName 229 - 230 - if (!siteName) { 231 - alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') 232 - return 233 - } 234 - 235 - setIsUploading(true) 236 - setUploadProgress('Preparing files...') 237 - 92 + setIsSavingConfig(true) 238 93 try { 239 - const formData = new FormData() 240 - formData.append('siteName', siteName) 241 - 242 - if (selectedFiles) { 243 - for (let i = 0; i < selectedFiles.length; i++) { 244 - formData.append('files', selectedFiles[i]) 245 - } 246 - } 247 - 248 - setUploadProgress('Uploading to AT Protocol...') 249 - const response = await fetch('/wisp/upload-files', { 250 - method: 'POST', 251 - body: formData 252 - }) 253 - 254 - const data = await response.json() 255 - if (data.success) { 256 - setUploadProgress('Upload complete!') 257 - setSkippedFiles(data.skippedFiles || []) 258 - setUploadedCount(data.uploadedCount || data.fileCount || 0) 259 - setSelectedSiteRkey('') 260 - setNewSiteName('') 261 - setSelectedFiles(null) 262 - 263 - // Refresh sites list 264 - await fetchSites() 94 + // Handle wisp domain mappings 95 + const selectedWispDomainIds = Array.from(selectedDomains).filter(id => id.startsWith('wisp:')) 96 + const selectedWispDomains = selectedWispDomainIds.map(id => id.replace('wisp:', '')) 265 97 266 - // Reset form - give more time if there are skipped files 267 - const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500 268 - setTimeout(() => { 269 - setUploadProgress('') 270 - setSkippedFiles([]) 271 - setUploadedCount(0) 272 - setIsUploading(false) 273 - }, resetDelay) 274 - } else { 275 - throw new Error(data.error || 'Upload failed') 276 - } 277 - } catch (err) { 278 - console.error('Upload error:', err) 279 - alert( 280 - `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` 98 + // Get currently mapped wisp domains 99 + const currentlyMappedWispDomains = wispDomains.filter( 100 + d => d.rkey === configuringSite.rkey 281 101 ) 282 - setIsUploading(false) 283 - setUploadProgress('') 284 - } 285 - } 286 102 287 - const handleAddCustomDomain = async () => { 288 - if (!customDomain) { 289 - alert('Please enter a domain') 290 - return 291 - } 292 - 293 - setIsAddingDomain(true) 294 - try { 295 - const response = await fetch('/api/domain/custom/add', { 296 - method: 'POST', 297 - headers: { 'Content-Type': 'application/json' }, 298 - body: JSON.stringify({ domain: customDomain }) 299 - }) 300 - 301 - const data = await response.json() 302 - if (data.success) { 303 - setCustomDomain('') 304 - setAddDomainModalOpen(false) 305 - await fetchDomains() 306 - 307 - // Automatically show DNS configuration for the newly added domain 308 - setViewDomainDNS(data.id) 309 - } else { 310 - throw new Error(data.error || 'Failed to add domain') 103 + // Unmap wisp domains that are no longer selected 104 + for (const domain of currentlyMappedWispDomains) { 105 + if (!selectedWispDomains.includes(domain.domain)) { 106 + await mapWispDomain(domain.domain, null) 107 + } 311 108 } 312 - } catch (err) { 313 - console.error('Add domain error:', err) 314 - alert( 315 - `Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}` 316 - ) 317 - } finally { 318 - setIsAddingDomain(false) 319 - } 320 - } 321 109 322 - const handleVerifyDomain = async (id: string) => { 323 - setVerificationStatus({ ...verificationStatus, [id]: 'verifying' }) 324 - 325 - try { 326 - const response = await fetch('/api/domain/custom/verify', { 327 - method: 'POST', 328 - headers: { 'Content-Type': 'application/json' }, 329 - body: JSON.stringify({ id }) 330 - }) 331 - 332 - const data = await response.json() 333 - if (data.success && data.verified) { 334 - setVerificationStatus({ ...verificationStatus, [id]: 'success' }) 335 - await fetchDomains() 336 - } else { 337 - setVerificationStatus({ ...verificationStatus, [id]: 'error' }) 338 - if (data.error) { 339 - alert(`Verification failed: ${data.error}`) 110 + // Map newly selected wisp domains 111 + for (const domainName of selectedWispDomains) { 112 + const isAlreadyMapped = currentlyMappedWispDomains.some(d => d.domain === domainName) 113 + if (!isAlreadyMapped) { 114 + await mapWispDomain(domainName, configuringSite.rkey) 340 115 } 341 116 } 342 - } catch (err) { 343 - console.error('Verify domain error:', err) 344 - setVerificationStatus({ ...verificationStatus, [id]: 'error' }) 345 - alert( 346 - `Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}` 347 - ) 348 - } 349 - } 350 117 351 - const handleDeleteCustomDomain = async (id: string) => { 352 - if (!confirm('Are you sure you want to remove this custom domain?')) { 353 - return 354 - } 355 - 356 - try { 357 - const response = await fetch(`/api/domain/custom/${id}`, { 358 - method: 'DELETE' 359 - }) 360 - 361 - const data = await response.json() 362 - if (data.success) { 363 - await fetchDomains() 364 - } else { 365 - throw new Error('Failed to delete domain') 366 - } 367 - } catch (err) { 368 - console.error('Delete domain error:', err) 369 - alert( 370 - `Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}` 118 + // Handle custom domain mappings 119 + const selectedCustomDomainIds = Array.from(selectedDomains).filter(id => !id.startsWith('wisp:')) 120 + const currentlyMappedCustomDomains = customDomains.filter( 121 + d => d.rkey === configuringSite.rkey 371 122 ) 372 - } 373 - } 374 123 375 - const handleConfigureSite = (site: Site) => { 376 - setConfiguringSite(site) 377 - 378 - // Determine current domain mapping 379 - if (wispDomain && wispDomain.rkey === site.rkey) { 380 - setSelectedDomain('wisp') 381 - } else { 382 - const customDomain = customDomains.find((d) => d.rkey === site.rkey) 383 - if (customDomain) { 384 - setSelectedDomain(customDomain.id) 385 - } else { 386 - setSelectedDomain('none') 124 + // Unmap domains that are no longer selected 125 + for (const domain of currentlyMappedCustomDomains) { 126 + if (!selectedCustomDomainIds.includes(domain.id)) { 127 + await mapCustomDomain(domain.id, null) 128 + } 387 129 } 388 - } 389 - } 390 130 391 - const handleSaveSiteConfig = async () => { 392 - if (!configuringSite) return 393 - 394 - setIsSavingConfig(true) 395 - try { 396 - if (selectedDomain === 'wisp') { 397 - // Map to wisp.place domain 398 - const response = await fetch('/api/domain/wisp/map-site', { 399 - method: 'POST', 400 - headers: { 'Content-Type': 'application/json' }, 401 - body: JSON.stringify({ siteRkey: configuringSite.rkey }) 402 - }) 403 - const data = await response.json() 404 - if (!data.success) throw new Error('Failed to map site') 405 - } else if (selectedDomain === 'none') { 406 - // Unmap from all domains 407 - // Unmap wisp domain if this site was mapped to it 408 - if (wispDomain && wispDomain.rkey === configuringSite.rkey) { 409 - await fetch('/api/domain/wisp/map-site', { 410 - method: 'POST', 411 - headers: { 'Content-Type': 'application/json' }, 412 - body: JSON.stringify({ siteRkey: null }) 413 - }) 414 - } 415 - 416 - // Unmap from custom domains 417 - const mappedCustom = customDomains.find( 418 - (d) => d.rkey === configuringSite.rkey 419 - ) 420 - if (mappedCustom) { 421 - await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, { 422 - method: 'POST', 423 - headers: { 'Content-Type': 'application/json' }, 424 - body: JSON.stringify({ siteRkey: null }) 425 - }) 131 + // Map newly selected domains 132 + for (const domainId of selectedCustomDomainIds) { 133 + const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId) 134 + if (!isAlreadyMapped) { 135 + await mapCustomDomain(domainId, configuringSite.rkey) 426 136 } 427 - } else { 428 - // Map to a custom domain 429 - const response = await fetch( 430 - `/api/domain/custom/${selectedDomain}/map-site`, 431 - { 432 - method: 'POST', 433 - headers: { 'Content-Type': 'application/json' }, 434 - body: JSON.stringify({ siteRkey: configuringSite.rkey }) 435 - } 436 - ) 437 - const data = await response.json() 438 - if (!data.success) throw new Error('Failed to map site') 439 137 } 440 138 441 - // Refresh domains to get updated mappings 139 + // Refresh both domains and sites to get updated mappings 442 140 await fetchDomains() 141 + await fetchSites() 443 142 setConfiguringSite(null) 444 143 } catch (err) { 445 144 console.error('Save config error:', err) ··· 459 158 } 460 159 461 160 setIsDeletingSite(true) 462 - try { 463 - const response = await fetch(`/api/site/${configuringSite.rkey}`, { 464 - method: 'DELETE' 465 - }) 466 - 467 - const data = await response.json() 468 - if (data.success) { 469 - // Refresh sites list 470 - await fetchSites() 471 - // Refresh domains in case this site was mapped 472 - await fetchDomains() 473 - setConfiguringSite(null) 474 - } else { 475 - throw new Error(data.error || 'Failed to delete site') 476 - } 477 - } catch (err) { 478 - console.error('Delete site error:', err) 479 - alert( 480 - `Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}` 481 - ) 482 - } finally { 483 - setIsDeletingSite(false) 161 + const success = await deleteSite(configuringSite.rkey) 162 + if (success) { 163 + // Refresh domains in case this site was mapped 164 + await fetchDomains() 165 + setConfiguringSite(null) 484 166 } 167 + setIsDeletingSite(false) 485 168 } 486 169 487 - const checkWispAvailability = async (handle: string) => { 488 - const trimmedHandle = handle.trim().toLowerCase() 489 - if (!trimmedHandle) { 490 - setWispAvailability({ available: null, checking: false }) 491 - return 492 - } 493 - 494 - setWispAvailability({ available: null, checking: true }) 495 - try { 496 - const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`) 497 - const data = await response.json() 498 - setWispAvailability({ available: data.available, checking: false }) 499 - } catch (err) { 500 - console.error('Check availability error:', err) 501 - setWispAvailability({ available: false, checking: false }) 502 - } 170 + const handleUploadComplete = async () => { 171 + await fetchSites() 503 172 } 504 173 505 - const handleClaimWispDomain = async () => { 506 - const trimmedHandle = wispHandle.trim().toLowerCase() 507 - if (!trimmedHandle) { 508 - alert('Please enter a handle') 509 - return 510 - } 511 - 512 - setIsClaimingWisp(true) 174 + const handleLogout = async () => { 513 175 try { 514 - const response = await fetch('/api/domain/claim', { 176 + const response = await fetch('/api/auth/logout', { 515 177 method: 'POST', 516 - headers: { 'Content-Type': 'application/json' }, 517 - body: JSON.stringify({ handle: trimmedHandle }) 178 + credentials: 'include' 518 179 }) 519 - 520 - const data = await response.json() 521 - if (data.success) { 522 - setWispHandle('') 523 - setWispAvailability({ available: null, checking: false }) 524 - await fetchDomains() 180 + const result = await response.json() 181 + if (result.success) { 182 + // Redirect to home page after successful logout 183 + window.location.href = '/' 525 184 } else { 526 - throw new Error(data.error || 'Failed to claim domain') 185 + alert('Logout failed: ' + (result.error || 'Unknown error')) 527 186 } 528 187 } catch (err) { 529 - console.error('Claim domain error:', err) 530 - const errorMessage = err instanceof Error ? err.message : 'Unknown error' 531 - 532 - // Handle "Already claimed" error more gracefully 533 - if (errorMessage.includes('Already claimed')) { 534 - alert('You have already claimed a wisp.place subdomain. Please refresh the page.') 535 - await fetchDomains() 536 - } else { 537 - alert(`Failed to claim domain: ${errorMessage}`) 538 - } 539 - } finally { 540 - setIsClaimingWisp(false) 188 + alert('Logout failed: ' + (err instanceof Error ? err.message : 'Unknown error')) 541 189 } 542 190 } 543 191 ··· 555 203 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 556 204 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 557 205 <div className="flex items-center gap-2"> 558 - <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 559 - <Globe className="w-5 h-5 text-primary-foreground" /> 560 - </div> 206 + <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 561 207 <span className="text-xl font-semibold text-foreground"> 562 208 wisp.place 563 209 </span> ··· 566 212 <span className="text-sm text-muted-foreground"> 567 213 {userInfo?.handle || 'Loading...'} 568 214 </span> 215 + <Button 216 + variant="ghost" 217 + size="sm" 218 + onClick={handleLogout} 219 + className="h-8 px-2" 220 + > 221 + <LogOut className="w-4 h-4" /> 222 + </Button> 569 223 </div> 570 224 </div> 571 225 </header> ··· 579 233 </div> 580 234 581 235 <Tabs defaultValue="sites" className="space-y-6 w-full"> 582 - <TabsList className="grid w-full grid-cols-3 max-w-md"> 236 + <TabsList className="grid w-full grid-cols-4"> 583 237 <TabsTrigger value="sites">Sites</TabsTrigger> 584 238 <TabsTrigger value="domains">Domains</TabsTrigger> 585 239 <TabsTrigger value="upload">Upload</TabsTrigger> 240 + <TabsTrigger value="cli">CLI</TabsTrigger> 586 241 </TabsList> 587 242 588 243 {/* Sites Tab */} 589 - <TabsContent value="sites" className="space-y-4 min-h-[400px]"> 590 - <Card> 591 - <CardHeader> 592 - <div className="flex items-center justify-between"> 593 - <div> 594 - <CardTitle>Your Sites</CardTitle> 595 - <CardDescription> 596 - View and manage all your deployed sites 597 - </CardDescription> 598 - </div> 599 - <Button 600 - variant="outline" 601 - size="sm" 602 - onClick={syncSites} 603 - disabled={isSyncing || sitesLoading} 604 - > 605 - <RefreshCw 606 - className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} 607 - /> 608 - Sync from PDS 609 - </Button> 610 - </div> 611 - </CardHeader> 612 - <CardContent className="space-y-4"> 613 - {sitesLoading ? ( 614 - <div className="flex items-center justify-center py-8"> 615 - <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 616 - </div> 617 - ) : sites.length === 0 ? ( 618 - <div className="text-center py-8 text-muted-foreground"> 619 - <p>No sites yet. Upload your first site!</p> 620 - </div> 621 - ) : ( 622 - sites.map((site) => ( 623 - <div 624 - key={`${site.did}-${site.rkey}`} 625 - className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors" 626 - > 627 - <div className="flex-1"> 628 - <div className="flex items-center gap-3 mb-2"> 629 - <h3 className="font-semibold text-lg"> 630 - {site.display_name || site.rkey} 631 - </h3> 632 - <Badge 633 - variant="secondary" 634 - className="text-xs" 635 - > 636 - active 637 - </Badge> 638 - </div> 639 - <a 640 - href={getSiteUrl(site)} 641 - target="_blank" 642 - rel="noopener noreferrer" 643 - className="text-sm text-accent hover:text-accent/80 flex items-center gap-1" 644 - > 645 - {getSiteDomainName(site)} 646 - <ExternalLink className="w-3 h-3" /> 647 - </a> 648 - </div> 649 - <Button 650 - variant="outline" 651 - size="sm" 652 - onClick={() => handleConfigureSite(site)} 653 - > 654 - <Settings className="w-4 h-4 mr-2" /> 655 - Configure 656 - </Button> 657 - </div> 658 - )) 659 - )} 660 - </CardContent> 661 - </Card> 244 + <TabsContent value="sites"> 245 + <SitesTab 246 + sites={sites} 247 + sitesLoading={sitesLoading} 248 + isSyncing={isSyncing} 249 + userInfo={userInfo} 250 + onSyncSites={syncSites} 251 + onConfigureSite={handleConfigureSite} 252 + /> 662 253 </TabsContent> 663 254 664 255 {/* Domains Tab */} 665 - <TabsContent value="domains" className="space-y-4 min-h-[400px]"> 666 - <Card> 667 - <CardHeader> 668 - <CardTitle>wisp.place Subdomain</CardTitle> 669 - <CardDescription> 670 - Your free subdomain on the wisp.place network 671 - </CardDescription> 672 - </CardHeader> 673 - <CardContent> 674 - {domainsLoading ? ( 675 - <div className="flex items-center justify-center py-4"> 676 - <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 677 - </div> 678 - ) : wispDomain ? ( 679 - <> 680 - <div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg"> 681 - <div className="flex items-center gap-2"> 682 - <CheckCircle2 className="w-5 h-5 text-green-500" /> 683 - <span className="font-mono text-lg"> 684 - {wispDomain.domain} 685 - </span> 686 - </div> 687 - {wispDomain.rkey && ( 688 - <p className="text-xs text-muted-foreground ml-7"> 689 - โ†’ Mapped to site: {wispDomain.rkey} 690 - </p> 691 - )} 692 - </div> 693 - <p className="text-sm text-muted-foreground mt-3"> 694 - {wispDomain.rkey 695 - ? 'This domain is mapped to a specific site' 696 - : 'This domain is not mapped to any site yet. Configure it from the Sites tab.'} 697 - </p> 698 - </> 699 - ) : ( 700 - <div className="space-y-4"> 701 - <div className="p-4 bg-muted/30 rounded-lg"> 702 - <p className="text-sm text-muted-foreground mb-4"> 703 - Claim your free wisp.place subdomain 704 - </p> 705 - <div className="space-y-3"> 706 - <div className="space-y-2"> 707 - <Label htmlFor="wisp-handle">Choose your handle</Label> 708 - <div className="flex gap-2"> 709 - <div className="flex-1 relative"> 710 - <Input 711 - id="wisp-handle" 712 - placeholder="mysite" 713 - value={wispHandle} 714 - onChange={(e) => { 715 - setWispHandle(e.target.value) 716 - if (e.target.value.trim()) { 717 - checkWispAvailability(e.target.value) 718 - } else { 719 - setWispAvailability({ available: null, checking: false }) 720 - } 721 - }} 722 - disabled={isClaimingWisp} 723 - className="pr-24" 724 - /> 725 - <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 726 - .wisp.place 727 - </span> 728 - </div> 729 - </div> 730 - {wispAvailability.checking && ( 731 - <p className="text-xs text-muted-foreground flex items-center gap-1"> 732 - <Loader2 className="w-3 h-3 animate-spin" /> 733 - Checking availability... 734 - </p> 735 - )} 736 - {!wispAvailability.checking && wispAvailability.available === true && ( 737 - <p className="text-xs text-green-600 flex items-center gap-1"> 738 - <CheckCircle2 className="w-3 h-3" /> 739 - Available 740 - </p> 741 - )} 742 - {!wispAvailability.checking && wispAvailability.available === false && ( 743 - <p className="text-xs text-red-600 flex items-center gap-1"> 744 - <XCircle className="w-3 h-3" /> 745 - Not available 746 - </p> 747 - )} 748 - </div> 749 - <Button 750 - onClick={handleClaimWispDomain} 751 - disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 752 - className="w-full" 753 - > 754 - {isClaimingWisp ? ( 755 - <> 756 - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 757 - Claiming... 758 - </> 759 - ) : ( 760 - 'Claim Subdomain' 761 - )} 762 - </Button> 763 - </div> 764 - </div> 765 - </div> 766 - )} 767 - </CardContent> 768 - </Card> 769 - 770 - <Card> 771 - <CardHeader> 772 - <CardTitle>Custom Domains</CardTitle> 773 - <CardDescription> 774 - Bring your own domain with DNS verification 775 - </CardDescription> 776 - </CardHeader> 777 - <CardContent className="space-y-4"> 778 - <Button 779 - onClick={() => setAddDomainModalOpen(true)} 780 - className="w-full" 781 - > 782 - Add Custom Domain 783 - </Button> 784 - 785 - {domainsLoading ? ( 786 - <div className="flex items-center justify-center py-4"> 787 - <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 788 - </div> 789 - ) : customDomains.length === 0 ? ( 790 - <div className="text-center py-4 text-muted-foreground text-sm"> 791 - No custom domains added yet 792 - </div> 793 - ) : ( 794 - <div className="space-y-2"> 795 - {customDomains.map((domain) => ( 796 - <div 797 - key={domain.id} 798 - className="flex items-center justify-between p-3 border border-border rounded-lg" 799 - > 800 - <div className="flex flex-col gap-1 flex-1"> 801 - <div className="flex items-center gap-2"> 802 - {domain.verified ? ( 803 - <CheckCircle2 className="w-4 h-4 text-green-500" /> 804 - ) : ( 805 - <XCircle className="w-4 h-4 text-red-500" /> 806 - )} 807 - <span className="font-mono"> 808 - {domain.domain} 809 - </span> 810 - </div> 811 - {domain.rkey && domain.rkey !== 'self' && ( 812 - <p className="text-xs text-muted-foreground ml-6"> 813 - โ†’ Mapped to site: {domain.rkey} 814 - </p> 815 - )} 816 - </div> 817 - <div className="flex items-center gap-2"> 818 - <Button 819 - variant="outline" 820 - size="sm" 821 - onClick={() => 822 - setViewDomainDNS(domain.id) 823 - } 824 - > 825 - View DNS 826 - </Button> 827 - {domain.verified ? ( 828 - <Badge variant="secondary"> 829 - Verified 830 - </Badge> 831 - ) : ( 832 - <Button 833 - variant="outline" 834 - size="sm" 835 - onClick={() => 836 - handleVerifyDomain(domain.id) 837 - } 838 - disabled={ 839 - verificationStatus[ 840 - domain.id 841 - ] === 'verifying' 842 - } 843 - > 844 - {verificationStatus[ 845 - domain.id 846 - ] === 'verifying' ? ( 847 - <> 848 - <Loader2 className="w-3 h-3 mr-1 animate-spin" /> 849 - Verifying... 850 - </> 851 - ) : ( 852 - 'Verify DNS' 853 - )} 854 - </Button> 855 - )} 856 - <Button 857 - variant="ghost" 858 - size="sm" 859 - onClick={() => 860 - handleDeleteCustomDomain( 861 - domain.id 862 - ) 863 - } 864 - > 865 - <Trash2 className="w-4 h-4" /> 866 - </Button> 867 - </div> 868 - </div> 869 - ))} 870 - </div> 871 - )} 872 - </CardContent> 873 - </Card> 256 + <TabsContent value="domains"> 257 + <DomainsTab 258 + wispDomains={wispDomains} 259 + customDomains={customDomains} 260 + domainsLoading={domainsLoading} 261 + verificationStatus={verificationStatus} 262 + userInfo={userInfo} 263 + onAddCustomDomain={addCustomDomain} 264 + onVerifyDomain={verifyDomain} 265 + onDeleteCustomDomain={deleteCustomDomain} 266 + onDeleteWispDomain={deleteWispDomain} 267 + onClaimWispDomain={claimWispDomain} 268 + onCheckWispAvailability={checkWispAvailability} 269 + /> 874 270 </TabsContent> 875 271 876 272 {/* Upload Tab */} 877 - <TabsContent value="upload" className="space-y-4 min-h-[400px]"> 878 - <Card> 879 - <CardHeader> 880 - <CardTitle>Upload Site</CardTitle> 881 - <CardDescription> 882 - Deploy a new site from a folder or Git repository 883 - </CardDescription> 884 - </CardHeader> 885 - <CardContent className="space-y-6"> 886 - <div className="space-y-4"> 887 - <RadioGroup 888 - value={siteMode} 889 - onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 890 - disabled={isUploading} 891 - > 892 - <div className="flex items-center space-x-2"> 893 - <RadioGroupItem value="existing" id="existing" /> 894 - <Label htmlFor="existing" className="cursor-pointer"> 895 - Update existing site 896 - </Label> 897 - </div> 898 - <div className="flex items-center space-x-2"> 899 - <RadioGroupItem value="new" id="new" /> 900 - <Label htmlFor="new" className="cursor-pointer"> 901 - Create new site 902 - </Label> 903 - </div> 904 - </RadioGroup> 905 - 906 - {siteMode === 'existing' ? ( 907 - <div className="space-y-2"> 908 - <Label htmlFor="site-select">Select Site</Label> 909 - {sitesLoading ? ( 910 - <div className="flex items-center justify-center py-4"> 911 - <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> 912 - </div> 913 - ) : sites.length === 0 ? ( 914 - <div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground"> 915 - No sites available. Create a new site instead. 916 - </div> 917 - ) : ( 918 - <select 919 - id="site-select" 920 - className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" 921 - value={selectedSiteRkey} 922 - onChange={(e) => setSelectedSiteRkey(e.target.value)} 923 - disabled={isUploading} 924 - > 925 - <option value="">Select a site...</option> 926 - {sites.map((site) => ( 927 - <option key={site.rkey} value={site.rkey}> 928 - {site.display_name || site.rkey} 929 - </option> 930 - ))} 931 - </select> 932 - )} 933 - </div> 934 - ) : ( 935 - <div className="space-y-2"> 936 - <Label htmlFor="new-site-name">New Site Name</Label> 937 - <Input 938 - id="new-site-name" 939 - placeholder="my-awesome-site" 940 - value={newSiteName} 941 - onChange={(e) => setNewSiteName(e.target.value)} 942 - disabled={isUploading} 943 - /> 944 - </div> 945 - )} 946 - 947 - <p className="text-xs text-muted-foreground"> 948 - File limits: 100MB per file, 300MB total 949 - </p> 950 - </div> 951 - 952 - <div className="grid md:grid-cols-2 gap-4"> 953 - <Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer"> 954 - <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 955 - <Upload className="w-12 h-12 text-muted-foreground mb-4" /> 956 - <h3 className="font-semibold mb-2"> 957 - Upload Folder 958 - </h3> 959 - <p className="text-sm text-muted-foreground mb-4"> 960 - Drag and drop or click to upload your 961 - static site files 962 - </p> 963 - <input 964 - type="file" 965 - id="file-upload" 966 - multiple 967 - onChange={handleFileSelect} 968 - className="hidden" 969 - {...(({ webkitdirectory: '', directory: '' } as any))} 970 - disabled={isUploading} 971 - /> 972 - <label htmlFor="file-upload"> 973 - <Button 974 - variant="outline" 975 - type="button" 976 - onClick={() => 977 - document 978 - .getElementById('file-upload') 979 - ?.click() 980 - } 981 - disabled={isUploading} 982 - > 983 - Choose Folder 984 - </Button> 985 - </label> 986 - {selectedFiles && selectedFiles.length > 0 && ( 987 - <p className="text-sm text-muted-foreground mt-3"> 988 - {selectedFiles.length} files selected 989 - </p> 990 - )} 991 - </CardContent> 992 - </Card> 993 - 994 - <Card className="border-2 border-dashed opacity-50"> 995 - <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 996 - <Globe className="w-12 h-12 text-muted-foreground mb-4" /> 997 - <h3 className="font-semibold mb-2"> 998 - Connect Git Repository 999 - </h3> 1000 - <p className="text-sm text-muted-foreground mb-4"> 1001 - Link your GitHub, GitLab, or any Git 1002 - repository 1003 - </p> 1004 - <Badge variant="secondary">Coming soon!</Badge> 1005 - </CardContent> 1006 - </Card> 1007 - </div> 1008 - 1009 - {uploadProgress && ( 1010 - <div className="space-y-3"> 1011 - <div className="p-4 bg-muted rounded-lg"> 1012 - <div className="flex items-center gap-2"> 1013 - <Loader2 className="w-4 h-4 animate-spin" /> 1014 - <span className="text-sm">{uploadProgress}</span> 1015 - </div> 1016 - </div> 1017 - 1018 - {skippedFiles.length > 0 && ( 1019 - <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 1020 - <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 1021 - <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> 1022 - <div className="flex-1"> 1023 - <span className="font-medium"> 1024 - {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 1025 - </span> 1026 - {uploadedCount > 0 && ( 1027 - <span className="text-sm ml-2"> 1028 - ({uploadedCount} uploaded successfully) 1029 - </span> 1030 - )} 1031 - </div> 1032 - </div> 1033 - <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 1034 - {skippedFiles.slice(0, 5).map((file, idx) => ( 1035 - <div key={idx} className="text-xs"> 1036 - <span className="font-mono">{file.name}</span> 1037 - <span className="text-muted-foreground"> - {file.reason}</span> 1038 - </div> 1039 - ))} 1040 - {skippedFiles.length > 5 && ( 1041 - <div className="text-xs text-muted-foreground"> 1042 - ...and {skippedFiles.length - 5} more 1043 - </div> 1044 - )} 1045 - </div> 1046 - </div> 1047 - )} 1048 - </div> 1049 - )} 273 + <TabsContent value="upload"> 274 + <UploadTab 275 + sites={sites} 276 + sitesLoading={sitesLoading} 277 + onUploadComplete={handleUploadComplete} 278 + /> 279 + </TabsContent> 1050 280 1051 - <Button 1052 - onClick={handleUpload} 1053 - className="w-full" 1054 - disabled={ 1055 - (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 1056 - isUploading || 1057 - (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 1058 - } 1059 - > 1060 - {isUploading ? ( 1061 - <> 1062 - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1063 - Uploading... 1064 - </> 1065 - ) : ( 1066 - <> 1067 - {siteMode === 'existing' ? ( 1068 - 'Update Site' 1069 - ) : ( 1070 - selectedFiles && selectedFiles.length > 0 1071 - ? 'Upload & Deploy' 1072 - : 'Create Empty Site' 1073 - )} 1074 - </> 1075 - )} 1076 - </Button> 1077 - </CardContent> 1078 - </Card> 281 + {/* CLI Tab */} 282 + <TabsContent value="cli"> 283 + <CLITab /> 1079 284 </TabsContent> 1080 285 </Tabs> 1081 286 </div> 1082 287 1083 - {/* Add Custom Domain Modal */} 1084 - <Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}> 1085 - <DialogContent className="sm:max-w-lg"> 1086 - <DialogHeader> 1087 - <DialogTitle>Add Custom Domain</DialogTitle> 1088 - <DialogDescription> 1089 - Enter your domain name. After adding, you'll see the DNS 1090 - records to configure. 1091 - </DialogDescription> 1092 - </DialogHeader> 1093 - <div className="space-y-4 py-4"> 1094 - <div className="space-y-2"> 1095 - <Label htmlFor="new-domain">Domain Name</Label> 1096 - <Input 1097 - id="new-domain" 1098 - placeholder="example.com" 1099 - value={customDomain} 1100 - onChange={(e) => setCustomDomain(e.target.value)} 1101 - /> 1102 - <p className="text-xs text-muted-foreground"> 1103 - After adding, click "View DNS" to see the records you 1104 - need to configure. 1105 - </p> 1106 - </div> 288 + {/* Footer */} 289 + <footer className="border-t border-border/40 bg-muted/20 mt-12"> 290 + <div className="container mx-auto px-4 py-8"> 291 + <div className="text-center text-sm text-muted-foreground"> 292 + <p> 293 + Built by{' '} 294 + <a 295 + href="https://bsky.app/profile/nekomimi.pet" 296 + target="_blank" 297 + rel="noopener noreferrer" 298 + className="text-accent hover:text-accent/80 transition-colors font-medium" 299 + > 300 + @nekomimi.pet 301 + </a> 302 + {' โ€ข '} 303 + Contact:{' '} 304 + <a 305 + href="mailto:contact@wisp.place" 306 + className="text-accent hover:text-accent/80 transition-colors font-medium" 307 + > 308 + contact@wisp.place 309 + </a> 310 + {' โ€ข '} 311 + Legal/DMCA:{' '} 312 + <a 313 + href="mailto:legal@wisp.place" 314 + className="text-accent hover:text-accent/80 transition-colors font-medium" 315 + > 316 + legal@wisp.place 317 + </a> 318 + </p> 319 + <p className="mt-2"> 320 + <a 321 + href="/acceptable-use" 322 + className="text-accent hover:text-accent/80 transition-colors font-medium" 323 + > 324 + Acceptable Use Policy 325 + </a> 326 + </p> 1107 327 </div> 1108 - <DialogFooter className="flex-col sm:flex-row gap-2"> 1109 - <Button 1110 - variant="outline" 1111 - onClick={() => { 1112 - setAddDomainModalOpen(false) 1113 - setCustomDomain('') 1114 - }} 1115 - className="w-full sm:w-auto" 1116 - disabled={isAddingDomain} 1117 - > 1118 - Cancel 1119 - </Button> 1120 - <Button 1121 - onClick={handleAddCustomDomain} 1122 - disabled={!customDomain || isAddingDomain} 1123 - className="w-full sm:w-auto" 1124 - > 1125 - {isAddingDomain ? ( 1126 - <> 1127 - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1128 - Adding... 1129 - </> 1130 - ) : ( 1131 - 'Add Domain' 1132 - )} 1133 - </Button> 1134 - </DialogFooter> 1135 - </DialogContent> 1136 - </Dialog> 328 + </div> 329 + </footer> 1137 330 1138 331 {/* Site Configuration Modal */} 1139 332 <Dialog ··· 1142 335 > 1143 336 <DialogContent className="sm:max-w-lg"> 1144 337 <DialogHeader> 1145 - <DialogTitle>Configure Site Domain</DialogTitle> 338 + <DialogTitle>Configure Site Domains</DialogTitle> 1146 339 <DialogDescription> 1147 - Choose which domain this site should use 340 + Select which domains should be mapped to this site. You can select multiple domains. 1148 341 </DialogDescription> 1149 342 </DialogHeader> 1150 343 {configuringSite && ( ··· 1157 350 </p> 1158 351 </div> 1159 352 1160 - <RadioGroup 1161 - value={selectedDomain} 1162 - onValueChange={setSelectedDomain} 1163 - > 1164 - {wispDomain && ( 1165 - <div className="flex items-center space-x-2"> 1166 - <RadioGroupItem value="wisp" id="wisp" /> 1167 - <Label 1168 - htmlFor="wisp" 1169 - className="flex-1 cursor-pointer" 1170 - > 1171 - <div className="flex items-center justify-between"> 1172 - <span className="font-mono text-sm"> 1173 - {wispDomain.domain} 1174 - </span> 1175 - <Badge variant="secondary" className="text-xs ml-2"> 1176 - Free 1177 - </Badge> 1178 - </div> 1179 - </Label> 1180 - </div> 1181 - )} 353 + <div className="space-y-3"> 354 + <p className="text-sm font-medium">Available Domains:</p> 355 + 356 + {wispDomains.map((wispDomain) => { 357 + const domainId = `wisp:${wispDomain.domain}` 358 + return ( 359 + <div key={domainId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"> 360 + <Checkbox 361 + id={domainId} 362 + checked={selectedDomains.has(domainId)} 363 + onCheckedChange={(checked) => { 364 + const newSelected = new Set(selectedDomains) 365 + if (checked) { 366 + newSelected.add(domainId) 367 + } else { 368 + newSelected.delete(domainId) 369 + } 370 + setSelectedDomains(newSelected) 371 + }} 372 + /> 373 + <Label 374 + htmlFor={domainId} 375 + className="flex-1 cursor-pointer" 376 + > 377 + <div className="flex items-center justify-between"> 378 + <span className="font-mono text-sm"> 379 + {wispDomain.domain} 380 + </span> 381 + <Badge variant="secondary" className="text-xs ml-2"> 382 + Wisp 383 + </Badge> 384 + </div> 385 + </Label> 386 + </div> 387 + ) 388 + })} 1182 389 1183 390 {customDomains 1184 391 .filter((d) => d.verified) 1185 392 .map((domain) => ( 1186 393 <div 1187 394 key={domain.id} 1188 - className="flex items-center space-x-2" 395 + className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30" 1189 396 > 1190 - <RadioGroupItem 1191 - value={domain.id} 397 + <Checkbox 1192 398 id={domain.id} 399 + checked={selectedDomains.has(domain.id)} 400 + onCheckedChange={(checked) => { 401 + const newSelected = new Set(selectedDomains) 402 + if (checked) { 403 + newSelected.add(domain.id) 404 + } else { 405 + newSelected.delete(domain.id) 406 + } 407 + setSelectedDomains(newSelected) 408 + }} 1193 409 /> 1194 410 <Label 1195 411 htmlFor={domain.id} ··· 1210 426 </div> 1211 427 ))} 1212 428 1213 - <div className="flex items-center space-x-2"> 1214 - <RadioGroupItem value="none" id="none" /> 1215 - <Label htmlFor="none" className="flex-1 cursor-pointer"> 1216 - <div className="flex flex-col"> 1217 - <span className="text-sm">Default URL</span> 1218 - <span className="text-xs text-muted-foreground font-mono break-all"> 1219 - sites.wisp.place/{configuringSite.did}/ 1220 - {configuringSite.rkey} 1221 - </span> 1222 - </div> 1223 - </Label> 1224 - </div> 1225 - </RadioGroup> 429 + {customDomains.filter(d => d.verified).length === 0 && wispDomains.length === 0 && ( 430 + <p className="text-sm text-muted-foreground py-4 text-center"> 431 + No domains available. Add a custom domain or claim a wisp.place subdomain. 432 + </p> 433 + )} 434 + </div> 435 + 436 + <div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50"> 437 + <p className="text-xs text-muted-foreground"> 438 + <strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '} 439 + <span className="font-mono"> 440 + sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey} 441 + </span> 442 + </p> 443 + </div> 1226 444 </div> 1227 445 )} 1228 446 <DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2"> ··· 1268 486 )} 1269 487 </Button> 1270 488 </div> 1271 - </DialogFooter> 1272 - </DialogContent> 1273 - </Dialog> 1274 - 1275 - {/* View DNS Records Modal */} 1276 - <Dialog 1277 - open={viewDomainDNS !== null} 1278 - onOpenChange={(open) => !open && setViewDomainDNS(null)} 1279 - > 1280 - <DialogContent className="sm:max-w-lg"> 1281 - <DialogHeader> 1282 - <DialogTitle>DNS Configuration</DialogTitle> 1283 - <DialogDescription> 1284 - Add these DNS records to your domain provider 1285 - </DialogDescription> 1286 - </DialogHeader> 1287 - {viewDomainDNS && userInfo && ( 1288 - <> 1289 - {(() => { 1290 - const domain = customDomains.find( 1291 - (d) => d.id === viewDomainDNS 1292 - ) 1293 - if (!domain) return null 1294 - 1295 - return ( 1296 - <div className="space-y-4 py-4"> 1297 - <div className="p-3 bg-muted/30 rounded-lg"> 1298 - <p className="text-sm font-medium mb-1"> 1299 - Domain: 1300 - </p> 1301 - <p className="font-mono text-sm"> 1302 - {domain.domain} 1303 - </p> 1304 - </div> 1305 - 1306 - <div className="space-y-3"> 1307 - <div className="p-3 bg-background rounded border border-border"> 1308 - <div className="flex justify-between items-start mb-2"> 1309 - <span className="text-xs font-semibold text-muted-foreground"> 1310 - TXT Record (Verification) 1311 - </span> 1312 - </div> 1313 - <div className="font-mono text-xs space-y-2"> 1314 - <div> 1315 - <span className="text-muted-foreground"> 1316 - Name: 1317 - </span>{' '} 1318 - <span className="select-all"> 1319 - _wisp.{domain.domain} 1320 - </span> 1321 - </div> 1322 - <div> 1323 - <span className="text-muted-foreground"> 1324 - Value: 1325 - </span>{' '} 1326 - <span className="select-all break-all"> 1327 - {userInfo.did} 1328 - </span> 1329 - </div> 1330 - </div> 1331 - </div> 1332 - 1333 - <div className="p-3 bg-background rounded border border-border"> 1334 - <div className="flex justify-between items-start mb-2"> 1335 - <span className="text-xs font-semibold text-muted-foreground"> 1336 - CNAME Record (Pointing) 1337 - </span> 1338 - </div> 1339 - <div className="font-mono text-xs space-y-2"> 1340 - <div> 1341 - <span className="text-muted-foreground"> 1342 - Name: 1343 - </span>{' '} 1344 - <span className="select-all"> 1345 - {domain.domain} 1346 - </span> 1347 - </div> 1348 - <div> 1349 - <span className="text-muted-foreground"> 1350 - Value: 1351 - </span>{' '} 1352 - <span className="select-all"> 1353 - {domain.id}.dns.wisp.place 1354 - </span> 1355 - </div> 1356 - </div> 1357 - <p className="text-xs text-muted-foreground mt-2"> 1358 - Some DNS providers may require you to use @ or leave it blank for the root domain 1359 - </p> 1360 - </div> 1361 - </div> 1362 - 1363 - <div className="p-3 bg-muted/30 rounded-lg"> 1364 - <p className="text-xs text-muted-foreground"> 1365 - ๐Ÿ’ก After configuring DNS, click "Verify DNS" 1366 - to check if everything is set up correctly. 1367 - DNS changes can take a few minutes to 1368 - propagate. 1369 - </p> 1370 - </div> 1371 - </div> 1372 - ) 1373 - })()} 1374 - </> 1375 - )} 1376 - <DialogFooter> 1377 - <Button 1378 - variant="outline" 1379 - onClick={() => setViewDomainDNS(null)} 1380 - className="w-full sm:w-auto" 1381 - > 1382 - Close 1383 - </Button> 1384 489 </DialogFooter> 1385 490 </DialogContent> 1386 491 </Dialog>
+239
public/editor/hooks/useDomainData.ts
··· 1 + import { useState } from 'react' 2 + 3 + export interface CustomDomain { 4 + id: string 5 + domain: string 6 + did: string 7 + rkey: string 8 + verified: boolean 9 + last_verified_at: number | null 10 + created_at: number 11 + } 12 + 13 + export interface WispDomain { 14 + domain: string 15 + rkey: string | null 16 + } 17 + 18 + type VerificationStatus = 'idle' | 'verifying' | 'success' | 'error' 19 + 20 + export function useDomainData() { 21 + const [wispDomains, setWispDomains] = useState<WispDomain[]>([]) 22 + const [customDomains, setCustomDomains] = useState<CustomDomain[]>([]) 23 + const [domainsLoading, setDomainsLoading] = useState(true) 24 + const [verificationStatus, setVerificationStatus] = useState<{ 25 + [id: string]: VerificationStatus 26 + }>({}) 27 + 28 + const fetchDomains = async () => { 29 + try { 30 + const response = await fetch('/api/user/domains') 31 + const data = await response.json() 32 + setWispDomains(data.wispDomains || []) 33 + setCustomDomains(data.customDomains || []) 34 + } catch (err) { 35 + console.error('Failed to fetch domains:', err) 36 + } finally { 37 + setDomainsLoading(false) 38 + } 39 + } 40 + 41 + const addCustomDomain = async (domain: string) => { 42 + try { 43 + const response = await fetch('/api/domain/custom/add', { 44 + method: 'POST', 45 + headers: { 'Content-Type': 'application/json' }, 46 + body: JSON.stringify({ domain }) 47 + }) 48 + 49 + const data = await response.json() 50 + if (data.success) { 51 + await fetchDomains() 52 + return { success: true, id: data.id } 53 + } else { 54 + throw new Error(data.error || 'Failed to add domain') 55 + } 56 + } catch (err) { 57 + console.error('Add domain error:', err) 58 + alert( 59 + `Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}` 60 + ) 61 + return { success: false } 62 + } 63 + } 64 + 65 + const verifyDomain = async (id: string) => { 66 + setVerificationStatus({ ...verificationStatus, [id]: 'verifying' }) 67 + 68 + try { 69 + const response = await fetch('/api/domain/custom/verify', { 70 + method: 'POST', 71 + headers: { 'Content-Type': 'application/json' }, 72 + body: JSON.stringify({ id }) 73 + }) 74 + 75 + const data = await response.json() 76 + if (data.success && data.verified) { 77 + setVerificationStatus({ ...verificationStatus, [id]: 'success' }) 78 + await fetchDomains() 79 + } else { 80 + setVerificationStatus({ ...verificationStatus, [id]: 'error' }) 81 + if (data.error) { 82 + alert(`Verification failed: ${data.error}`) 83 + } 84 + } 85 + } catch (err) { 86 + console.error('Verify domain error:', err) 87 + setVerificationStatus({ ...verificationStatus, [id]: 'error' }) 88 + alert( 89 + `Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}` 90 + ) 91 + } 92 + } 93 + 94 + const deleteCustomDomain = async (id: string) => { 95 + if (!confirm('Are you sure you want to remove this custom domain?')) { 96 + return false 97 + } 98 + 99 + try { 100 + const response = await fetch(`/api/domain/custom/${id}`, { 101 + method: 'DELETE' 102 + }) 103 + 104 + const data = await response.json() 105 + if (data.success) { 106 + await fetchDomains() 107 + return true 108 + } else { 109 + throw new Error('Failed to delete domain') 110 + } 111 + } catch (err) { 112 + console.error('Delete domain error:', err) 113 + alert( 114 + `Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}` 115 + ) 116 + return false 117 + } 118 + } 119 + 120 + const mapWispDomain = async (domain: string, siteRkey: string | null) => { 121 + try { 122 + const response = await fetch('/api/domain/wisp/map-site', { 123 + method: 'POST', 124 + headers: { 'Content-Type': 'application/json' }, 125 + body: JSON.stringify({ domain, siteRkey }) 126 + }) 127 + const data = await response.json() 128 + if (!data.success) throw new Error('Failed to map wisp domain') 129 + return true 130 + } catch (err) { 131 + console.error('Map wisp domain error:', err) 132 + throw err 133 + } 134 + } 135 + 136 + const deleteWispDomain = async (domain: string) => { 137 + if (!confirm('Are you sure you want to remove this wisp.place domain?')) { 138 + return false 139 + } 140 + 141 + try { 142 + const response = await fetch(`/api/domain/wisp/${encodeURIComponent(domain)}`, { 143 + method: 'DELETE' 144 + }) 145 + 146 + const data = await response.json() 147 + if (data.success) { 148 + await fetchDomains() 149 + return true 150 + } else { 151 + throw new Error('Failed to delete domain') 152 + } 153 + } catch (err) { 154 + console.error('Delete wisp domain error:', err) 155 + alert( 156 + `Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}` 157 + ) 158 + return false 159 + } 160 + } 161 + 162 + const mapCustomDomain = async (domainId: string, siteRkey: string | null) => { 163 + try { 164 + const response = await fetch(`/api/domain/custom/${domainId}/map-site`, { 165 + method: 'POST', 166 + headers: { 'Content-Type': 'application/json' }, 167 + body: JSON.stringify({ siteRkey }) 168 + }) 169 + const data = await response.json() 170 + if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`) 171 + return true 172 + } catch (err) { 173 + console.error('Map custom domain error:', err) 174 + throw err 175 + } 176 + } 177 + 178 + const claimWispDomain = async (handle: string) => { 179 + try { 180 + const response = await fetch('/api/domain/claim', { 181 + method: 'POST', 182 + headers: { 'Content-Type': 'application/json' }, 183 + body: JSON.stringify({ handle }) 184 + }) 185 + 186 + const data = await response.json() 187 + if (data.success) { 188 + await fetchDomains() 189 + return { success: true } 190 + } else { 191 + throw new Error(data.error || 'Failed to claim domain') 192 + } 193 + } catch (err) { 194 + console.error('Claim domain error:', err) 195 + const errorMessage = err instanceof Error ? err.message : 'Unknown error' 196 + 197 + // Handle domain limit error more gracefully 198 + if (errorMessage.includes('Domain limit reached')) { 199 + alert('You have already claimed 3 wisp.place subdomains (maximum limit).') 200 + await fetchDomains() 201 + } else { 202 + alert(`Failed to claim domain: ${errorMessage}`) 203 + } 204 + return { success: false, error: errorMessage } 205 + } 206 + } 207 + 208 + const checkWispAvailability = async (handle: string) => { 209 + const trimmedHandle = handle.trim().toLowerCase() 210 + if (!trimmedHandle) { 211 + return { available: null } 212 + } 213 + 214 + try { 215 + const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`) 216 + const data = await response.json() 217 + return { available: data.available } 218 + } catch (err) { 219 + console.error('Check availability error:', err) 220 + return { available: false } 221 + } 222 + } 223 + 224 + return { 225 + wispDomains, 226 + customDomains, 227 + domainsLoading, 228 + verificationStatus, 229 + fetchDomains, 230 + addCustomDomain, 231 + verifyDomain, 232 + deleteCustomDomain, 233 + mapWispDomain, 234 + deleteWispDomain, 235 + mapCustomDomain, 236 + claimWispDomain, 237 + checkWispAvailability 238 + } 239 + }
+112
public/editor/hooks/useSiteData.ts
··· 1 + import { useState } from 'react' 2 + 3 + export interface Site { 4 + did: string 5 + rkey: string 6 + display_name: string | null 7 + created_at: number 8 + updated_at: number 9 + } 10 + 11 + export interface DomainInfo { 12 + type: 'wisp' | 'custom' 13 + domain: string 14 + verified?: boolean 15 + id?: string 16 + } 17 + 18 + export interface SiteWithDomains extends Site { 19 + domains?: DomainInfo[] 20 + } 21 + 22 + export function useSiteData() { 23 + const [sites, setSites] = useState<SiteWithDomains[]>([]) 24 + const [sitesLoading, setSitesLoading] = useState(true) 25 + const [isSyncing, setIsSyncing] = useState(false) 26 + 27 + const fetchSites = async () => { 28 + try { 29 + const response = await fetch('/api/user/sites') 30 + const data = await response.json() 31 + const sitesData: Site[] = data.sites || [] 32 + 33 + // Fetch domain info for each site 34 + const sitesWithDomains = await Promise.all( 35 + sitesData.map(async (site) => { 36 + try { 37 + const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`) 38 + const domainsData = await domainsResponse.json() 39 + return { 40 + ...site, 41 + domains: domainsData.domains || [] 42 + } 43 + } catch (err) { 44 + console.error(`Failed to fetch domains for site ${site.rkey}:`, err) 45 + return { 46 + ...site, 47 + domains: [] 48 + } 49 + } 50 + }) 51 + ) 52 + 53 + setSites(sitesWithDomains) 54 + } catch (err) { 55 + console.error('Failed to fetch sites:', err) 56 + } finally { 57 + setSitesLoading(false) 58 + } 59 + } 60 + 61 + const syncSites = async () => { 62 + setIsSyncing(true) 63 + try { 64 + const response = await fetch('/api/user/sync', { 65 + method: 'POST' 66 + }) 67 + const data = await response.json() 68 + if (data.success) { 69 + console.log(`Synced ${data.synced} sites from PDS`) 70 + // Refresh sites list 71 + await fetchSites() 72 + } 73 + } catch (err) { 74 + console.error('Failed to sync sites:', err) 75 + alert('Failed to sync sites from PDS') 76 + } finally { 77 + setIsSyncing(false) 78 + } 79 + } 80 + 81 + const deleteSite = async (rkey: string) => { 82 + try { 83 + const response = await fetch(`/api/site/${rkey}`, { 84 + method: 'DELETE' 85 + }) 86 + 87 + const data = await response.json() 88 + if (data.success) { 89 + // Refresh sites list 90 + await fetchSites() 91 + return true 92 + } else { 93 + throw new Error(data.error || 'Failed to delete site') 94 + } 95 + } catch (err) { 96 + console.error('Delete site error:', err) 97 + alert( 98 + `Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}` 99 + ) 100 + return false 101 + } 102 + } 103 + 104 + return { 105 + sites, 106 + sitesLoading, 107 + isSyncing, 108 + fetchSites, 109 + syncSites, 110 + deleteSite 111 + } 112 + }
+29
public/editor/hooks/useUserInfo.ts
··· 1 + import { useState } from 'react' 2 + 3 + export interface UserInfo { 4 + did: string 5 + handle: string 6 + } 7 + 8 + export function useUserInfo() { 9 + const [userInfo, setUserInfo] = useState<UserInfo | null>(null) 10 + const [loading, setLoading] = useState(true) 11 + 12 + const fetchUserInfo = async () => { 13 + try { 14 + const response = await fetch('/api/user/info') 15 + const data = await response.json() 16 + setUserInfo(data) 17 + } catch (err) { 18 + console.error('Failed to fetch user info:', err) 19 + } finally { 20 + setLoading(false) 21 + } 22 + } 23 + 24 + return { 25 + userInfo, 26 + loading, 27 + fetchUserInfo 28 + } 29 + }
+41 -1
public/editor/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>Elysia Static</title> 6 + <title>wisp.place</title> 7 + <meta name="description" content="Manage your decentralized static sites hosted on AT Protocol." /> 8 + 9 + <!-- Open Graph / Facebook --> 10 + <meta property="og:type" content="website" /> 11 + <meta property="og:url" content="https://wisp.place/editor" /> 12 + <meta property="og:title" content="Editor - wisp.place" /> 13 + <meta property="og:description" content="Manage your decentralized static sites hosted on AT Protocol." /> 14 + <meta property="og:site_name" content="wisp.place" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary" /> 18 + <meta name="twitter:url" content="https://wisp.place/editor" /> 19 + <meta name="twitter:title" content="Editor - wisp.place" /> 20 + <meta name="twitter:description" content="Manage your decentralized static sites hosted on AT Protocol." /> 21 + 22 + <!-- Theme --> 23 + <meta name="theme-color" content="#7c3aed" /> 24 + 7 25 <link rel="icon" type="image/x-icon" href="../favicon.ico"> 26 + <link rel="icon" type="image/png" sizes="32x32" href="../favicon-32x32.png"> 27 + <link rel="icon" type="image/png" sizes="16x16" href="../favicon-16x16.png"> 28 + <link rel="apple-touch-icon" sizes="180x180" href="../apple-touch-icon.png"> 29 + <link rel="manifest" href="../site.webmanifest"> 30 + <style> 31 + /* Dark theme fallback styles for before JS loads */ 32 + @media (prefers-color-scheme: dark) { 33 + body { 34 + background-color: oklch(0.23 0.015 285); 35 + color: oklch(0.90 0.005 285); 36 + } 37 + 38 + pre { 39 + background-color: oklch(0.33 0.015 285) !important; 40 + color: oklch(0.90 0.005 285) !important; 41 + } 42 + 43 + .bg-muted { 44 + background-color: oklch(0.33 0.015 285) !important; 45 + } 46 + } 47 + </style> 8 48 </head> 9 49 <body> 10 50 <div id="elysia"></div>
+322
public/editor/tabs/CLITab.tsx
··· 1 + import { 2 + Card, 3 + CardContent, 4 + CardDescription, 5 + CardHeader, 6 + CardTitle 7 + } from '@public/components/ui/card' 8 + import { Badge } from '@public/components/ui/badge' 9 + import { ExternalLink } from 'lucide-react' 10 + import { CodeBlock } from '@public/components/ui/code-block' 11 + 12 + export function CLITab() { 13 + return ( 14 + <div className="space-y-4 min-h-[400px]"> 15 + <Card> 16 + <CardHeader> 17 + <div className="flex items-center gap-2 mb-2"> 18 + <CardTitle>Wisp CLI Tool</CardTitle> 19 + <Badge variant="secondary" className="text-xs">v0.2.0</Badge> 20 + <Badge variant="outline" className="text-xs">Alpha</Badge> 21 + </div> 22 + <CardDescription> 23 + Deploy static sites directly from your terminal 24 + </CardDescription> 25 + </CardHeader> 26 + <CardContent className="space-y-6"> 27 + <div className="prose prose-sm max-w-none dark:prose-invert"> 28 + <p className="text-sm text-muted-foreground"> 29 + The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account. 30 + Authenticate with app password or OAuth and deploy from CI/CD pipelines. 31 + </p> 32 + </div> 33 + 34 + <div className="space-y-3"> 35 + <h3 className="text-sm font-semibold">Features</h3> 36 + <ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside"> 37 + <li><strong>Deploy:</strong> Push static sites directly from your terminal</li> 38 + <li><strong>Pull:</strong> Download sites from the PDS for development or backup</li> 39 + <li><strong>Serve:</strong> Run a local server with real-time firehose updates</li> 40 + </ul> 41 + </div> 42 + 43 + <div className="space-y-3"> 44 + <h3 className="text-sm font-semibold">Download v0.2.0</h3> 45 + <div className="grid gap-2"> 46 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 47 + <a 48 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin" 49 + target="_blank" 50 + rel="noopener noreferrer" 51 + className="flex items-center justify-between mb-2" 52 + > 53 + <span className="font-mono text-sm">macOS (Apple Silicon)</span> 54 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 55 + </a> 56 + <div className="text-xs text-muted-foreground"> 57 + <span className="font-mono">SHA-1: a8c27ea41c5e2672bfecb3476ece1c801741d759</span> 58 + </div> 59 + </div> 60 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 61 + <a 62 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux" 63 + target="_blank" 64 + rel="noopener noreferrer" 65 + className="flex items-center justify-between mb-2" 66 + > 67 + <span className="font-mono text-sm">Linux (ARM64)</span> 68 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 69 + </a> 70 + <div className="text-xs text-muted-foreground"> 71 + <span className="font-mono">SHA-1: fd7ee689c7600fc953179ea755b0357c8481a622</span> 72 + </div> 73 + </div> 74 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 75 + <a 76 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux" 77 + target="_blank" 78 + rel="noopener noreferrer" 79 + className="flex items-center justify-between mb-2" 80 + > 81 + <span className="font-mono text-sm">Linux (x86_64)</span> 82 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 83 + </a> 84 + <div className="text-xs text-muted-foreground"> 85 + <span className="font-mono">SHA-1: 8bca6992559e19e1d29ab3d2fcc6d09b28e5a485</span> 86 + </div> 87 + </div> 88 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 89 + <a 90 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-windows.exe" 91 + target="_blank" 92 + rel="noopener noreferrer" 93 + className="flex items-center justify-between mb-2" 94 + > 95 + <span className="font-mono text-sm">Windows (x86_64)</span> 96 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 97 + </a> 98 + <div className="text-xs text-muted-foreground"> 99 + <span className="font-mono">SHA-1: 90ea3987a06597fa6c42e1df9009e9758e92dd54</span> 100 + </div> 101 + </div> 102 + </div> 103 + </div> 104 + 105 + <div className="space-y-3"> 106 + <h3 className="text-sm font-semibold">Deploy a Site</h3> 107 + <CodeBlock 108 + code={`# Download and make executable 109 + curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin 110 + chmod +x wisp-cli-aarch64-darwin 111 + 112 + # Deploy your site 113 + ./wisp-cli-aarch64-darwin deploy your-handle.bsky.social \\ 114 + --path ./dist \\ 115 + --site my-site \\ 116 + --password your-app-password 117 + 118 + # Your site will be available at: 119 + # https://sites.wisp.place/your-handle/my-site`} 120 + language="bash" 121 + /> 122 + </div> 123 + 124 + <div className="space-y-3"> 125 + <h3 className="text-sm font-semibold">Pull a Site from PDS</h3> 126 + <p className="text-xs text-muted-foreground"> 127 + Download a site from the PDS to your local machine (uses OAuth authentication): 128 + </p> 129 + <CodeBlock 130 + code={`# Pull a site to a specific directory 131 + wisp-cli pull your-handle.bsky.social \\ 132 + --site my-site \\ 133 + --output ./my-site 134 + 135 + # Pull to current directory 136 + wisp-cli pull your-handle.bsky.social \\ 137 + --site my-site 138 + 139 + # Opens browser for OAuth authentication on first run`} 140 + language="bash" 141 + /> 142 + </div> 143 + 144 + <div className="space-y-3"> 145 + <h3 className="text-sm font-semibold">Serve a Site Locally with Real-Time Updates</h3> 146 + <p className="text-xs text-muted-foreground"> 147 + Run a local server that monitors the firehose for real-time updates (uses OAuth authentication): 148 + </p> 149 + <CodeBlock 150 + code={`# Serve on http://localhost:8080 (default) 151 + wisp-cli serve your-handle.bsky.social \\ 152 + --site my-site 153 + 154 + # Serve on a custom port 155 + wisp-cli serve your-handle.bsky.social \\ 156 + --site my-site \\ 157 + --port 3000 158 + 159 + # Downloads site, serves it, and watches firehose for live updates!`} 160 + language="bash" 161 + /> 162 + </div> 163 + 164 + <div className="space-y-3"> 165 + <h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3> 166 + <p className="text-xs text-muted-foreground"> 167 + Deploy automatically on every push using{' '} 168 + <a 169 + href="https://blog.tangled.org/ci" 170 + target="_blank" 171 + rel="noopener noreferrer" 172 + className="text-accent hover:underline" 173 + > 174 + Tangled Spindle 175 + </a> 176 + </p> 177 + 178 + <div className="space-y-4"> 179 + <div> 180 + <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 181 + <span>Example 1: Simple Asset Publishing</span> 182 + <Badge variant="secondary" className="text-xs">Copy Files</Badge> 183 + </h4> 184 + <CodeBlock 185 + code={`when: 186 + - event: ['push'] 187 + branch: ['main'] 188 + - event: ['manual'] 189 + 190 + engine: 'nixery' 191 + 192 + clone: 193 + skip: false 194 + depth: 1 195 + 196 + dependencies: 197 + nixpkgs: 198 + - coreutils 199 + - curl 200 + 201 + environment: 202 + SITE_PATH: '.' # Copy entire repo 203 + SITE_NAME: 'myWebbedSite' 204 + WISP_HANDLE: 'your-handle.bsky.social' 205 + 206 + steps: 207 + - name: deploy assets to wisp 208 + command: | 209 + # Download Wisp CLI 210 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 211 + chmod +x wisp-cli 212 + 213 + # Deploy to Wisp 214 + ./wisp-cli deploy \\ 215 + "$WISP_HANDLE" \\ 216 + --path "$SITE_PATH" \\ 217 + --site "$SITE_NAME" \\ 218 + --password "$WISP_APP_PASSWORD" 219 + 220 + # Output 221 + #Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite 222 + #Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite 223 + `} 224 + language="yaml" 225 + /> 226 + </div> 227 + 228 + <div> 229 + <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 230 + <span>Example 2: React/Vite Build & Deploy</span> 231 + <Badge variant="secondary" className="text-xs">Full Build</Badge> 232 + </h4> 233 + <CodeBlock 234 + code={`when: 235 + - event: ['push'] 236 + branch: ['main'] 237 + - event: ['manual'] 238 + 239 + engine: 'nixery' 240 + 241 + clone: 242 + skip: false 243 + depth: 1 244 + submodules: false 245 + 246 + dependencies: 247 + nixpkgs: 248 + - nodejs 249 + - coreutils 250 + - curl 251 + github:NixOS/nixpkgs/nixpkgs-unstable: 252 + - bun 253 + 254 + environment: 255 + SITE_PATH: 'dist' 256 + SITE_NAME: 'my-react-site' 257 + WISP_HANDLE: 'your-handle.bsky.social' 258 + 259 + steps: 260 + - name: build site 261 + command: | 262 + # necessary to ensure bun is in PATH 263 + export PATH="$HOME/.nix-profile/bin:$PATH" 264 + 265 + bun install --frozen-lockfile 266 + 267 + # build with vite, run directly to get around env issues 268 + bun node_modules/.bin/vite build 269 + 270 + - name: deploy to wisp 271 + command: | 272 + # Download Wisp CLI 273 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 274 + chmod +x wisp-cli 275 + 276 + # Deploy to Wisp 277 + ./wisp-cli deploy \\ 278 + "$WISP_HANDLE" \\ 279 + --path "$SITE_PATH" \\ 280 + --site "$SITE_NAME" \\ 281 + --password "$WISP_APP_PASSWORD"`} 282 + language="yaml" 283 + /> 284 + </div> 285 + </div> 286 + 287 + <div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent"> 288 + <p className="text-xs text-muted-foreground"> 289 + <strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings. 290 + Generate an app password from your AT Protocol account settings. 291 + </p> 292 + </div> 293 + </div> 294 + 295 + <div className="space-y-3"> 296 + <h3 className="text-sm font-semibold">Learn More</h3> 297 + <div className="grid gap-2"> 298 + <a 299 + href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli" 300 + target="_blank" 301 + rel="noopener noreferrer" 302 + className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 303 + > 304 + <span className="text-sm">Source Code</span> 305 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 306 + </a> 307 + <a 308 + href="https://blog.tangled.org/ci" 309 + target="_blank" 310 + rel="noopener noreferrer" 311 + className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 312 + > 313 + <span className="text-sm">Tangled Spindle CI/CD</span> 314 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 315 + </a> 316 + </div> 317 + </div> 318 + </CardContent> 319 + </Card> 320 + </div> 321 + ) 322 + }
+524
public/editor/tabs/DomainsTab.tsx
··· 1 + import { useState } from 'react' 2 + import { 3 + Card, 4 + CardContent, 5 + CardDescription, 6 + CardHeader, 7 + CardTitle 8 + } from '@public/components/ui/card' 9 + import { Button } from '@public/components/ui/button' 10 + import { Input } from '@public/components/ui/input' 11 + import { Label } from '@public/components/ui/label' 12 + import { Badge } from '@public/components/ui/badge' 13 + import { 14 + Dialog, 15 + DialogContent, 16 + DialogDescription, 17 + DialogHeader, 18 + DialogTitle, 19 + DialogFooter 20 + } from '@public/components/ui/dialog' 21 + import { 22 + CheckCircle2, 23 + XCircle, 24 + Loader2, 25 + Trash2 26 + } from 'lucide-react' 27 + import type { WispDomain, CustomDomain } from '../hooks/useDomainData' 28 + import type { UserInfo } from '../hooks/useUserInfo' 29 + 30 + interface DomainsTabProps { 31 + wispDomains: WispDomain[] 32 + customDomains: CustomDomain[] 33 + domainsLoading: boolean 34 + verificationStatus: { [id: string]: 'idle' | 'verifying' | 'success' | 'error' } 35 + userInfo: UserInfo | null 36 + onAddCustomDomain: (domain: string) => Promise<{ success: boolean; id?: string }> 37 + onVerifyDomain: (id: string) => Promise<void> 38 + onDeleteCustomDomain: (id: string) => Promise<boolean> 39 + onDeleteWispDomain: (domain: string) => Promise<boolean> 40 + onClaimWispDomain: (handle: string) => Promise<{ success: boolean; error?: string }> 41 + onCheckWispAvailability: (handle: string) => Promise<{ available: boolean | null }> 42 + } 43 + 44 + export function DomainsTab({ 45 + wispDomains, 46 + customDomains, 47 + domainsLoading, 48 + verificationStatus, 49 + userInfo, 50 + onAddCustomDomain, 51 + onVerifyDomain, 52 + onDeleteCustomDomain, 53 + onDeleteWispDomain, 54 + onClaimWispDomain, 55 + onCheckWispAvailability 56 + }: DomainsTabProps) { 57 + // Wisp domain claim state 58 + const [wispHandle, setWispHandle] = useState('') 59 + const [isClaimingWisp, setIsClaimingWisp] = useState(false) 60 + const [wispAvailability, setWispAvailability] = useState<{ 61 + available: boolean | null 62 + checking: boolean 63 + }>({ available: null, checking: false }) 64 + 65 + // Custom domain modal state 66 + const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) 67 + const [customDomain, setCustomDomain] = useState('') 68 + const [isAddingDomain, setIsAddingDomain] = useState(false) 69 + const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null) 70 + 71 + const checkWispAvailability = async (handle: string) => { 72 + const trimmedHandle = handle.trim().toLowerCase() 73 + if (!trimmedHandle) { 74 + setWispAvailability({ available: null, checking: false }) 75 + return 76 + } 77 + 78 + setWispAvailability({ available: null, checking: true }) 79 + const result = await onCheckWispAvailability(trimmedHandle) 80 + setWispAvailability({ available: result.available, checking: false }) 81 + } 82 + 83 + const handleClaimWispDomain = async () => { 84 + const trimmedHandle = wispHandle.trim().toLowerCase() 85 + if (!trimmedHandle) { 86 + alert('Please enter a handle') 87 + return 88 + } 89 + 90 + setIsClaimingWisp(true) 91 + const result = await onClaimWispDomain(trimmedHandle) 92 + if (result.success) { 93 + setWispHandle('') 94 + setWispAvailability({ available: null, checking: false }) 95 + } 96 + setIsClaimingWisp(false) 97 + } 98 + 99 + const handleAddCustomDomain = async () => { 100 + if (!customDomain) { 101 + alert('Please enter a domain') 102 + return 103 + } 104 + 105 + setIsAddingDomain(true) 106 + const result = await onAddCustomDomain(customDomain) 107 + setIsAddingDomain(false) 108 + 109 + if (result.success) { 110 + setCustomDomain('') 111 + setAddDomainModalOpen(false) 112 + // Automatically show DNS configuration for the newly added domain 113 + if (result.id) { 114 + setViewDomainDNS(result.id) 115 + } 116 + } 117 + } 118 + 119 + return ( 120 + <> 121 + <div className="space-y-4 min-h-[400px]"> 122 + <Card> 123 + <CardHeader> 124 + <CardTitle>wisp.place Subdomains</CardTitle> 125 + <CardDescription> 126 + Your free subdomains on the wisp.place network (up to 3) 127 + </CardDescription> 128 + </CardHeader> 129 + <CardContent> 130 + {domainsLoading ? ( 131 + <div className="flex items-center justify-center py-4"> 132 + <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 133 + </div> 134 + ) : ( 135 + <div className="space-y-4"> 136 + {wispDomains.length > 0 && ( 137 + <div className="space-y-2"> 138 + {wispDomains.map((domain) => ( 139 + <div 140 + key={domain.domain} 141 + className="flex items-center justify-between p-3 border border-border rounded-lg" 142 + > 143 + <div className="flex flex-col gap-1 flex-1"> 144 + <div className="flex items-center gap-2"> 145 + <CheckCircle2 className="w-4 h-4 text-green-500" /> 146 + <span className="font-mono"> 147 + {domain.domain} 148 + </span> 149 + </div> 150 + {domain.rkey && ( 151 + <p className="text-xs text-muted-foreground ml-6"> 152 + โ†’ Mapped to site: {domain.rkey} 153 + </p> 154 + )} 155 + </div> 156 + <Button 157 + variant="ghost" 158 + size="sm" 159 + onClick={() => onDeleteWispDomain(domain.domain)} 160 + > 161 + <Trash2 className="w-4 h-4" /> 162 + </Button> 163 + </div> 164 + ))} 165 + </div> 166 + )} 167 + 168 + {wispDomains.length < 3 && ( 169 + <div className="p-4 bg-muted/30 rounded-lg"> 170 + <p className="text-sm text-muted-foreground mb-4"> 171 + {wispDomains.length === 0 172 + ? 'Claim your free wisp.place subdomain' 173 + : `Claim another wisp.place subdomain (${wispDomains.length}/3)`} 174 + </p> 175 + <div className="space-y-3"> 176 + <div className="space-y-2"> 177 + <Label htmlFor="wisp-handle">Choose your handle</Label> 178 + <div className="flex gap-2"> 179 + <div className="flex-1 relative"> 180 + <Input 181 + id="wisp-handle" 182 + placeholder="mysite" 183 + value={wispHandle} 184 + onChange={(e) => { 185 + setWispHandle(e.target.value) 186 + if (e.target.value.trim()) { 187 + checkWispAvailability(e.target.value) 188 + } else { 189 + setWispAvailability({ available: null, checking: false }) 190 + } 191 + }} 192 + disabled={isClaimingWisp} 193 + className="pr-24" 194 + /> 195 + <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 196 + .wisp.place 197 + </span> 198 + </div> 199 + </div> 200 + {wispAvailability.checking && ( 201 + <p className="text-xs text-muted-foreground flex items-center gap-1"> 202 + <Loader2 className="w-3 h-3 animate-spin" /> 203 + Checking availability... 204 + </p> 205 + )} 206 + {!wispAvailability.checking && wispAvailability.available === true && ( 207 + <p className="text-xs text-green-600 flex items-center gap-1"> 208 + <CheckCircle2 className="w-3 h-3" /> 209 + Available 210 + </p> 211 + )} 212 + {!wispAvailability.checking && wispAvailability.available === false && ( 213 + <p className="text-xs text-red-600 flex items-center gap-1"> 214 + <XCircle className="w-3 h-3" /> 215 + Not available 216 + </p> 217 + )} 218 + </div> 219 + <Button 220 + onClick={handleClaimWispDomain} 221 + disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 222 + className="w-full" 223 + > 224 + {isClaimingWisp ? ( 225 + <> 226 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 227 + Claiming... 228 + </> 229 + ) : ( 230 + 'Claim Subdomain' 231 + )} 232 + </Button> 233 + </div> 234 + </div> 235 + )} 236 + 237 + {wispDomains.length === 3 && ( 238 + <div className="p-3 bg-muted/30 rounded-lg text-center"> 239 + <p className="text-sm text-muted-foreground"> 240 + You have claimed the maximum of 3 wisp.place subdomains 241 + </p> 242 + </div> 243 + )} 244 + </div> 245 + )} 246 + </CardContent> 247 + </Card> 248 + 249 + <Card> 250 + <CardHeader> 251 + <CardTitle>Custom Domains</CardTitle> 252 + <CardDescription> 253 + Bring your own domain with DNS verification 254 + </CardDescription> 255 + </CardHeader> 256 + <CardContent className="space-y-4"> 257 + <Button 258 + onClick={() => setAddDomainModalOpen(true)} 259 + className="w-full" 260 + > 261 + Add Custom Domain 262 + </Button> 263 + 264 + {domainsLoading ? ( 265 + <div className="flex items-center justify-center py-4"> 266 + <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 267 + </div> 268 + ) : customDomains.length === 0 ? ( 269 + <div className="text-center py-4 text-muted-foreground text-sm"> 270 + No custom domains added yet 271 + </div> 272 + ) : ( 273 + <div className="space-y-2"> 274 + {customDomains.map((domain) => ( 275 + <div 276 + key={domain.id} 277 + className="flex items-center justify-between p-3 border border-border rounded-lg" 278 + > 279 + <div className="flex flex-col gap-1 flex-1"> 280 + <div className="flex items-center gap-2"> 281 + {domain.verified ? ( 282 + <CheckCircle2 className="w-4 h-4 text-green-500" /> 283 + ) : ( 284 + <XCircle className="w-4 h-4 text-red-500" /> 285 + )} 286 + <span className="font-mono"> 287 + {domain.domain} 288 + </span> 289 + </div> 290 + {domain.rkey && domain.rkey !== 'self' && ( 291 + <p className="text-xs text-muted-foreground ml-6"> 292 + โ†’ Mapped to site: {domain.rkey} 293 + </p> 294 + )} 295 + </div> 296 + <div className="flex items-center gap-2"> 297 + <Button 298 + variant="outline" 299 + size="sm" 300 + onClick={() => 301 + setViewDomainDNS(domain.id) 302 + } 303 + > 304 + View DNS 305 + </Button> 306 + {domain.verified ? ( 307 + <Badge variant="secondary"> 308 + Verified 309 + </Badge> 310 + ) : ( 311 + <Button 312 + variant="outline" 313 + size="sm" 314 + onClick={() => 315 + onVerifyDomain(domain.id) 316 + } 317 + disabled={ 318 + verificationStatus[ 319 + domain.id 320 + ] === 'verifying' 321 + } 322 + > 323 + {verificationStatus[ 324 + domain.id 325 + ] === 'verifying' ? ( 326 + <> 327 + <Loader2 className="w-3 h-3 mr-1 animate-spin" /> 328 + Verifying... 329 + </> 330 + ) : ( 331 + 'Verify DNS' 332 + )} 333 + </Button> 334 + )} 335 + <Button 336 + variant="ghost" 337 + size="sm" 338 + onClick={() => 339 + onDeleteCustomDomain( 340 + domain.id 341 + ) 342 + } 343 + > 344 + <Trash2 className="w-4 h-4" /> 345 + </Button> 346 + </div> 347 + </div> 348 + ))} 349 + </div> 350 + )} 351 + </CardContent> 352 + </Card> 353 + </div> 354 + 355 + {/* Add Custom Domain Modal */} 356 + <Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}> 357 + <DialogContent className="sm:max-w-lg"> 358 + <DialogHeader> 359 + <DialogTitle>Add Custom Domain</DialogTitle> 360 + <DialogDescription> 361 + Enter your domain name. After adding, you'll see the DNS 362 + records to configure. 363 + </DialogDescription> 364 + </DialogHeader> 365 + <div className="space-y-4 py-4"> 366 + <div className="space-y-2"> 367 + <Label htmlFor="new-domain">Domain Name</Label> 368 + <Input 369 + id="new-domain" 370 + placeholder="example.com" 371 + value={customDomain} 372 + onChange={(e) => setCustomDomain(e.target.value)} 373 + /> 374 + <p className="text-xs text-muted-foreground"> 375 + After adding, click "View DNS" to see the records you 376 + need to configure. 377 + </p> 378 + </div> 379 + </div> 380 + <DialogFooter className="flex-col sm:flex-row gap-2"> 381 + <Button 382 + variant="outline" 383 + onClick={() => { 384 + setAddDomainModalOpen(false) 385 + setCustomDomain('') 386 + }} 387 + className="w-full sm:w-auto" 388 + disabled={isAddingDomain} 389 + > 390 + Cancel 391 + </Button> 392 + <Button 393 + onClick={handleAddCustomDomain} 394 + disabled={!customDomain || isAddingDomain} 395 + className="w-full sm:w-auto" 396 + > 397 + {isAddingDomain ? ( 398 + <> 399 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 400 + Adding... 401 + </> 402 + ) : ( 403 + 'Add Domain' 404 + )} 405 + </Button> 406 + </DialogFooter> 407 + </DialogContent> 408 + </Dialog> 409 + 410 + {/* View DNS Records Modal */} 411 + <Dialog 412 + open={viewDomainDNS !== null} 413 + onOpenChange={(open) => !open && setViewDomainDNS(null)} 414 + > 415 + <DialogContent className="sm:max-w-lg"> 416 + <DialogHeader> 417 + <DialogTitle>DNS Configuration</DialogTitle> 418 + <DialogDescription> 419 + Add these DNS records to your domain provider 420 + </DialogDescription> 421 + </DialogHeader> 422 + {viewDomainDNS && userInfo && ( 423 + <> 424 + {(() => { 425 + const domain = customDomains.find( 426 + (d) => d.id === viewDomainDNS 427 + ) 428 + if (!domain) return null 429 + 430 + return ( 431 + <div className="space-y-4 py-4"> 432 + <div className="p-3 bg-muted/30 rounded-lg"> 433 + <p className="text-sm font-medium mb-1"> 434 + Domain: 435 + </p> 436 + <p className="font-mono text-sm"> 437 + {domain.domain} 438 + </p> 439 + </div> 440 + 441 + <div className="space-y-3"> 442 + <div className="p-3 bg-background rounded border border-border"> 443 + <div className="flex justify-between items-start mb-2"> 444 + <span className="text-xs font-semibold text-muted-foreground"> 445 + TXT Record (Verification) 446 + </span> 447 + </div> 448 + <div className="font-mono text-xs space-y-2"> 449 + <div> 450 + <span className="text-muted-foreground"> 451 + Name: 452 + </span>{' '} 453 + <span className="select-all"> 454 + _wisp.{domain.domain} 455 + </span> 456 + </div> 457 + <div> 458 + <span className="text-muted-foreground"> 459 + Value: 460 + </span>{' '} 461 + <span className="select-all break-all"> 462 + {userInfo.did} 463 + </span> 464 + </div> 465 + </div> 466 + </div> 467 + 468 + <div className="p-3 bg-background rounded border border-border"> 469 + <div className="flex justify-between items-start mb-2"> 470 + <span className="text-xs font-semibold text-muted-foreground"> 471 + CNAME Record (Pointing) 472 + </span> 473 + </div> 474 + <div className="font-mono text-xs space-y-2"> 475 + <div> 476 + <span className="text-muted-foreground"> 477 + Name: 478 + </span>{' '} 479 + <span className="select-all"> 480 + {domain.domain} 481 + </span> 482 + </div> 483 + <div> 484 + <span className="text-muted-foreground"> 485 + Value: 486 + </span>{' '} 487 + <span className="select-all"> 488 + {domain.id}.dns.wisp.place 489 + </span> 490 + </div> 491 + </div> 492 + <p className="text-xs text-muted-foreground mt-2"> 493 + Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification. 494 + </p> 495 + </div> 496 + </div> 497 + 498 + <div className="p-3 bg-muted/30 rounded-lg"> 499 + <p className="text-xs text-muted-foreground"> 500 + ๐Ÿ’ก After configuring DNS, click "Verify DNS" 501 + to check if everything is set up correctly. 502 + DNS changes can take a few minutes to 503 + propagate. 504 + </p> 505 + </div> 506 + </div> 507 + ) 508 + })()} 509 + </> 510 + )} 511 + <DialogFooter> 512 + <Button 513 + variant="outline" 514 + onClick={() => setViewDomainDNS(null)} 515 + className="w-full sm:w-auto" 516 + > 517 + Close 518 + </Button> 519 + </DialogFooter> 520 + </DialogContent> 521 + </Dialog> 522 + </> 523 + ) 524 + }
+196
public/editor/tabs/SitesTab.tsx
··· 1 + import { 2 + Card, 3 + CardContent, 4 + CardDescription, 5 + CardHeader, 6 + CardTitle 7 + } from '@public/components/ui/card' 8 + import { Button } from '@public/components/ui/button' 9 + import { Badge } from '@public/components/ui/badge' 10 + import { 11 + Globe, 12 + ExternalLink, 13 + CheckCircle2, 14 + AlertCircle, 15 + Loader2, 16 + RefreshCw, 17 + Settings 18 + } from 'lucide-react' 19 + import type { SiteWithDomains } from '../hooks/useSiteData' 20 + import type { UserInfo } from '../hooks/useUserInfo' 21 + 22 + interface SitesTabProps { 23 + sites: SiteWithDomains[] 24 + sitesLoading: boolean 25 + isSyncing: boolean 26 + userInfo: UserInfo | null 27 + onSyncSites: () => Promise<void> 28 + onConfigureSite: (site: SiteWithDomains) => void 29 + } 30 + 31 + export function SitesTab({ 32 + sites, 33 + sitesLoading, 34 + isSyncing, 35 + userInfo, 36 + onSyncSites, 37 + onConfigureSite 38 + }: SitesTabProps) { 39 + const getSiteUrl = (site: SiteWithDomains) => { 40 + // Use the first mapped domain if available 41 + if (site.domains && site.domains.length > 0) { 42 + return `https://${site.domains[0].domain}` 43 + } 44 + 45 + // Default fallback URL - use handle instead of DID 46 + if (!userInfo) return '#' 47 + return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}` 48 + } 49 + 50 + const getSiteDomainName = (site: SiteWithDomains) => { 51 + // Return the first domain if available 52 + if (site.domains && site.domains.length > 0) { 53 + return site.domains[0].domain 54 + } 55 + 56 + // Use handle instead of DID for display 57 + if (!userInfo) return `sites.wisp.place/.../${site.rkey}` 58 + return `sites.wisp.place/${userInfo.handle}/${site.rkey}` 59 + } 60 + 61 + return ( 62 + <div className="space-y-4 min-h-[400px]"> 63 + <Card> 64 + <CardHeader> 65 + <div className="flex items-center justify-between"> 66 + <div> 67 + <CardTitle>Your Sites</CardTitle> 68 + <CardDescription> 69 + View and manage all your deployed sites 70 + </CardDescription> 71 + </div> 72 + <Button 73 + variant="outline" 74 + size="sm" 75 + onClick={onSyncSites} 76 + disabled={isSyncing || sitesLoading} 77 + > 78 + <RefreshCw 79 + className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} 80 + /> 81 + Sync from PDS 82 + </Button> 83 + </div> 84 + </CardHeader> 85 + <CardContent className="space-y-4"> 86 + {sitesLoading ? ( 87 + <div className="flex items-center justify-center py-8"> 88 + <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 89 + </div> 90 + ) : sites.length === 0 ? ( 91 + <div className="text-center py-8 text-muted-foreground"> 92 + <p>No sites yet. Upload your first site!</p> 93 + </div> 94 + ) : ( 95 + sites.map((site) => ( 96 + <div 97 + key={`${site.did}-${site.rkey}`} 98 + className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors" 99 + > 100 + <div className="flex-1"> 101 + <div className="flex items-center gap-3 mb-2"> 102 + <h3 className="font-semibold text-lg"> 103 + {site.display_name || site.rkey} 104 + </h3> 105 + <Badge 106 + variant="secondary" 107 + className="text-xs" 108 + > 109 + active 110 + </Badge> 111 + </div> 112 + 113 + {/* Display all mapped domains */} 114 + {site.domains && site.domains.length > 0 ? ( 115 + <div className="space-y-1"> 116 + {site.domains.map((domainInfo, idx) => ( 117 + <div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2"> 118 + <a 119 + href={`https://${domainInfo.domain}`} 120 + target="_blank" 121 + rel="noopener noreferrer" 122 + className="text-sm text-accent hover:text-accent/80 flex items-center gap-1" 123 + > 124 + <Globe className="w-3 h-3" /> 125 + {domainInfo.domain} 126 + <ExternalLink className="w-3 h-3" /> 127 + </a> 128 + <Badge 129 + variant={domainInfo.type === 'wisp' ? 'default' : 'outline'} 130 + className="text-xs" 131 + > 132 + {domainInfo.type} 133 + </Badge> 134 + {domainInfo.type === 'custom' && ( 135 + <Badge 136 + variant={domainInfo.verified ? 'default' : 'secondary'} 137 + className="text-xs" 138 + > 139 + {domainInfo.verified ? ( 140 + <> 141 + <CheckCircle2 className="w-3 h-3 mr-1" /> 142 + verified 143 + </> 144 + ) : ( 145 + <> 146 + <AlertCircle className="w-3 h-3 mr-1" /> 147 + pending 148 + </> 149 + )} 150 + </Badge> 151 + )} 152 + </div> 153 + ))} 154 + </div> 155 + ) : ( 156 + <a 157 + href={getSiteUrl(site)} 158 + target="_blank" 159 + rel="noopener noreferrer" 160 + className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1" 161 + > 162 + {getSiteDomainName(site)} 163 + <ExternalLink className="w-3 h-3" /> 164 + </a> 165 + )} 166 + </div> 167 + <Button 168 + variant="outline" 169 + size="sm" 170 + onClick={() => onConfigureSite(site)} 171 + > 172 + <Settings className="w-4 h-4 mr-2" /> 173 + Configure 174 + </Button> 175 + </div> 176 + )) 177 + )} 178 + </CardContent> 179 + </Card> 180 + 181 + <div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50"> 182 + <div className="flex items-start gap-2"> 183 + <AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" /> 184 + <div className="flex-1 space-y-1"> 185 + <p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400"> 186 + Note about sites.wisp.place URLs 187 + </p> 188 + <p className="text-xs text-muted-foreground"> 189 + Complex sites hosted on <code className="px-1 py-0.5 bg-background rounded text-xs">sites.wisp.place</code> may have broken assets if they use absolute paths (e.g., <code className="px-1 py-0.5 bg-background rounded text-xs">/folder/script.js</code>) in CSS or JavaScript files. While HTML paths are automatically rewritten, CSS and JS files are served as-is. For best results, use a wisp.place subdomain or custom domain, or ensure your site uses relative paths. 190 + </p> 191 + </div> 192 + </div> 193 + </div> 194 + </div> 195 + ) 196 + }
+323
public/editor/tabs/UploadTab.tsx
··· 1 + import { useState, useEffect } from 'react' 2 + import { 3 + Card, 4 + CardContent, 5 + CardDescription, 6 + CardHeader, 7 + CardTitle 8 + } from '@public/components/ui/card' 9 + import { Button } from '@public/components/ui/button' 10 + import { Input } from '@public/components/ui/input' 11 + import { Label } from '@public/components/ui/label' 12 + import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 13 + import { Badge } from '@public/components/ui/badge' 14 + import { 15 + Globe, 16 + Upload, 17 + AlertCircle, 18 + Loader2 19 + } from 'lucide-react' 20 + import type { SiteWithDomains } from '../hooks/useSiteData' 21 + 22 + interface UploadTabProps { 23 + sites: SiteWithDomains[] 24 + sitesLoading: boolean 25 + onUploadComplete: () => Promise<void> 26 + } 27 + 28 + export function UploadTab({ 29 + sites, 30 + sitesLoading, 31 + onUploadComplete 32 + }: UploadTabProps) { 33 + // Upload state 34 + const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing') 35 + const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('') 36 + const [newSiteName, setNewSiteName] = useState('') 37 + const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 38 + const [isUploading, setIsUploading] = useState(false) 39 + const [uploadProgress, setUploadProgress] = useState('') 40 + const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 41 + const [uploadedCount, setUploadedCount] = useState(0) 42 + 43 + // Auto-switch to 'new' mode if no sites exist 44 + useEffect(() => { 45 + if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { 46 + setSiteMode('new') 47 + } 48 + }, [sites, sitesLoading, siteMode]) 49 + 50 + const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 51 + if (e.target.files && e.target.files.length > 0) { 52 + setSelectedFiles(e.target.files) 53 + } 54 + } 55 + 56 + const handleUpload = async () => { 57 + const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName 58 + 59 + if (!siteName) { 60 + alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') 61 + return 62 + } 63 + 64 + setIsUploading(true) 65 + setUploadProgress('Preparing files...') 66 + 67 + try { 68 + const formData = new FormData() 69 + formData.append('siteName', siteName) 70 + 71 + if (selectedFiles) { 72 + for (let i = 0; i < selectedFiles.length; i++) { 73 + formData.append('files', selectedFiles[i]) 74 + } 75 + } 76 + 77 + setUploadProgress('Uploading to AT Protocol...') 78 + const response = await fetch('/wisp/upload-files', { 79 + method: 'POST', 80 + body: formData 81 + }) 82 + 83 + const data = await response.json() 84 + if (data.success) { 85 + setUploadProgress('Upload complete!') 86 + setSkippedFiles(data.skippedFiles || []) 87 + setUploadedCount(data.uploadedCount || data.fileCount || 0) 88 + setSelectedSiteRkey('') 89 + setNewSiteName('') 90 + setSelectedFiles(null) 91 + 92 + // Refresh sites list 93 + await onUploadComplete() 94 + 95 + // Reset form - give more time if there are skipped files 96 + const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500 97 + setTimeout(() => { 98 + setUploadProgress('') 99 + setSkippedFiles([]) 100 + setUploadedCount(0) 101 + setIsUploading(false) 102 + }, resetDelay) 103 + } else { 104 + throw new Error(data.error || 'Upload failed') 105 + } 106 + } catch (err) { 107 + console.error('Upload error:', err) 108 + alert( 109 + `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` 110 + ) 111 + setIsUploading(false) 112 + setUploadProgress('') 113 + } 114 + } 115 + 116 + return ( 117 + <div className="space-y-4 min-h-[400px]"> 118 + <Card> 119 + <CardHeader> 120 + <CardTitle>Upload Site</CardTitle> 121 + <CardDescription> 122 + Deploy a new site from a folder or Git repository 123 + </CardDescription> 124 + </CardHeader> 125 + <CardContent className="space-y-6"> 126 + <div className="space-y-4"> 127 + <div className="p-4 bg-muted/50 rounded-lg"> 128 + <RadioGroup 129 + value={siteMode} 130 + onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 131 + disabled={isUploading} 132 + > 133 + <div className="flex items-center space-x-2"> 134 + <RadioGroupItem value="existing" id="existing" /> 135 + <Label htmlFor="existing" className="cursor-pointer"> 136 + Update existing site 137 + </Label> 138 + </div> 139 + <div className="flex items-center space-x-2"> 140 + <RadioGroupItem value="new" id="new" /> 141 + <Label htmlFor="new" className="cursor-pointer"> 142 + Create new site 143 + </Label> 144 + </div> 145 + </RadioGroup> 146 + </div> 147 + 148 + {siteMode === 'existing' ? ( 149 + <div className="space-y-2"> 150 + <Label htmlFor="site-select">Select Site</Label> 151 + {sitesLoading ? ( 152 + <div className="flex items-center justify-center py-4"> 153 + <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> 154 + </div> 155 + ) : sites.length === 0 ? ( 156 + <div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground"> 157 + No sites available. Create a new site instead. 158 + </div> 159 + ) : ( 160 + <select 161 + id="site-select" 162 + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" 163 + value={selectedSiteRkey} 164 + onChange={(e) => setSelectedSiteRkey(e.target.value)} 165 + disabled={isUploading} 166 + > 167 + <option value="">Select a site...</option> 168 + {sites.map((site) => ( 169 + <option key={site.rkey} value={site.rkey}> 170 + {site.display_name || site.rkey} 171 + </option> 172 + ))} 173 + </select> 174 + )} 175 + </div> 176 + ) : ( 177 + <div className="space-y-2"> 178 + <Label htmlFor="new-site-name">New Site Name</Label> 179 + <Input 180 + id="new-site-name" 181 + placeholder="my-awesome-site" 182 + value={newSiteName} 183 + onChange={(e) => setNewSiteName(e.target.value)} 184 + disabled={isUploading} 185 + /> 186 + </div> 187 + )} 188 + 189 + <p className="text-xs text-muted-foreground"> 190 + File limits: 100MB per file, 300MB total 191 + </p> 192 + </div> 193 + 194 + <div className="grid md:grid-cols-2 gap-4"> 195 + <Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer"> 196 + <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 197 + <Upload className="w-12 h-12 text-muted-foreground mb-4" /> 198 + <h3 className="font-semibold mb-2"> 199 + Upload Folder 200 + </h3> 201 + <p className="text-sm text-muted-foreground mb-4"> 202 + Drag and drop or click to upload your 203 + static site files 204 + </p> 205 + <input 206 + type="file" 207 + id="file-upload" 208 + multiple 209 + onChange={handleFileSelect} 210 + className="hidden" 211 + {...(({ webkitdirectory: '', directory: '' } as any))} 212 + disabled={isUploading} 213 + /> 214 + <label htmlFor="file-upload"> 215 + <Button 216 + variant="outline" 217 + type="button" 218 + onClick={() => 219 + document 220 + .getElementById('file-upload') 221 + ?.click() 222 + } 223 + disabled={isUploading} 224 + > 225 + Choose Folder 226 + </Button> 227 + </label> 228 + {selectedFiles && selectedFiles.length > 0 && ( 229 + <p className="text-sm text-muted-foreground mt-3"> 230 + {selectedFiles.length} files selected 231 + </p> 232 + )} 233 + </CardContent> 234 + </Card> 235 + 236 + <Card className="border-2 border-dashed opacity-50"> 237 + <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 238 + <Globe className="w-12 h-12 text-muted-foreground mb-4" /> 239 + <h3 className="font-semibold mb-2"> 240 + Connect Git Repository 241 + </h3> 242 + <p className="text-sm text-muted-foreground mb-4"> 243 + Link your GitHub, GitLab, or any Git 244 + repository 245 + </p> 246 + <Badge variant="secondary">Coming soon!</Badge> 247 + </CardContent> 248 + </Card> 249 + </div> 250 + 251 + {uploadProgress && ( 252 + <div className="space-y-3"> 253 + <div className="p-4 bg-muted rounded-lg"> 254 + <div className="flex items-center gap-2"> 255 + <Loader2 className="w-4 h-4 animate-spin" /> 256 + <span className="text-sm">{uploadProgress}</span> 257 + </div> 258 + </div> 259 + 260 + {skippedFiles.length > 0 && ( 261 + <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 262 + <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 263 + <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 264 + <div className="flex-1"> 265 + <span className="font-medium"> 266 + {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 267 + </span> 268 + {uploadedCount > 0 && ( 269 + <span className="text-sm ml-2"> 270 + ({uploadedCount} uploaded successfully) 271 + </span> 272 + )} 273 + </div> 274 + </div> 275 + <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 276 + {skippedFiles.slice(0, 5).map((file, idx) => ( 277 + <div key={idx} className="text-xs"> 278 + <span className="font-mono">{file.name}</span> 279 + <span className="text-muted-foreground"> - {file.reason}</span> 280 + </div> 281 + ))} 282 + {skippedFiles.length > 5 && ( 283 + <div className="text-xs text-muted-foreground"> 284 + ...and {skippedFiles.length - 5} more 285 + </div> 286 + )} 287 + </div> 288 + </div> 289 + )} 290 + </div> 291 + )} 292 + 293 + <Button 294 + onClick={handleUpload} 295 + className="w-full" 296 + disabled={ 297 + (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 298 + isUploading || 299 + (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 300 + } 301 + > 302 + {isUploading ? ( 303 + <> 304 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 305 + Uploading... 306 + </> 307 + ) : ( 308 + <> 309 + {siteMode === 'existing' ? ( 310 + 'Update Site' 311 + ) : ( 312 + selectedFiles && selectedFiles.length > 0 313 + ? 'Upload & Deploy' 314 + : 'Create Empty Site' 315 + )} 316 + </> 317 + )} 318 + </Button> 319 + </CardContent> 320 + </Card> 321 + </div> 322 + ) 323 + }
public/favicon-16x16.png

This is a binary file and will not be displayed.

public/favicon-32x32.png

This is a binary file and will not be displayed.

public/favicon.ico

This is a binary file and will not be displayed.

-14
public/favicon.svg
··· 1 - <!--?xml version="1.0" encoding="utf-8"?--> 2 - <svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Centered large wisp on black background"> 3 - <!-- black background --> 4 - <rect width="64" height="64" fill="#000000"></rect> 5 - 6 - <!-- outer faint glow --> 7 - <circle cx="32" cy="32" r="14" fill="none" stroke="#7CF5D8" stroke-opacity="0.35" stroke-width="2.6"></circle> 8 - 9 - <!-- bright halo --> 10 - <circle cx="32" cy="32" r="10" fill="none" stroke="#CFF8EE" stroke-opacity="0.95" stroke-width="2.4"></circle> 11 - 12 - <!-- bright core --> 13 - <circle cx="32" cy="32" r="4" fill="#FFFFFF"></circle> 14 - </svg>
+23 -1
public/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>Elysia Static</title> 6 + <title>wisp.place</title> 7 + <meta name="description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution. Built on Bluesky's decentralized network." /> 8 + 9 + <!-- Open Graph / Facebook --> 10 + <meta property="og:type" content="website" /> 11 + <meta property="og:url" content="https://wisp.place/" /> 12 + <meta property="og:title" content="wisp.place - Decentralized Static Site Hosting" /> 13 + <meta property="og:description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution." /> 14 + <meta property="og:site_name" content="wisp.place" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary_large_image" /> 18 + <meta name="twitter:url" content="https://wisp.place/" /> 19 + <meta name="twitter:title" content="wisp.place - Decentralized Static Site Hosting" /> 20 + <meta name="twitter:description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution." /> 21 + 22 + <!-- Theme --> 23 + <meta name="theme-color" content="#7c3aed" /> 24 + 7 25 <link rel="icon" type="image/x-icon" href="./favicon.ico"> 26 + <link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"> 27 + <link rel="icon" type="image/png" sizes="16x16" href="./favicon-16x16.png"> 28 + <link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png"> 29 + <link rel="manifest" href="./site.webmanifest"> 8 30 </head> 9 31 <body> 10 32 <div id="elysia"></div>
+428 -16
public/index.tsx
··· 1 - import { useState, useRef, useEffect } from 'react' 1 + import React, { useState, useRef, useEffect } from 'react' 2 2 import { createRoot } from 'react-dom/client' 3 3 import { 4 4 ArrowRight, ··· 9 9 Code, 10 10 Server 11 11 } from 'lucide-react' 12 - 13 12 import Layout from '@public/layouts' 14 13 import { Button } from '@public/components/ui/button' 15 14 import { Card } from '@public/components/ui/card' 15 + import { BlueskyPostList, BlueskyProfile, BlueskyPost, AtProtoProvider, useLatestRecord, type AtProtoStyles, type FeedPostRecord } from 'atproto-ui' 16 + 17 + //Credit to https://tangled.org/@jakelazaroff.com/actor-typeahead 18 + interface Actor { 19 + handle: string 20 + avatar?: string 21 + displayName?: string 22 + } 23 + 24 + interface ActorTypeaheadProps { 25 + children: React.ReactElement<React.InputHTMLAttributes<HTMLInputElement>> 26 + host?: string 27 + rows?: number 28 + onSelect?: (handle: string) => void 29 + autoSubmit?: boolean 30 + } 31 + 32 + const ActorTypeahead: React.FC<ActorTypeaheadProps> = ({ 33 + children, 34 + host = 'https://public.api.bsky.app', 35 + rows = 5, 36 + onSelect, 37 + autoSubmit = false 38 + }) => { 39 + const [actors, setActors] = useState<Actor[]>([]) 40 + const [index, setIndex] = useState(-1) 41 + const [pressed, setPressed] = useState(false) 42 + const [isOpen, setIsOpen] = useState(false) 43 + const containerRef = useRef<HTMLDivElement>(null) 44 + const inputRef = useRef<HTMLInputElement>(null) 45 + const lastQueryRef = useRef<string>('') 46 + const previousValueRef = useRef<string>('') 47 + const preserveIndexRef = useRef(false) 48 + 49 + const handleInput = async (e: React.FormEvent<HTMLInputElement>) => { 50 + const query = e.currentTarget.value 51 + 52 + // Check if the value actually changed (filter out arrow key events) 53 + if (query === previousValueRef.current) { 54 + return 55 + } 56 + previousValueRef.current = query 57 + 58 + if (!query) { 59 + setActors([]) 60 + setIndex(-1) 61 + setIsOpen(false) 62 + lastQueryRef.current = '' 63 + return 64 + } 65 + 66 + // Store the query for this request 67 + const currentQuery = query 68 + lastQueryRef.current = currentQuery 69 + 70 + try { 71 + const url = new URL('xrpc/app.bsky.actor.searchActorsTypeahead', host) 72 + url.searchParams.set('q', query) 73 + url.searchParams.set('limit', `${rows}`) 74 + 75 + const res = await fetch(url) 76 + const json = await res.json() 77 + 78 + // Only update if this is still the latest query 79 + if (lastQueryRef.current === currentQuery) { 80 + setActors(json.actors || []) 81 + // Only reset index if we're not preserving it 82 + if (!preserveIndexRef.current) { 83 + setIndex(-1) 84 + } 85 + preserveIndexRef.current = false 86 + setIsOpen(true) 87 + } 88 + } catch (error) { 89 + console.error('Failed to fetch actors:', error) 90 + if (lastQueryRef.current === currentQuery) { 91 + setActors([]) 92 + setIsOpen(false) 93 + } 94 + } 95 + } 96 + 97 + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 98 + const navigationKeys = ['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Enter', 'Escape'] 99 + 100 + // Mark that we should preserve the index for navigation keys 101 + if (navigationKeys.includes(e.key)) { 102 + preserveIndexRef.current = true 103 + } 104 + 105 + if (!isOpen || actors.length === 0) return 106 + 107 + switch (e.key) { 108 + case 'ArrowDown': 109 + e.preventDefault() 110 + setIndex((prev) => { 111 + const newIndex = prev < 0 ? 0 : Math.min(prev + 1, actors.length - 1) 112 + return newIndex 113 + }) 114 + break 115 + case 'PageDown': 116 + e.preventDefault() 117 + setIndex(actors.length - 1) 118 + break 119 + case 'ArrowUp': 120 + e.preventDefault() 121 + setIndex((prev) => { 122 + const newIndex = prev < 0 ? 0 : Math.max(prev - 1, 0) 123 + return newIndex 124 + }) 125 + break 126 + case 'PageUp': 127 + e.preventDefault() 128 + setIndex(0) 129 + break 130 + case 'Escape': 131 + e.preventDefault() 132 + setActors([]) 133 + setIndex(-1) 134 + setIsOpen(false) 135 + break 136 + case 'Enter': 137 + if (index >= 0 && index < actors.length) { 138 + e.preventDefault() 139 + selectActor(actors[index].handle) 140 + } 141 + break 142 + } 143 + } 144 + 145 + const selectActor = (handle: string) => { 146 + if (inputRef.current) { 147 + inputRef.current.value = handle 148 + } 149 + setActors([]) 150 + setIndex(-1) 151 + setIsOpen(false) 152 + onSelect?.(handle) 153 + 154 + // Auto-submit the form if enabled 155 + if (autoSubmit && inputRef.current) { 156 + const form = inputRef.current.closest('form') 157 + if (form) { 158 + // Use setTimeout to ensure the value is set before submission 159 + setTimeout(() => { 160 + form.requestSubmit() 161 + }, 0) 162 + } 163 + } 164 + } 165 + 166 + const handleFocusOut = (e: React.FocusEvent) => { 167 + if (pressed) return 168 + setActors([]) 169 + setIndex(-1) 170 + setIsOpen(false) 171 + } 172 + 173 + // Clone the input element and add our event handlers 174 + const input = React.cloneElement(children, { 175 + ref: (el: HTMLInputElement) => { 176 + inputRef.current = el 177 + // Preserve the original ref if it exists 178 + const originalRef = (children as any).ref 179 + if (typeof originalRef === 'function') { 180 + originalRef(el) 181 + } else if (originalRef) { 182 + originalRef.current = el 183 + } 184 + }, 185 + onInput: (e: React.FormEvent<HTMLInputElement>) => { 186 + handleInput(e) 187 + children.props.onInput?.(e) 188 + }, 189 + onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => { 190 + handleKeyDown(e) 191 + children.props.onKeyDown?.(e) 192 + }, 193 + onBlur: (e: React.FocusEvent<HTMLInputElement>) => { 194 + handleFocusOut(e) 195 + children.props.onBlur?.(e) 196 + }, 197 + autoComplete: 'off' 198 + } as any) 199 + 200 + return ( 201 + <div ref={containerRef} style={{ position: 'relative', display: 'block' }}> 202 + {input} 203 + {isOpen && actors.length > 0 && ( 204 + <ul 205 + style={{ 206 + display: 'flex', 207 + flexDirection: 'column', 208 + position: 'absolute', 209 + left: 0, 210 + marginTop: '4px', 211 + width: '100%', 212 + listStyle: 'none', 213 + overflow: 'hidden', 214 + backgroundColor: 'rgba(255, 255, 255, 0.8)', 215 + backgroundClip: 'padding-box', 216 + backdropFilter: 'blur(12px)', 217 + WebkitBackdropFilter: 'blur(12px)', 218 + border: '1px solid rgba(0, 0, 0, 0.1)', 219 + borderRadius: '8px', 220 + boxShadow: '0 6px 6px -4px rgba(0, 0, 0, 0.2)', 221 + padding: '4px', 222 + margin: 0, 223 + zIndex: 1000 224 + }} 225 + onMouseDown={() => setPressed(true)} 226 + onMouseUp={() => { 227 + setPressed(false) 228 + inputRef.current?.focus() 229 + }} 230 + > 231 + {actors.map((actor, i) => ( 232 + <li key={actor.handle}> 233 + <button 234 + type="button" 235 + onClick={() => selectActor(actor.handle)} 236 + style={{ 237 + all: 'unset', 238 + boxSizing: 'border-box', 239 + display: 'flex', 240 + alignItems: 'center', 241 + gap: '8px', 242 + padding: '6px 8px', 243 + width: '100%', 244 + height: 'calc(1.5rem + 12px)', 245 + borderRadius: '4px', 246 + cursor: 'pointer', 247 + backgroundColor: i === index ? 'hsl(var(--accent) / 0.5)' : 'transparent', 248 + transition: 'background-color 0.1s' 249 + }} 250 + onMouseEnter={() => setIndex(i)} 251 + > 252 + <div 253 + style={{ 254 + width: '1.5rem', 255 + height: '1.5rem', 256 + borderRadius: '50%', 257 + backgroundColor: 'hsl(var(--muted))', 258 + overflow: 'hidden', 259 + flexShrink: 0 260 + }} 261 + > 262 + {actor.avatar && ( 263 + <img 264 + src={actor.avatar} 265 + alt="" 266 + style={{ 267 + display: 'block', 268 + width: '100%', 269 + height: '100%', 270 + objectFit: 'cover' 271 + }} 272 + /> 273 + )} 274 + </div> 275 + <span 276 + style={{ 277 + whiteSpace: 'nowrap', 278 + overflow: 'hidden', 279 + textOverflow: 'ellipsis', 280 + color: '#000000' 281 + }} 282 + > 283 + {actor.handle} 284 + </span> 285 + </button> 286 + </li> 287 + ))} 288 + </ul> 289 + )} 290 + </div> 291 + ) 292 + } 293 + 294 + const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => { 295 + const { record, rkey, loading } = useLatestRecord<FeedPostRecord>( 296 + did, 297 + 'app.bsky.feed.post' 298 + ) 299 + 300 + if (loading) return <span>Loadingโ€ฆ</span> 301 + if (!record || !rkey) return <span>No posts yet.</span> 302 + 303 + return <BlueskyPost did={did} rkey={rkey} record={record} showParent={true} /> 304 + } 16 305 17 306 function App() { 18 307 const [showForm, setShowForm] = useState(false) 308 + const [checkingAuth, setCheckingAuth] = useState(true) 19 309 const inputRef = useRef<HTMLInputElement>(null) 20 310 21 311 useEffect(() => { 312 + // Check authentication status on mount 313 + const checkAuth = async () => { 314 + try { 315 + const response = await fetch('/api/auth/status', { 316 + credentials: 'include' 317 + }) 318 + const data = await response.json() 319 + if (data.authenticated) { 320 + // User is already authenticated, redirect to editor 321 + window.location.href = '/editor' 322 + return 323 + } 324 + // If not authenticated, clear any stale cookies 325 + document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 326 + } catch (error) { 327 + console.error('Auth check failed:', error) 328 + // Clear cookies on error as well 329 + document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 330 + } finally { 331 + setCheckingAuth(false) 332 + } 333 + } 334 + 335 + checkAuth() 336 + }, []) 337 + 338 + useEffect(() => { 22 339 if (showForm) { 23 340 setTimeout(() => inputRef.current?.focus(), 500) 24 341 } 25 342 }, [showForm]) 26 343 344 + if (checkingAuth) { 345 + return ( 346 + <div className="min-h-screen bg-background flex items-center justify-center"> 347 + <div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div> 348 + </div> 349 + ) 350 + } 351 + 27 352 return ( 28 353 <> 29 354 <div className="min-h-screen"> ··· 31 356 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 32 357 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 33 358 <div className="flex items-center gap-2"> 34 - <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 35 - <Globe className="w-5 h-5 text-primary-foreground" /> 36 - </div> 359 + <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 37 360 <span className="text-xl font-semibold text-foreground"> 38 361 wisp.place 39 362 </span> ··· 49 372 <Button 50 373 size="sm" 51 374 className="bg-accent text-accent-foreground hover:bg-accent/90" 375 + onClick={() => setShowForm(true)} 52 376 > 53 377 Get Started 54 378 </Button> ··· 61 385 <div className="max-w-4xl mx-auto text-center"> 62 386 <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8"> 63 387 <span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span> 64 - <span className="text-sm text-accent-foreground"> 388 + <span className="text-sm text-foreground"> 65 389 Built on AT Protocol 66 390 </span> 67 391 </div> ··· 135 459 'Login failed:', 136 460 error 137 461 ) 462 + // Clear any invalid cookies 463 + document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 138 464 alert('Authentication failed') 139 465 } 140 466 }} 141 467 className="space-y-3" 142 468 > 143 - <input 144 - ref={inputRef} 145 - type="text" 146 - name="handle" 147 - placeholder="Enter your handle (e.g., alice.bsky.social)" 148 - className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" 149 - /> 469 + <ActorTypeahead 470 + autoSubmit={true} 471 + onSelect={(handle) => { 472 + if (inputRef.current) { 473 + inputRef.current.value = handle 474 + } 475 + }} 476 + > 477 + <input 478 + ref={inputRef} 479 + type="text" 480 + name="handle" 481 + placeholder="Enter your handle (e.g., alice.bsky.social)" 482 + className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" 483 + /> 484 + </ActorTypeahead> 150 485 <button 151 486 type="submit" 152 487 className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors" ··· 286 621 287 622 {/* CTA Section */} 288 623 <section className="container mx-auto px-4 py-20"> 624 + <div className="max-w-6xl mx-auto"> 625 + <div className="text-center mb-12"> 626 + <h2 className="text-3xl md:text-4xl font-bold"> 627 + Follow on Bluesky for updates 628 + </h2> 629 + </div> 630 + <div className="grid md:grid-cols-2 gap-8 items-center"> 631 + <Card 632 + className="shadow-lg border-2 border-border overflow-hidden !py-3" 633 + style={{ 634 + '--atproto-color-bg': 'var(--card)', 635 + '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 636 + '--atproto-color-text': 'hsl(var(--foreground))', 637 + '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 638 + '--atproto-color-link': 'hsl(var(--accent))', 639 + '--atproto-color-link-hover': 'hsl(var(--accent))', 640 + '--atproto-color-border': 'transparent', 641 + } as AtProtoStyles} 642 + > 643 + <BlueskyPostList did="wisp.place" /> 644 + </Card> 645 + <div className="space-y-6 w-full max-w-md mx-auto"> 646 + <Card 647 + className="shadow-lg border-2 overflow-hidden relative !py-3" 648 + style={{ 649 + '--atproto-color-bg': 'var(--card)', 650 + '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 651 + '--atproto-color-text': 'hsl(var(--foreground))', 652 + '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 653 + } as AtProtoStyles} 654 + > 655 + <BlueskyProfile did="wisp.place" /> 656 + </Card> 657 + <Card 658 + className="shadow-lg border-2 overflow-hidden relative !py-3" 659 + style={{ 660 + '--atproto-color-bg': 'var(--card)', 661 + '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 662 + '--atproto-color-text': 'hsl(var(--foreground))', 663 + '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 664 + } as AtProtoStyles} 665 + > 666 + <LatestPostWithPrefetch did="wisp.place" /> 667 + </Card> 668 + </div> 669 + </div> 670 + </div> 671 + </section> 672 + 673 + {/* Ready to Deploy CTA */} 674 + <section className="container mx-auto px-4 py-20"> 289 675 <div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12"> 290 676 <h2 className="text-3xl md:text-4xl font-bold mb-4"> 291 677 Ready to deploy? ··· 319 705 > 320 706 @nekomimi.pet 321 707 </a> 708 + {' โ€ข '} 709 + Contact:{' '} 710 + <a 711 + href="mailto:contact@wisp.place" 712 + className="text-accent hover:text-accent/80 transition-colors font-medium" 713 + > 714 + contact@wisp.place 715 + </a> 716 + {' โ€ข '} 717 + Legal/DMCA:{' '} 718 + <a 719 + href="mailto:legal@wisp.place" 720 + className="text-accent hover:text-accent/80 transition-colors font-medium" 721 + > 722 + legal@wisp.place 723 + </a> 724 + </p> 725 + <p className="mt-2"> 726 + <a 727 + href="/acceptable-use" 728 + className="text-accent hover:text-accent/80 transition-colors font-medium" 729 + > 730 + Acceptable Use Policy 731 + </a> 322 732 </p> 323 733 </div> 324 734 </div> ··· 330 740 331 741 const root = createRoot(document.getElementById('elysia')!) 332 742 root.render( 333 - <Layout className="gap-6"> 334 - <App /> 335 - </Layout> 743 + <AtProtoProvider> 744 + <Layout className="gap-6"> 745 + <App /> 746 + </Layout> 747 + </AtProtoProvider> 336 748 )
+24
public/layouts/index.tsx
··· 1 1 import type { PropsWithChildren } from 'react' 2 + import { useEffect } from 'react' 2 3 3 4 import { QueryClientProvider, QueryClient } from '@tanstack/react-query' 4 5 import clsx from 'clsx' ··· 12 13 } 13 14 14 15 export default function Layout({ children, className }: LayoutProps) { 16 + useEffect(() => { 17 + // Function to update dark mode based on system preference 18 + const updateDarkMode = (e: MediaQueryList | MediaQueryListEvent) => { 19 + if (e.matches) { 20 + document.documentElement.classList.add('dark') 21 + } else { 22 + document.documentElement.classList.remove('dark') 23 + } 24 + } 25 + 26 + // Create media query 27 + const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)') 28 + 29 + // Set initial value 30 + updateDarkMode(darkModeQuery) 31 + 32 + // Listen for changes 33 + darkModeQuery.addEventListener('change', updateDarkMode) 34 + 35 + // Cleanup 36 + return () => darkModeQuery.removeEventListener('change', updateDarkMode) 37 + }, []) 38 + 15 39 return ( 16 40 <QueryClientProvider client={client}> 17 41 <div
+18 -1
public/onboarding/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>Get Started - wisp.place</title> 6 + <title>wisp.place</title> 7 + <meta name="description" content="Get started with wisp.place and host your first decentralized static site on AT Protocol." /> 8 + 9 + <!-- Open Graph / Facebook --> 10 + <meta property="og:type" content="website" /> 11 + <meta property="og:url" content="https://wisp.place/onboarding" /> 12 + <meta property="og:title" content="Get Started - wisp.place" /> 13 + <meta property="og:description" content="Get started with wisp.place and host your first decentralized static site on AT Protocol." /> 14 + <meta property="og:site_name" content="wisp.place" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary" /> 18 + <meta name="twitter:url" content="https://wisp.place/onboarding" /> 19 + <meta name="twitter:title" content="Get Started - wisp.place" /> 20 + <meta name="twitter:description" content="Get started with wisp.place and host your first decentralized static site on AT Protocol." /> 21 + 22 + <!-- Theme --> 23 + <meta name="theme-color" content="#7c3aed" /> 7 24 </head> 8 25 <body> 9 26 <div id="elysia"></div>
+21
public/robots.txt
··· 1 + # robots.txt for wisp.place 2 + 3 + User-agent: * 4 + 5 + # Allow indexing of landing page 6 + Allow: /$ 7 + 8 + # Disallow application pages 9 + Disallow: /editor 10 + Disallow: /admin 11 + Disallow: /onboarding 12 + 13 + # Disallow API routes 14 + Disallow: /api/ 15 + Disallow: /wisp/ 16 + 17 + # Allow static assets 18 + Allow: /favicon.ico 19 + Allow: /favicon-*.png 20 + Allow: /apple-touch-icon.png 21 + Allow: /site.webmanifest
+1
public/site.webmanifest
··· 1 + {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+62 -36
public/styles/global.css
··· 1 1 @import "tailwindcss"; 2 2 @import "tw-animate-css"; 3 3 4 - @custom-variant dark (&:is(.dark *)); 4 + @custom-variant dark (@media (prefers-color-scheme: dark)); 5 5 6 6 :root { 7 + color-scheme: light; 8 + 7 9 /* Warm beige background inspired by Sunset design #E9DDD8 */ 8 10 --background: oklch(0.90 0.012 35); 9 11 /* Very dark brown text for strong contrast #2A2420 */ ··· 57 59 } 58 60 59 61 .dark { 60 - /* #413C58 - violet background for dark mode */ 61 - --background: oklch(0.28 0.04 285); 62 - /* #F2E7C9 - parchment text */ 63 - --foreground: oklch(0.93 0.03 85); 62 + color-scheme: dark; 64 63 65 - --card: oklch(0.32 0.04 285); 66 - --card-foreground: oklch(0.93 0.03 85); 64 + /* Slate violet background - #2C2C2C with violet tint */ 65 + --background: oklch(0.23 0.015 285); 66 + /* Light gray text - #E4E4E4 */ 67 + --foreground: oklch(0.90 0.005 285); 67 68 68 - --popover: oklch(0.32 0.04 285); 69 - --popover-foreground: oklch(0.93 0.03 85); 69 + /* Slightly lighter slate for cards */ 70 + --card: oklch(0.28 0.015 285); 71 + --card-foreground: oklch(0.90 0.005 285); 70 72 71 - /* #FFAAD2 - pink primary in dark mode */ 72 - --primary: oklch(0.78 0.15 345); 73 - --primary-foreground: oklch(0.32 0.04 285); 73 + --popover: oklch(0.28 0.015 285); 74 + --popover-foreground: oklch(0.90 0.005 285); 74 75 75 - --accent: oklch(0.78 0.15 345); 76 - --accent-foreground: oklch(0.32 0.04 285); 76 + /* Lavender buttons - #B39CD0 */ 77 + --primary: oklch(0.70 0.10 295); 78 + --primary-foreground: oklch(0.23 0.015 285); 77 79 78 - --secondary: oklch(0.56 0.08 220); 79 - --secondary-foreground: oklch(0.93 0.03 85); 80 + /* Soft pink accent - #FFC1CC */ 81 + --accent: oklch(0.85 0.08 5); 82 + --accent-foreground: oklch(0.23 0.015 285); 80 83 81 - --muted: oklch(0.38 0.03 285); 82 - --muted-foreground: oklch(0.75 0.02 85); 84 + /* Light cyan secondary - #A8DADC */ 85 + --secondary: oklch(0.82 0.05 200); 86 + --secondary-foreground: oklch(0.23 0.015 285); 83 87 84 - --border: oklch(0.42 0.03 285); 85 - --input: oklch(0.42 0.03 285); 86 - --ring: oklch(0.78 0.15 345); 88 + /* Muted slate areas */ 89 + --muted: oklch(0.33 0.015 285); 90 + --muted-foreground: oklch(0.72 0.01 285); 87 91 88 - --destructive: oklch(0.577 0.245 27.325); 89 - --destructive-foreground: oklch(0.985 0 0); 92 + /* Subtle borders */ 93 + --border: oklch(0.38 0.02 285); 94 + --input: oklch(0.30 0.015 285); 95 + --ring: oklch(0.70 0.10 295); 90 96 91 - --chart-1: oklch(0.78 0.15 345); 92 - --chart-2: oklch(0.93 0.03 85); 93 - --chart-3: oklch(0.56 0.08 220); 94 - --chart-4: oklch(0.85 0.02 130); 95 - --chart-5: oklch(0.32 0.04 285); 96 - --sidebar: oklch(0.205 0 0); 97 - --sidebar-foreground: oklch(0.985 0 0); 98 - --sidebar-primary: oklch(0.488 0.243 264.376); 99 - --sidebar-primary-foreground: oklch(0.985 0 0); 100 - --sidebar-accent: oklch(0.269 0 0); 101 - --sidebar-accent-foreground: oklch(0.985 0 0); 102 - --sidebar-border: oklch(0.269 0 0); 103 - --sidebar-ring: oklch(0.439 0 0); 97 + /* Warm destructive color */ 98 + --destructive: oklch(0.60 0.22 27); 99 + --destructive-foreground: oklch(0.98 0.01 85); 100 + 101 + /* Chart colors using the accent palette */ 102 + --chart-1: oklch(0.85 0.08 5); 103 + --chart-2: oklch(0.82 0.05 200); 104 + --chart-3: oklch(0.70 0.10 295); 105 + --chart-4: oklch(0.75 0.08 340); 106 + --chart-5: oklch(0.65 0.08 180); 107 + 108 + /* Sidebar slate */ 109 + --sidebar: oklch(0.20 0.015 285); 110 + --sidebar-foreground: oklch(0.90 0.005 285); 111 + --sidebar-primary: oklch(0.70 0.10 295); 112 + --sidebar-primary-foreground: oklch(0.20 0.015 285); 113 + --sidebar-accent: oklch(0.28 0.015 285); 114 + --sidebar-accent-foreground: oklch(0.90 0.005 285); 115 + --sidebar-border: oklch(0.32 0.02 285); 116 + --sidebar-ring: oklch(0.70 0.10 295); 104 117 } 105 118 106 119 @theme inline { ··· 164 177 .arrow-animate { 165 178 animation: arrow-bounce 1.5s ease-in-out infinite; 166 179 } 180 + 181 + /* Shiki syntax highlighting styles */ 182 + .shiki-wrapper { 183 + border-radius: 0.5rem; 184 + padding: 1rem; 185 + overflow-x: auto; 186 + border: 1px solid hsl(var(--border)); 187 + } 188 + 189 + .shiki-wrapper pre { 190 + margin: 0 !important; 191 + padding: 0 !important; 192 + }
public/transparent-full-size-ico.png

This is a binary file and will not be displayed.

+2 -2
scripts/change-admin-password.ts
··· 1 1 // Change admin password 2 - import { adminAuth } from './src/lib/admin-auth' 3 - import { db } from './src/lib/db' 2 + import { adminAuth } from '../src/lib/admin-auth' 3 + import { db } from '../src/lib/db' 4 4 import { randomBytes, createHash } from 'crypto' 5 5 6 6 // Get username and new password from command line
+35 -14
src/index.ts
··· 1 1 import { Elysia } from 'elysia' 2 + import type { Context } from 'elysia' 2 3 import { cors } from '@elysiajs/cors' 3 - import { openapi, fromTypes } from '@elysiajs/openapi' 4 4 import { staticPlugin } from '@elysiajs/static' 5 5 6 6 import type { Config } from './lib/types' ··· 12 12 cleanupExpiredSessions, 13 13 rotateKeysIfNeeded 14 14 } from './lib/oauth-client' 15 + import { getCookieSecret } from './lib/db' 15 16 import { authRoutes } from './routes/auth' 16 17 import { wispRoutes } from './routes/wisp' 17 18 import { domainRoutes } from './routes/domain' ··· 31 32 // Initialize admin setup (prompt if no admin exists) 32 33 await promptAdminSetup() 33 34 35 + // Get or generate cookie signing secret 36 + const cookieSecret = await getCookieSecret() 37 + 34 38 const client = await getOAuthClient(config) 35 39 36 40 // Periodic maintenance: cleanup expired sessions and rotate keys ··· 58 62 dnsVerifier.start() 59 63 logger.info('DNS Verifier Started - checking custom domains every 10 minutes') 60 64 61 - export const app = new Elysia() 62 - .use(openapi({ 63 - references: fromTypes() 64 - })) 65 + export const app = new Elysia({ 66 + serve: { 67 + maxRequestBodySize: 1024 * 1024 * 128 * 3, 68 + development: Bun.env.NODE_ENV !== 'production' ? true : false, 69 + id: Bun.env.NODE_ENV !== 'production' ? undefined : null, 70 + }, 71 + cookie: { 72 + secrets: cookieSecret, 73 + sign: ['did'] 74 + } 75 + }) 65 76 // Observability middleware 66 77 .onBeforeHandle(observabilityMiddleware('main-app').beforeHandle) 67 - .onAfterHandle((ctx) => { 78 + .onAfterHandle((ctx: Context) => { 68 79 observabilityMiddleware('main-app').afterHandle(ctx) 69 80 // Security headers middleware 70 81 const { set } = ctx ··· 93 104 }) 94 105 .onError(observabilityMiddleware('main-app').onError) 95 106 .use(csrfProtection()) 96 - .use(authRoutes(client)) 97 - .use(wispRoutes(client)) 98 - .use(domainRoutes(client)) 99 - .use(userRoutes(client)) 100 - .use(siteRoutes(client)) 101 - .use(adminRoutes()) 107 + .use(authRoutes(client, cookieSecret)) 108 + .use(wispRoutes(client, cookieSecret)) 109 + .use(domainRoutes(client, cookieSecret)) 110 + .use(userRoutes(client, cookieSecret)) 111 + .use(siteRoutes(client, cookieSecret)) 112 + .use(adminRoutes(cookieSecret)) 102 113 .use( 103 114 await staticPlugin({ 104 115 prefix: '/' 105 116 }) 106 117 ) 107 - .get('/client-metadata.json', (c) => { 118 + .get('/client-metadata.json', () => { 108 119 return createClientMetadata(config) 109 120 }) 110 - .get('/jwks.json', async (c) => { 121 + .get('/jwks.json', async ({ set }) => { 122 + // Prevent caching to ensure clients always get fresh keys after rotation 123 + set.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' 124 + set.headers['Pragma'] = 'no-cache' 125 + set.headers['Expires'] = '0' 126 + 111 127 const keys = await getCurrentKeys() 112 128 if (!keys.length) return { keys: [] } 113 129 ··· 143 159 error: error instanceof Error ? error.message : String(error) 144 160 } 145 161 } 162 + }) 163 + .get('/.well-known/atproto-did', ({ set }) => { 164 + // Return plain text DID for AT Protocol domain verification 165 + set.headers['Content-Type'] = 'text/plain' 166 + return 'did:plc:7puq73yz2hkvbcpdhnsze2qw' 146 167 }) 147 168 .use(cors({ 148 169 origin: config.domain,
+182 -15
src/lib/db.ts
··· 36 36 ) 37 37 `; 38 38 39 - // Domains table maps subdomain -> DID 39 + // Cookie secrets table for signed cookies 40 + await db` 41 + CREATE TABLE IF NOT EXISTS cookie_secrets ( 42 + id TEXT PRIMARY KEY DEFAULT 'default', 43 + secret TEXT NOT NULL, 44 + created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 45 + ) 46 + `; 47 + 48 + // Domains table maps subdomain -> DID (now supports up to 3 domains per user) 40 49 await db` 41 50 CREATE TABLE IF NOT EXISTS domains ( 42 51 domain TEXT PRIMARY KEY, 43 - did TEXT UNIQUE NOT NULL, 52 + did TEXT NOT NULL, 44 53 rkey TEXT, 45 54 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 46 55 ) ··· 71 80 // Column might already exist, ignore 72 81 } 73 82 83 + // Remove the unique constraint on domains.did to allow multiple domains per user 84 + try { 85 + await db`ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_did_key`; 86 + } catch (err) { 87 + // Constraint might already be removed, ignore 88 + } 89 + 74 90 // Custom domains table for BYOD (bring your own domain) 75 91 await db` 76 92 CREATE TABLE IF NOT EXISTS custom_domains ( ··· 108 124 ) 109 125 `; 110 126 127 + // Create indexes for common query patterns 128 + await Promise.all([ 129 + // oauth_states cleanup queries 130 + db`CREATE INDEX IF NOT EXISTS idx_oauth_states_expires_at ON oauth_states(expires_at)`.catch(err => { 131 + if (!err.message?.includes('already exists')) { 132 + console.error('Failed to create idx_oauth_states_expires_at:', err); 133 + } 134 + }), 135 + 136 + // oauth_sessions cleanup queries 137 + db`CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires_at ON oauth_sessions(expires_at)`.catch(err => { 138 + if (!err.message?.includes('already exists')) { 139 + console.error('Failed to create idx_oauth_sessions_expires_at:', err); 140 + } 141 + }), 142 + 143 + // oauth_keys key rotation queries 144 + db`CREATE INDEX IF NOT EXISTS idx_oauth_keys_created_at ON oauth_keys(created_at)`.catch(err => { 145 + if (!err.message?.includes('already exists')) { 146 + console.error('Failed to create idx_oauth_keys_created_at:', err); 147 + } 148 + }), 149 + 150 + // domains queries by (did, rkey) 151 + db`CREATE INDEX IF NOT EXISTS idx_domains_did_rkey ON domains(did, rkey)`.catch(err => { 152 + if (!err.message?.includes('already exists')) { 153 + console.error('Failed to create idx_domains_did_rkey:', err); 154 + } 155 + }), 156 + 157 + // custom_domains queries by did 158 + db`CREATE INDEX IF NOT EXISTS idx_custom_domains_did ON custom_domains(did)`.catch(err => { 159 + if (!err.message?.includes('already exists')) { 160 + console.error('Failed to create idx_custom_domains_did:', err); 161 + } 162 + }), 163 + 164 + // custom_domains queries by (did, rkey) 165 + db`CREATE INDEX IF NOT EXISTS idx_custom_domains_did_rkey ON custom_domains(did, rkey)`.catch(err => { 166 + if (!err.message?.includes('already exists')) { 167 + console.error('Failed to create idx_custom_domains_did_rkey:', err); 168 + } 169 + }), 170 + 171 + // custom_domains DNS verification worker queries 172 + db`CREATE INDEX IF NOT EXISTS idx_custom_domains_verified ON custom_domains(verified)`.catch(err => { 173 + if (!err.message?.includes('already exists')) { 174 + console.error('Failed to create idx_custom_domains_verified:', err); 175 + } 176 + }), 177 + 178 + // sites queries by did 179 + db`CREATE INDEX IF NOT EXISTS idx_sites_did ON sites(did)`.catch(err => { 180 + if (!err.message?.includes('already exists')) { 181 + console.error('Failed to create idx_sites_did:', err); 182 + } 183 + }) 184 + ]); 185 + 111 186 const RESERVED_HANDLES = new Set([ 112 187 "www", 113 188 "api", ··· 130 205 export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`; 131 206 132 207 export const getDomainByDid = async (did: string): Promise<string | null> => { 133 - const rows = await db`SELECT domain FROM domains WHERE did = ${did}`; 208 + const rows = await db`SELECT domain FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`; 134 209 return rows[0]?.domain ?? null; 135 210 }; 136 211 137 212 export const getWispDomainInfo = async (did: string) => { 138 - const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`; 213 + const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`; 139 214 return rows[0] ?? null; 215 + }; 216 + 217 + export const getAllWispDomains = async (did: string) => { 218 + const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC`; 219 + return rows; 220 + }; 221 + 222 + export const countWispDomains = async (did: string): Promise<number> => { 223 + const rows = await db`SELECT COUNT(*) as count FROM domains WHERE did = ${did}`; 224 + return Number(rows[0]?.count ?? 0); 140 225 }; 141 226 142 227 export const getDidByDomain = async (domain: string): Promise<string | null> => { ··· 192 277 export const claimDomain = async (did: string, handle: string): Promise<string> => { 193 278 const h = handle.trim().toLowerCase(); 194 279 if (!isValidHandle(h)) throw new Error('invalid_handle'); 280 + 281 + // Check if user already has 3 domains 282 + const existingCount = await countWispDomains(did); 283 + if (existingCount >= 3) { 284 + throw new Error('domain_limit_reached'); 285 + } 286 + 195 287 const domain = toDomain(h); 196 288 try { 197 289 await db` ··· 199 291 VALUES (${domain}, ${did}) 200 292 `; 201 293 } catch (err) { 202 - // Unique constraint violations -> already taken or DID already claimed 294 + // Unique constraint violations -> already taken 203 295 throw new Error('conflict'); 204 296 } 205 297 return domain; ··· 224 316 } 225 317 }; 226 318 227 - export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => { 319 + export const updateWispDomainSite = async (domain: string, siteRkey: string | null): Promise<void> => { 228 320 await db` 229 321 UPDATE domains 230 322 SET rkey = ${siteRkey} 231 - WHERE did = ${did} 323 + WHERE domain = ${domain} 232 324 `; 233 325 }; 234 326 235 327 export const getWispDomainSite = async (did: string): Promise<string | null> => { 236 - const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`; 328 + const rows = await db`SELECT rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`; 237 329 return rows[0]?.rkey ?? null; 238 330 }; 239 331 332 + export const deleteWispDomain = async (domain: string): Promise<void> => { 333 + await db`DELETE FROM domains WHERE domain = ${domain}`; 334 + }; 335 + 240 336 // Session timeout configuration (30 days in seconds) 241 337 const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds 242 338 // OAuth state timeout (1 hour in seconds) ··· 244 340 245 341 const stateStore = { 246 342 async set(key: string, data: any) { 247 - console.debug('[stateStore] set', key) 248 343 const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT; 249 344 await db` 250 345 INSERT INTO oauth_states (key, data, created_at, expires_at) ··· 253 348 `; 254 349 }, 255 350 async get(key: string) { 256 - console.debug('[stateStore] get', key) 257 351 const now = Math.floor(Date.now() / 1000); 258 352 const result = await db` 259 353 SELECT data, expires_at ··· 265 359 // Check if expired 266 360 const expiresAt = Number(result[0].expires_at); 267 361 if (expiresAt && now > expiresAt) { 268 - console.debug('[stateStore] State expired, deleting', key); 269 362 await db`DELETE FROM oauth_states WHERE key = ${key}`; 270 363 return undefined; 271 364 } ··· 273 366 return JSON.parse(result[0].data); 274 367 }, 275 368 async del(key: string) { 276 - console.debug('[stateStore] del', key) 277 369 await db`DELETE FROM oauth_states WHERE key = ${key}`; 278 370 } 279 371 }; 280 372 281 373 const sessionStore = { 282 374 async set(sub: string, data: any) { 283 - console.debug('[sessionStore] set', sub) 284 375 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT; 285 376 await db` 286 377 INSERT INTO oauth_sessions (sub, data, updated_at, expires_at) ··· 292 383 `; 293 384 }, 294 385 async get(sub: string) { 295 - console.debug('[sessionStore] get', sub) 296 386 const now = Math.floor(Date.now() / 1000); 297 387 const result = await db` 298 388 SELECT data, expires_at ··· 312 402 return JSON.parse(result[0].data); 313 403 }, 314 404 async del(sub: string) { 315 - console.debug('[sessionStore] del', sub) 316 405 await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 317 406 } 318 407 }; ··· 578 667 return { success: false, error: err }; 579 668 } 580 669 }; 670 + 671 + // Get all domains (wisp + custom) mapped to a specific site 672 + export const getDomainsBySite = async (did: string, rkey: string) => { 673 + const domains: Array<{ 674 + type: 'wisp' | 'custom'; 675 + domain: string; 676 + verified?: boolean; 677 + id?: string; 678 + }> = []; 679 + 680 + // Check wisp domain 681 + const wispDomain = await db` 682 + SELECT domain, rkey FROM domains 683 + WHERE did = ${did} AND rkey = ${rkey} 684 + `; 685 + if (wispDomain.length > 0) { 686 + domains.push({ 687 + type: 'wisp', 688 + domain: wispDomain[0].domain, 689 + }); 690 + } 691 + 692 + // Check custom domains 693 + const customDomains = await db` 694 + SELECT id, domain, verified FROM custom_domains 695 + WHERE did = ${did} AND rkey = ${rkey} 696 + ORDER BY created_at DESC 697 + `; 698 + for (const cd of customDomains) { 699 + domains.push({ 700 + type: 'custom', 701 + domain: cd.domain, 702 + verified: cd.verified, 703 + id: cd.id, 704 + }); 705 + } 706 + 707 + return domains; 708 + }; 709 + 710 + // Get count of domains mapped to a specific site 711 + export const getDomainCountBySite = async (did: string, rkey: string) => { 712 + const wispCount = await db` 713 + SELECT COUNT(*) as count FROM domains 714 + WHERE did = ${did} AND rkey = ${rkey} 715 + `; 716 + 717 + const customCount = await db` 718 + SELECT COUNT(*) as count FROM custom_domains 719 + WHERE did = ${did} AND rkey = ${rkey} 720 + `; 721 + 722 + return { 723 + wisp: Number(wispCount[0]?.count || 0), 724 + custom: Number(customCount[0]?.count || 0), 725 + total: Number(wispCount[0]?.count || 0) + Number(customCount[0]?.count || 0), 726 + }; 727 + }; 728 + 729 + // Cookie secret management - ensure we have a secret for signing cookies 730 + export const getCookieSecret = async (): Promise<string> => { 731 + // Check if secret already exists 732 + const rows = await db`SELECT secret FROM cookie_secrets WHERE id = 'default' LIMIT 1`; 733 + 734 + if (rows.length > 0) { 735 + return rows[0].secret as string; 736 + } 737 + 738 + // Generate new secret if none exists 739 + const secret = crypto.randomUUID() + crypto.randomUUID(); // 72 character random string 740 + await db` 741 + INSERT INTO cookie_secrets (id, secret, created_at) 742 + VALUES ('default', ${secret}, EXTRACT(EPOCH FROM NOW())) 743 + `; 744 + 745 + console.log('[CookieSecret] Generated new cookie signing secret'); 746 + return secret; 747 + };
+19 -3
src/lib/dns-verify.ts
··· 135 135 } 136 136 137 137 /** 138 - * Verify both TXT and CNAME records for a custom domain 138 + * Verify custom domain using TXT record as authoritative proof 139 + * CNAME check is optional/advisory - TXT record is sufficient for verification 140 + * 141 + * This approach works with CNAME flattening (e.g., Cloudflare) where the CNAME 142 + * is resolved to A/AAAA records and won't be visible in DNS queries. 139 143 */ 140 144 export const verifyCustomDomain = async ( 141 145 domain: string, 142 146 expectedDid: string, 143 147 expectedHash: string 144 148 ): Promise<VerificationResult> => { 149 + // TXT record is authoritative - it proves ownership 145 150 const txtResult = await verifyDomainOwnership(domain, expectedDid) 146 151 if (!txtResult.verified) { 147 152 return txtResult 148 153 } 149 154 155 + // CNAME check is advisory only - we still check it for logging/debugging 156 + // but don't fail verification if it's missing (could be flattened) 150 157 const cnameResult = await verifyCNAME(domain, expectedHash) 158 + 159 + // Log CNAME status for debugging, but don't fail on it 151 160 if (!cnameResult.verified) { 152 - return cnameResult 161 + console.log(`[DNS Verify] โš ๏ธ CNAME verification failed (may be flattened):`, cnameResult.error) 153 162 } 154 163 155 - return { verified: true } 164 + // TXT verification is sufficient 165 + return { 166 + verified: true, 167 + found: { 168 + txt: txtResult.found?.txt, 169 + cname: cnameResult.found?.cname 170 + } 171 + } 156 172 }
-1
src/lib/oauth-client.ts
··· 58 58 `; 59 59 }, 60 60 async get(sub: string) { 61 - console.debug('[sessionStore] get', sub) 62 61 const now = Math.floor(Date.now() / 1000); 63 62 const result = await db` 64 63 SELECT data, expires_at
+10 -6
src/lib/observability.ts
··· 312 312 service 313 313 ) 314 314 315 - logCollector.error( 316 - `Request failed: ${request.method} ${url.pathname}`, 317 - service, 318 - error, 319 - { statusCode: set.status || 500 } 320 - ) 315 + // Don't log 404 errors 316 + const statusCode = set.status || 500 317 + if (statusCode !== 404) { 318 + logCollector.error( 319 + `Request failed: ${request.method} ${url.pathname}`, 320 + service, 321 + error, 322 + { statusCode } 323 + ) 324 + } 321 325 } 322 326 } 323 327 }
+360
src/lib/wisp-utils.test.ts
··· 5 5 processUploadedFiles, 6 6 createManifest, 7 7 updateFileBlobs, 8 + computeCID, 9 + extractBlobMap, 8 10 type UploadedFile, 9 11 type FileUploadResult, 10 12 } from './wisp-utils' ··· 637 639 } 638 640 }) 639 641 }) 642 + 643 + describe('computeCID', () => { 644 + test('should compute CID for gzipped+base64 encoded content', () => { 645 + // This simulates the actual flow: gzip -> base64 -> compute CID 646 + const originalContent = Buffer.from('Hello, World!') 647 + const gzipped = compressFile(originalContent) 648 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 649 + 650 + const cid = computeCID(base64Content) 651 + 652 + // CID should be a valid CIDv1 string starting with 'bafkrei' 653 + expect(cid).toMatch(/^bafkrei[a-z0-9]+$/) 654 + expect(cid.length).toBeGreaterThan(10) 655 + }) 656 + 657 + test('should compute deterministic CIDs for identical content', () => { 658 + const content = Buffer.from('Test content for CID calculation') 659 + const gzipped = compressFile(content) 660 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 661 + 662 + const cid1 = computeCID(base64Content) 663 + const cid2 = computeCID(base64Content) 664 + 665 + expect(cid1).toBe(cid2) 666 + }) 667 + 668 + test('should compute different CIDs for different content', () => { 669 + const content1 = Buffer.from('Content A') 670 + const content2 = Buffer.from('Content B') 671 + 672 + const gzipped1 = compressFile(content1) 673 + const gzipped2 = compressFile(content2) 674 + 675 + const base64Content1 = Buffer.from(gzipped1.toString('base64'), 'binary') 676 + const base64Content2 = Buffer.from(gzipped2.toString('base64'), 'binary') 677 + 678 + const cid1 = computeCID(base64Content1) 679 + const cid2 = computeCID(base64Content2) 680 + 681 + expect(cid1).not.toBe(cid2) 682 + }) 683 + 684 + test('should handle empty content', () => { 685 + const emptyContent = Buffer.from('') 686 + const gzipped = compressFile(emptyContent) 687 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 688 + 689 + const cid = computeCID(base64Content) 690 + 691 + expect(cid).toMatch(/^bafkrei[a-z0-9]+$/) 692 + }) 693 + 694 + test('should compute same CID as PDS for base64-encoded content', () => { 695 + // Test that binary encoding produces correct bytes for CID calculation 696 + const testContent = Buffer.from('<!DOCTYPE html><html><body>Hello</body></html>') 697 + const gzipped = compressFile(testContent) 698 + const base64Content = Buffer.from(gzipped.toString('base64'), 'binary') 699 + 700 + // Compute CID twice to ensure consistency 701 + const cid1 = computeCID(base64Content) 702 + const cid2 = computeCID(base64Content) 703 + 704 + expect(cid1).toBe(cid2) 705 + expect(cid1).toMatch(/^bafkrei/) 706 + }) 707 + 708 + test('should use binary encoding for base64 strings', () => { 709 + // This test verifies we're using the correct encoding method 710 + // For base64 strings, 'binary' encoding ensures each character becomes exactly one byte 711 + const content = Buffer.from('Test content') 712 + const gzipped = compressFile(content) 713 + const base64String = gzipped.toString('base64') 714 + 715 + // Using binary encoding (what we use in production) 716 + const base64Content = Buffer.from(base64String, 'binary') 717 + 718 + // Verify the length matches the base64 string length 719 + expect(base64Content.length).toBe(base64String.length) 720 + 721 + // Verify CID is computed correctly 722 + const cid = computeCID(base64Content) 723 + expect(cid).toMatch(/^bafkrei/) 724 + }) 725 + }) 726 + 727 + describe('extractBlobMap', () => { 728 + test('should extract blob map from flat directory structure', () => { 729 + const mockCid = CID.parse(TEST_CID_STRING) 730 + const mockBlob = new BlobRef(mockCid, 'text/html', 100) 731 + 732 + const directory: Directory = { 733 + $type: 'place.wisp.fs#directory', 734 + type: 'directory', 735 + entries: [ 736 + { 737 + name: 'index.html', 738 + node: { 739 + $type: 'place.wisp.fs#file', 740 + type: 'file', 741 + blob: mockBlob, 742 + }, 743 + }, 744 + ], 745 + } 746 + 747 + const blobMap = extractBlobMap(directory) 748 + 749 + expect(blobMap.size).toBe(1) 750 + expect(blobMap.has('index.html')).toBe(true) 751 + 752 + const entry = blobMap.get('index.html') 753 + expect(entry?.cid).toBe(TEST_CID_STRING) 754 + expect(entry?.blobRef).toBe(mockBlob) 755 + }) 756 + 757 + test('should extract blob map from nested directory structure', () => { 758 + const mockCid1 = CID.parse(TEST_CID_STRING) 759 + const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 760 + 761 + const mockBlob1 = new BlobRef(mockCid1, 'text/html', 100) 762 + const mockBlob2 = new BlobRef(mockCid2, 'text/css', 50) 763 + 764 + const directory: Directory = { 765 + $type: 'place.wisp.fs#directory', 766 + type: 'directory', 767 + entries: [ 768 + { 769 + name: 'index.html', 770 + node: { 771 + $type: 'place.wisp.fs#file', 772 + type: 'file', 773 + blob: mockBlob1, 774 + }, 775 + }, 776 + { 777 + name: 'assets', 778 + node: { 779 + $type: 'place.wisp.fs#directory', 780 + type: 'directory', 781 + entries: [ 782 + { 783 + name: 'styles.css', 784 + node: { 785 + $type: 'place.wisp.fs#file', 786 + type: 'file', 787 + blob: mockBlob2, 788 + }, 789 + }, 790 + ], 791 + }, 792 + }, 793 + ], 794 + } 795 + 796 + const blobMap = extractBlobMap(directory) 797 + 798 + expect(blobMap.size).toBe(2) 799 + expect(blobMap.has('index.html')).toBe(true) 800 + expect(blobMap.has('assets/styles.css')).toBe(true) 801 + 802 + expect(blobMap.get('index.html')?.cid).toBe(TEST_CID_STRING) 803 + expect(blobMap.get('assets/styles.css')?.cid).toBe('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 804 + }) 805 + 806 + test('should handle deeply nested directory structures', () => { 807 + const mockCid = CID.parse(TEST_CID_STRING) 808 + const mockBlob = new BlobRef(mockCid, 'text/javascript', 200) 809 + 810 + const directory: Directory = { 811 + $type: 'place.wisp.fs#directory', 812 + type: 'directory', 813 + entries: [ 814 + { 815 + name: 'src', 816 + node: { 817 + $type: 'place.wisp.fs#directory', 818 + type: 'directory', 819 + entries: [ 820 + { 821 + name: 'lib', 822 + node: { 823 + $type: 'place.wisp.fs#directory', 824 + type: 'directory', 825 + entries: [ 826 + { 827 + name: 'utils.js', 828 + node: { 829 + $type: 'place.wisp.fs#file', 830 + type: 'file', 831 + blob: mockBlob, 832 + }, 833 + }, 834 + ], 835 + }, 836 + }, 837 + ], 838 + }, 839 + }, 840 + ], 841 + } 842 + 843 + const blobMap = extractBlobMap(directory) 844 + 845 + expect(blobMap.size).toBe(1) 846 + expect(blobMap.has('src/lib/utils.js')).toBe(true) 847 + expect(blobMap.get('src/lib/utils.js')?.cid).toBe(TEST_CID_STRING) 848 + }) 849 + 850 + test('should handle empty directory', () => { 851 + const directory: Directory = { 852 + $type: 'place.wisp.fs#directory', 853 + type: 'directory', 854 + entries: [], 855 + } 856 + 857 + const blobMap = extractBlobMap(directory) 858 + 859 + expect(blobMap.size).toBe(0) 860 + }) 861 + 862 + test('should correctly extract CID from BlobRef instances (not plain objects)', () => { 863 + // This test verifies the fix: AT Protocol SDK returns BlobRef instances, 864 + // not plain objects with $type and $link properties 865 + const mockCid = CID.parse(TEST_CID_STRING) 866 + const mockBlob = new BlobRef(mockCid, 'application/octet-stream', 500) 867 + 868 + const directory: Directory = { 869 + $type: 'place.wisp.fs#directory', 870 + type: 'directory', 871 + entries: [ 872 + { 873 + name: 'test.bin', 874 + node: { 875 + $type: 'place.wisp.fs#file', 876 + type: 'file', 877 + blob: mockBlob, 878 + }, 879 + }, 880 + ], 881 + } 882 + 883 + const blobMap = extractBlobMap(directory) 884 + 885 + // The fix: we call .toString() on the CID instance instead of accessing $link 886 + expect(blobMap.get('test.bin')?.cid).toBe(TEST_CID_STRING) 887 + expect(blobMap.get('test.bin')?.blobRef.ref.toString()).toBe(TEST_CID_STRING) 888 + }) 889 + 890 + test('should handle multiple files in same directory', () => { 891 + const mockCid1 = CID.parse(TEST_CID_STRING) 892 + const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 893 + const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq') 894 + 895 + const mockBlob1 = new BlobRef(mockCid1, 'image/png', 1000) 896 + const mockBlob2 = new BlobRef(mockCid2, 'image/png', 2000) 897 + const mockBlob3 = new BlobRef(mockCid3, 'image/png', 3000) 898 + 899 + const directory: Directory = { 900 + $type: 'place.wisp.fs#directory', 901 + type: 'directory', 902 + entries: [ 903 + { 904 + name: 'images', 905 + node: { 906 + $type: 'place.wisp.fs#directory', 907 + type: 'directory', 908 + entries: [ 909 + { 910 + name: 'logo.png', 911 + node: { 912 + $type: 'place.wisp.fs#file', 913 + type: 'file', 914 + blob: mockBlob1, 915 + }, 916 + }, 917 + { 918 + name: 'banner.png', 919 + node: { 920 + $type: 'place.wisp.fs#file', 921 + type: 'file', 922 + blob: mockBlob2, 923 + }, 924 + }, 925 + { 926 + name: 'icon.png', 927 + node: { 928 + $type: 'place.wisp.fs#file', 929 + type: 'file', 930 + blob: mockBlob3, 931 + }, 932 + }, 933 + ], 934 + }, 935 + }, 936 + ], 937 + } 938 + 939 + const blobMap = extractBlobMap(directory) 940 + 941 + expect(blobMap.size).toBe(3) 942 + expect(blobMap.has('images/logo.png')).toBe(true) 943 + expect(blobMap.has('images/banner.png')).toBe(true) 944 + expect(blobMap.has('images/icon.png')).toBe(true) 945 + }) 946 + 947 + test('should handle mixed directory and file structure', () => { 948 + const mockCid1 = CID.parse(TEST_CID_STRING) 949 + const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi') 950 + const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq') 951 + 952 + const directory: Directory = { 953 + $type: 'place.wisp.fs#directory', 954 + type: 'directory', 955 + entries: [ 956 + { 957 + name: 'index.html', 958 + node: { 959 + $type: 'place.wisp.fs#file', 960 + type: 'file', 961 + blob: new BlobRef(mockCid1, 'text/html', 100), 962 + }, 963 + }, 964 + { 965 + name: 'assets', 966 + node: { 967 + $type: 'place.wisp.fs#directory', 968 + type: 'directory', 969 + entries: [ 970 + { 971 + name: 'styles.css', 972 + node: { 973 + $type: 'place.wisp.fs#file', 974 + type: 'file', 975 + blob: new BlobRef(mockCid2, 'text/css', 50), 976 + }, 977 + }, 978 + ], 979 + }, 980 + }, 981 + { 982 + name: 'README.md', 983 + node: { 984 + $type: 'place.wisp.fs#file', 985 + type: 'file', 986 + blob: new BlobRef(mockCid3, 'text/markdown', 200), 987 + }, 988 + }, 989 + ], 990 + } 991 + 992 + const blobMap = extractBlobMap(directory) 993 + 994 + expect(blobMap.size).toBe(3) 995 + expect(blobMap.has('index.html')).toBe(true) 996 + expect(blobMap.has('assets/styles.css')).toBe(true) 997 + expect(blobMap.has('README.md')).toBe(true) 998 + }) 999 + })
+63 -2
src/lib/wisp-utils.ts
··· 2 2 import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs"; 3 3 import { validateRecord } from "../lexicons/types/place/wisp/fs"; 4 4 import { gzipSync } from 'zlib'; 5 + import { CID } from 'multiformats/cid'; 6 + import { sha256 } from 'multiformats/hashes/sha2'; 7 + import * as raw from 'multiformats/codecs/raw'; 8 + import { createHash } from 'crypto'; 9 + import * as mf from 'multiformats'; 5 10 6 11 export interface UploadedFile { 7 12 name: string; ··· 48 53 } 49 54 50 55 /** 51 - * Compress a file using gzip 56 + * Compress a file using gzip with deterministic output 52 57 */ 53 58 export function compressFile(content: Buffer): Buffer { 54 - return gzipSync(content, { level: 9 }); 59 + return gzipSync(content, { 60 + level: 9 61 + }); 55 62 } 56 63 57 64 /** ··· 65 72 const directoryMap = new Map<string, UploadedFile[]>(); 66 73 67 74 for (const file of files) { 75 + // Skip undefined/null files (defensive) 76 + if (!file || !file.name) { 77 + console.error('Skipping undefined or invalid file in processUploadedFiles'); 78 + continue; 79 + } 80 + 68 81 // Remove any base folder name from the path 69 82 const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 70 83 const parts = normalizedPath.split('/'); ··· 239 252 240 253 return result; 241 254 } 255 + 256 + /** 257 + * Compute CID (Content Identifier) for blob content 258 + * Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256 259 + * Based on @atproto/common/src/ipld.ts sha256RawToCid implementation 260 + */ 261 + export function computeCID(content: Buffer): string { 262 + // Use node crypto to compute sha256 hash (same as AT Protocol) 263 + const hash = createHash('sha256').update(content).digest(); 264 + // Create digest object from hash bytes 265 + const digest = mf.digest.create(sha256.code, hash); 266 + // Create CIDv1 with raw codec 267 + const cid = CID.createV1(raw.code, digest); 268 + return cid.toString(); 269 + } 270 + 271 + /** 272 + * Extract blob information from a directory tree 273 + * Returns a map of file paths to their blob refs and CIDs 274 + */ 275 + export function extractBlobMap( 276 + directory: Directory, 277 + currentPath: string = '' 278 + ): Map<string, { blobRef: BlobRef; cid: string }> { 279 + const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>(); 280 + 281 + for (const entry of directory.entries) { 282 + const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 283 + 284 + if ('type' in entry.node && entry.node.type === 'file') { 285 + const fileNode = entry.node as File; 286 + // AT Protocol SDK returns BlobRef class instances, not plain objects 287 + // The ref is a CID instance that can be converted to string 288 + if (fileNode.blob && fileNode.blob.ref) { 289 + const cidString = fileNode.blob.ref.toString(); 290 + blobMap.set(fullPath, { 291 + blobRef: fileNode.blob, 292 + cid: cidString 293 + }); 294 + } 295 + } else if ('type' in entry.node && entry.node.type === 'directory') { 296 + const subMap = extractBlobMap(entry.node as Directory, fullPath); 297 + subMap.forEach((value, key) => blobMap.set(key, value)); 298 + } 299 + } 300 + 301 + return blobMap; 302 + }
+106 -9
src/routes/admin.ts
··· 4 4 import { logCollector, errorTracker, metricsCollector } from '../lib/observability' 5 5 import { db } from '../lib/db' 6 6 7 - export const adminRoutes = () => 7 + export const adminRoutes = (cookieSecret: string) => 8 8 new Elysia({ prefix: '/api/admin' }) 9 9 // Login 10 10 .post( ··· 35 35 body: t.Object({ 36 36 username: t.String(), 37 37 password: t.String() 38 + }), 39 + cookie: t.Cookie({ 40 + admin_session: t.Optional(t.String()) 41 + }, { 42 + secrets: cookieSecret, 43 + sign: ['admin_session'] 38 44 }) 39 45 } 40 46 ) ··· 47 53 } 48 54 cookie.admin_session.remove() 49 55 return { success: true } 56 + }, { 57 + cookie: t.Cookie({ 58 + admin_session: t.Optional(t.String()) 59 + }, { 60 + secrets: cookieSecret, 61 + sign: ['admin_session'] 62 + }) 50 63 }) 51 64 52 65 // Check auth status ··· 65 78 authenticated: true, 66 79 username: session.username 67 80 } 81 + }, { 82 + cookie: t.Cookie({ 83 + admin_session: t.Optional(t.String()) 84 + }, { 85 + secrets: cookieSecret, 86 + sign: ['admin_session'] 87 + }) 68 88 }) 69 89 70 90 // Get logs (protected) ··· 86 106 // Get logs from hosting service 87 107 let hostingLogs: any[] = [] 88 108 try { 89 - const hostingPort = process.env.HOSTING_PORT || '3001' 109 + const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}` 90 110 const params = new URLSearchParams() 91 111 if (query.level) params.append('level', query.level as string) 92 112 if (query.service) params.append('service', query.service as string) ··· 94 114 if (query.eventType) params.append('eventType', query.eventType as string) 95 115 params.append('limit', String(filter.limit || 100)) 96 116 97 - const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/logs?${params}`) 117 + const response = await fetch(`${hostingServiceUrl}/__internal__/observability/logs?${params}`) 98 118 if (response.ok) { 99 119 const data = await response.json() 100 120 hostingLogs = data.logs ··· 109 129 ) 110 130 111 131 return { logs: allLogs.slice(0, filter.limit || 100) } 132 + }, { 133 + cookie: t.Cookie({ 134 + admin_session: t.Optional(t.String()) 135 + }, { 136 + secrets: cookieSecret, 137 + sign: ['admin_session'] 138 + }) 112 139 }) 113 140 114 141 // Get errors (protected) ··· 127 154 // Get errors from hosting service 128 155 let hostingErrors: any[] = [] 129 156 try { 130 - const hostingPort = process.env.HOSTING_PORT || '3001' 157 + const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}` 131 158 const params = new URLSearchParams() 132 159 if (query.service) params.append('service', query.service as string) 133 160 params.append('limit', String(filter.limit || 100)) 134 161 135 - const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/errors?${params}`) 162 + const response = await fetch(`${hostingServiceUrl}/__internal__/observability/errors?${params}`) 136 163 if (response.ok) { 137 164 const data = await response.json() 138 165 hostingErrors = data.errors ··· 147 174 ) 148 175 149 176 return { errors: allErrors.slice(0, filter.limit || 100) } 177 + }, { 178 + cookie: t.Cookie({ 179 + admin_session: t.Optional(t.String()) 180 + }, { 181 + secrets: cookieSecret, 182 + sign: ['admin_session'] 183 + }) 150 184 }) 151 185 152 186 // Get metrics (protected) ··· 173 207 } 174 208 175 209 try { 176 - const hostingPort = process.env.HOSTING_PORT || '3001' 177 - const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/metrics?timeWindow=${timeWindow}`) 210 + const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}` 211 + const response = await fetch(`${hostingServiceUrl}/__internal__/observability/metrics?timeWindow=${timeWindow}`) 178 212 if (response.ok) { 179 213 const data = await response.json() 180 214 hostingServiceStats = data.stats ··· 189 223 hostingService: hostingServiceStats, 190 224 timeWindow 191 225 } 226 + }, { 227 + cookie: t.Cookie({ 228 + admin_session: t.Optional(t.String()) 229 + }, { 230 + secrets: cookieSecret, 231 + sign: ['admin_session'] 232 + }) 192 233 }) 193 234 194 235 // Get database stats (protected) ··· 204 245 205 246 // Get recent sites (including those without domains) 206 247 const recentSites = await db` 207 - SELECT 248 + SELECT 208 249 s.did, 209 250 s.rkey, 210 251 s.display_name, ··· 235 276 message: error instanceof Error ? error.message : String(error) 236 277 } 237 278 } 279 + }, { 280 + cookie: t.Cookie({ 281 + admin_session: t.Optional(t.String()) 282 + }, { 283 + secrets: cookieSecret, 284 + sign: ['admin_session'] 285 + }) 286 + }) 287 + 288 + // Get cache stats (protected) 289 + .get('/cache', async ({ cookie, set }) => { 290 + const check = requireAdmin({ cookie, set }) 291 + if (check) return check 292 + 293 + try { 294 + const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}` 295 + const response = await fetch(`${hostingServiceUrl}/__internal__/observability/cache`) 296 + 297 + if (response.ok) { 298 + const data = await response.json() 299 + return data 300 + } else { 301 + set.status = 503 302 + return { 303 + error: 'Failed to fetch cache stats from hosting service', 304 + message: 'Hosting service unavailable' 305 + } 306 + } 307 + } catch (error) { 308 + set.status = 500 309 + return { 310 + error: 'Failed to fetch cache stats', 311 + message: error instanceof Error ? error.message : String(error) 312 + } 313 + } 314 + }, { 315 + cookie: t.Cookie({ 316 + admin_session: t.Optional(t.String()) 317 + }, { 318 + secrets: cookieSecret, 319 + sign: ['admin_session'] 320 + }) 238 321 }) 239 322 240 323 // Get sites listing (protected) ··· 247 330 248 331 try { 249 332 const sites = await db` 250 - SELECT 333 + SELECT 251 334 s.did, 252 335 s.rkey, 253 336 s.display_name, ··· 282 365 message: error instanceof Error ? error.message : String(error) 283 366 } 284 367 } 368 + }, { 369 + cookie: t.Cookie({ 370 + admin_session: t.Optional(t.String()) 371 + }, { 372 + secrets: cookieSecret, 373 + sign: ['admin_session'] 374 + }) 285 375 }) 286 376 287 377 // Get system health (protected) ··· 301 391 }, 302 392 timestamp: new Date().toISOString() 303 393 } 394 + }, { 395 + cookie: t.Cookie({ 396 + admin_session: t.Optional(t.String()) 397 + }, { 398 + secrets: cookieSecret, 399 + sign: ['admin_session'] 400 + }) 304 401 }) 305 402
+20 -6
src/routes/auth.ts
··· 1 - import { Elysia } from 'elysia' 1 + import { Elysia, t } from 'elysia' 2 2 import { NodeOAuthClient } from '@atproto/oauth-client-node' 3 - import { getSitesByDid, getDomainByDid } from '../lib/db' 3 + import { getSitesByDid, getDomainByDid, getCookieSecret } from '../lib/db' 4 4 import { syncSitesFromPDS } from '../lib/sync-sites' 5 5 import { authenticateRequest } from '../lib/wisp-auth' 6 6 import { logger } from '../lib/observability' 7 7 8 - export const authRoutes = (client: NodeOAuthClient) => new Elysia() 8 + export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({ 9 + cookie: { 10 + secrets: cookieSecret, 11 + sign: ['did'] 12 + } 13 + }) 9 14 .post('/api/auth/signin', async (c) => { 10 15 let handle = 'unknown' 11 16 try { ··· 32 37 33 38 if (!session) { 34 39 logger.error('[Auth] OAuth callback failed: no session returned') 40 + c.cookie.did.remove() 35 41 return c.redirect('/?error=auth_failed') 36 42 } 37 43 38 44 const cookieSession = c.cookie 39 - cookieSession.did.value = session.did 45 + cookieSession.did.set({ 46 + value: session.did, 47 + httpOnly: true, 48 + secure: process.env.NODE_ENV === 'production', 49 + sameSite: 'lax', 50 + maxAge: 30 * 24 * 60 * 60 // 30 days 51 + }) 40 52 41 53 // Sync sites from PDS to database cache 42 54 logger.debug('[Auth] Syncing sites from PDS for', session.did) ··· 64 76 } catch (err) { 65 77 // This catches state validation failures and other OAuth errors 66 78 logger.error('[Auth] OAuth callback error', err) 79 + c.cookie.did.remove() 67 80 return c.redirect('/?error=auth_failed') 68 81 } 69 82 }) ··· 73 86 const did = cookieSession.did?.value 74 87 75 88 // Clear the session cookie 76 - cookieSession.did.value = '' 77 - cookieSession.did.maxAge = 0 89 + cookieSession.did.remove() 78 90 79 91 // If we have a DID, try to revoke the OAuth session 80 92 if (did && typeof did === 'string') { ··· 98 110 const auth = await authenticateRequest(client, c.cookie) 99 111 100 112 if (!auth) { 113 + c.cookie.did.remove() 101 114 return { authenticated: false } 102 115 } 103 116 ··· 107 120 } 108 121 } catch (err) { 109 122 logger.error('[Auth] Status check error', err) 123 + c.cookie.did.remove() 110 124 return { authenticated: false } 111 125 } 112 126 })
+65 -14
src/routes/domain.ts
··· 10 10 isValidHandle, 11 11 toDomain, 12 12 updateDomain, 13 + countWispDomains, 14 + deleteWispDomain, 13 15 getCustomDomainInfo, 14 16 getCustomDomainById, 15 17 claimCustomDomain, ··· 22 24 import { verifyCustomDomain } from '../lib/dns-verify' 23 25 import { logger } from '../lib/logger' 24 26 25 - export const domainRoutes = (client: NodeOAuthClient) => 26 - new Elysia({ prefix: '/api/domain' }) 27 + export const domainRoutes = (client: NodeOAuthClient, cookieSecret: string) => 28 + new Elysia({ 29 + prefix: '/api/domain', 30 + cookie: { 31 + secrets: cookieSecret, 32 + sign: ['did'] 33 + } 34 + }) 27 35 // Public endpoints (no auth required) 28 36 .get('/check', async ({ query }) => { 29 37 try { ··· 84 92 try { 85 93 const { handle } = body as { handle?: string }; 86 94 const normalizedHandle = (handle || "").trim().toLowerCase(); 87 - 95 + 88 96 if (!isValidHandle(normalizedHandle)) { 89 97 throw new Error("Invalid handle"); 90 98 } 91 99 92 - // ensure user hasn't already claimed 93 - const existing = await getDomainByDid(auth.did); 94 - if (existing) { 95 - throw new Error("Already claimed"); 96 - } 97 - 100 + // Check if user already has 3 domains (handled in claimDomain) 98 101 // claim in DB 99 102 let domain: string; 100 103 try { 101 104 domain = await claimDomain(auth.did, normalizedHandle); 102 105 } catch (err) { 103 - throw new Error("Handle taken"); 106 + const message = err instanceof Error ? err.message : 'Unknown error'; 107 + if (message === 'domain_limit_reached') { 108 + throw new Error("Domain limit reached: You can only claim up to 3 wisp.place domains"); 109 + } 110 + throw new Error("Handle taken or error claiming domain"); 104 111 } 105 112 106 - // write place.wisp.domain record rkey = self 113 + // write place.wisp.domain record with unique rkey 107 114 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 115 + const rkey = normalizedHandle; // Use handle as rkey for uniqueness 108 116 await agent.com.atproto.repo.putRecord({ 109 117 repo: auth.did, 110 118 collection: "place.wisp.domain", 111 - rkey: "self", 119 + rkey, 112 120 record: { 113 121 $type: "place.wisp.domain", 114 122 domain, ··· 309 317 }) 310 318 .post('/wisp/map-site', async ({ body, auth }) => { 311 319 try { 312 - const { siteRkey } = body as { siteRkey: string | null }; 320 + const { domain, siteRkey } = body as { domain: string; siteRkey: string | null }; 321 + 322 + if (!domain) { 323 + throw new Error('Domain parameter required'); 324 + } 313 325 314 326 // Update wisp.place domain to point to this site 315 - await updateWispDomainSite(auth.did, siteRkey); 327 + await updateWispDomainSite(domain, siteRkey); 316 328 317 329 return { success: true }; 318 330 } catch (err) { 319 331 logger.error('[Domain] Wisp domain map error', err); 320 332 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 333 + } 334 + }) 335 + .delete('/wisp/:domain', async ({ params, auth }) => { 336 + try { 337 + const { domain } = params; 338 + 339 + // Verify domain belongs to user 340 + const domainLower = domain.toLowerCase().trim(); 341 + const info = await isDomainRegistered(domainLower); 342 + 343 + if (!info.registered || info.type !== 'wisp') { 344 + throw new Error('Domain not found'); 345 + } 346 + 347 + if (info.did !== auth.did) { 348 + throw new Error('Unauthorized: You do not own this domain'); 349 + } 350 + 351 + // Delete from database 352 + await deleteWispDomain(domainLower); 353 + 354 + // Delete from PDS 355 + const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 356 + const handle = domainLower.replace(`.${process.env.BASE_DOMAIN || 'wisp.place'}`, ''); 357 + try { 358 + await agent.com.atproto.repo.deleteRecord({ 359 + repo: auth.did, 360 + collection: "place.wisp.domain", 361 + rkey: handle, 362 + }); 363 + } catch (err) { 364 + // Record might not exist in PDS, continue anyway 365 + logger.warn('[Domain] Could not delete wisp domain from PDS', err); 366 + } 367 + 368 + return { success: true }; 369 + } catch (err) { 370 + logger.error('[Domain] Wisp domain delete error', err); 371 + throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 321 372 } 322 373 }) 323 374 .post('/custom/:id/map-site', async ({ params, body, auth }) => {
+8 -2
src/routes/site.ts
··· 5 5 import { deleteSite } from '../lib/db' 6 6 import { logger } from '../lib/logger' 7 7 8 - export const siteRoutes = (client: NodeOAuthClient) => 9 - new Elysia({ prefix: '/api/site' }) 8 + export const siteRoutes = (client: NodeOAuthClient, cookieSecret: string) => 9 + new Elysia({ 10 + prefix: '/api/site', 11 + cookie: { 12 + secrets: cookieSecret, 13 + sign: ['did'] 14 + } 15 + }) 10 16 .derive(async ({ cookie }) => { 11 17 const auth = await requireAuth(client, cookie) 12 18 return { auth }
+30 -10
src/routes/user.ts
··· 1 - import { Elysia } from 'elysia' 1 + import { Elysia, t } from 'elysia' 2 2 import { requireAuth } from '../lib/wisp-auth' 3 3 import { NodeOAuthClient } from '@atproto/oauth-client-node' 4 4 import { Agent } from '@atproto/api' 5 - import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db' 5 + import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite, getAllWispDomains } from '../lib/db' 6 6 import { syncSitesFromPDS } from '../lib/sync-sites' 7 7 import { logger } from '../lib/logger' 8 8 9 - export const userRoutes = (client: NodeOAuthClient) => 10 - new Elysia({ prefix: '/api/user' }) 9 + export const userRoutes = (client: NodeOAuthClient, cookieSecret: string) => 10 + new Elysia({ 11 + prefix: '/api/user', 12 + cookie: { 13 + secrets: cookieSecret, 14 + sign: ['did'] 15 + } 16 + }) 11 17 .derive(async ({ cookie }) => { 12 18 const auth = await requireAuth(client, cookie) 13 19 return { auth } ··· 65 71 }) 66 72 .get('/domains', async ({ auth }) => { 67 73 try { 68 - // Get wisp.place subdomain with mapping 69 - const wispDomainInfo = await getWispDomainInfo(auth.did) 74 + // Get all wisp.place subdomains with mappings (up to 3) 75 + const wispDomains = await getAllWispDomains(auth.did) 70 76 71 77 // Get custom domains 72 78 const customDomains = await getCustomDomainsByDid(auth.did) 73 79 74 80 return { 75 - wispDomain: wispDomainInfo ? { 76 - domain: wispDomainInfo.domain, 77 - rkey: wispDomainInfo.rkey || null 78 - } : null, 81 + wispDomains: wispDomains.map(d => ({ 82 + domain: d.domain, 83 + rkey: d.rkey || null 84 + })), 79 85 customDomains 80 86 } 81 87 } catch (err) { ··· 98 104 throw new Error('Failed to sync sites') 99 105 } 100 106 }) 107 + .get('/site/:rkey/domains', async ({ auth, params }) => { 108 + try { 109 + const { rkey } = params 110 + const domains = await getDomainsBySite(auth.did, rkey) 111 + 112 + return { 113 + rkey, 114 + domains 115 + } 116 + } catch (err) { 117 + logger.error('[User] Site domains error', err) 118 + throw new Error('Failed to get domains for site') 119 + } 120 + })
+138 -12
src/routes/wisp.ts
··· 9 9 createManifest, 10 10 updateFileBlobs, 11 11 shouldCompressFile, 12 - compressFile 12 + compressFile, 13 + computeCID, 14 + extractBlobMap 13 15 } from '../lib/wisp-utils' 14 16 import { upsertSite } from '../lib/db' 15 17 import { logger } from '../lib/observability' ··· 35 37 return true; 36 38 } 37 39 38 - export const wispRoutes = (client: NodeOAuthClient) => 39 - new Elysia({ prefix: '/wisp' }) 40 + export const wispRoutes = (client: NodeOAuthClient, cookieSecret: string) => 41 + new Elysia({ 42 + prefix: '/wisp', 43 + cookie: { 44 + secrets: cookieSecret, 45 + sign: ['did'] 46 + } 47 + }) 40 48 .derive(async ({ cookie }) => { 41 49 const auth = await requireAuth(client, cookie) 42 50 return { auth } ··· 48 56 siteName: string; 49 57 files: File | File[] 50 58 }; 59 + 60 + console.log('=== UPLOAD FILES START ==='); 61 + console.log('Site name:', siteName); 62 + console.log('Files received:', Array.isArray(files) ? files.length : 'single file'); 51 63 52 64 try { 53 65 if (!siteName) { ··· 106 118 107 119 // Create agent with OAuth session 108 120 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 121 + console.log('Agent created for DID:', auth.did); 122 + 123 + // Try to fetch existing record to enable incremental updates 124 + let existingBlobMap = new Map<string, { blobRef: any; cid: string }>(); 125 + console.log('Attempting to fetch existing record...'); 126 + try { 127 + const rkey = siteName; 128 + const existingRecord = await agent.com.atproto.repo.getRecord({ 129 + repo: auth.did, 130 + collection: 'place.wisp.fs', 131 + rkey: rkey 132 + }); 133 + console.log('Existing record found!'); 134 + 135 + if (existingRecord.data.value && typeof existingRecord.data.value === 'object' && 'root' in existingRecord.data.value) { 136 + const manifest = existingRecord.data.value as any; 137 + existingBlobMap = extractBlobMap(manifest.root); 138 + console.log(`Found existing manifest with ${existingBlobMap.size} files for incremental update`); 139 + logger.info(`Found existing manifest with ${existingBlobMap.size} files for incremental update`); 140 + } 141 + } catch (error: any) { 142 + console.log('No existing record found or error:', error?.message || error); 143 + // Record doesn't exist yet, this is a new site 144 + if (error?.status !== 400 && error?.error !== 'RecordNotFound') { 145 + logger.warn('Failed to fetch existing record, proceeding with full upload', error); 146 + } 147 + } 109 148 110 149 // Convert File objects to UploadedFile format 111 150 // Elysia gives us File objects directly, handle both single file and array ··· 113 152 const uploadedFiles: UploadedFile[] = []; 114 153 const skippedFiles: Array<{ name: string; reason: string }> = []; 115 154 116 - 155 + console.log('Processing files, count:', fileArray.length); 117 156 118 157 for (let i = 0; i < fileArray.length; i++) { 119 158 const file = fileArray[i]; 159 + console.log(`Processing file ${i + 1}/${fileArray.length}:`, file.name, file.size, 'bytes'); 120 160 121 161 // Skip files that are too large (limit to 100MB per file) 122 162 const maxSize = MAX_FILE_SIZE; // 100MB ··· 135 175 // Compress and base64 encode ALL files 136 176 const compressedContent = compressFile(originalContent); 137 177 // Base64 encode the gzipped content to prevent PDS content sniffing 138 - const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8'); 178 + // Convert base64 string to bytes using binary encoding (each char becomes exactly one byte) 179 + // This is what PDS receives and computes CID on 180 + const base64Content = Buffer.from(compressedContent.toString('base64'), 'binary'); 139 181 const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1); 182 + console.log(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 140 183 logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 141 184 142 185 uploadedFiles.push({ 143 186 name: file.name, 144 - content: base64Content, 187 + content: base64Content, // This is the gzipped+base64 content that will be uploaded and CID-computed 145 188 mimeType: originalMimeType, 146 189 size: base64Content.length, 147 190 compressed: true, ··· 206 249 } 207 250 208 251 // Process files into directory structure 209 - const { directory, fileCount } = processUploadedFiles(uploadedFiles); 252 + console.log('Processing uploaded files into directory structure...'); 253 + console.log('uploadedFiles array length:', uploadedFiles.length); 254 + console.log('uploadedFiles contents:', uploadedFiles.map((f, i) => `${i}: ${f?.name || 'UNDEFINED'}`)); 210 255 211 - // Upload files as blobs in parallel 256 + // Filter out any undefined/null/invalid entries (defensive) 257 + const validUploadedFiles = uploadedFiles.filter((f, i) => { 258 + if (!f) { 259 + console.error(`Filtering out undefined/null file at index ${i}`); 260 + return false; 261 + } 262 + if (!f.name) { 263 + console.error(`Filtering out file with no name at index ${i}:`, f); 264 + return false; 265 + } 266 + if (!f.content) { 267 + console.error(`Filtering out file with no content at index ${i}:`, f.name); 268 + return false; 269 + } 270 + return true; 271 + }); 272 + if (validUploadedFiles.length !== uploadedFiles.length) { 273 + console.warn(`Filtered out ${uploadedFiles.length - validUploadedFiles.length} invalid files`); 274 + } 275 + console.log('validUploadedFiles length:', validUploadedFiles.length); 276 + 277 + const { directory, fileCount } = processUploadedFiles(validUploadedFiles); 278 + console.log('Directory structure created, file count:', fileCount); 279 + 280 + // Upload files as blobs in parallel (or reuse existing blobs with matching CIDs) 281 + console.log('Starting blob upload/reuse phase...'); 212 282 // For compressed files, we upload as octet-stream and store the original MIME type in metadata 213 283 // For text/html files, we also use octet-stream as a workaround for PDS image pipeline issues 214 - const uploadPromises = uploadedFiles.map(async (file, i) => { 284 + const uploadPromises = validUploadedFiles.map(async (file, i) => { 215 285 try { 286 + // Skip undefined files (shouldn't happen after filter, but defensive) 287 + if (!file || !file.name) { 288 + console.error(`ERROR: Undefined file at index ${i} in validUploadedFiles!`); 289 + throw new Error(`Undefined file at index ${i}`); 290 + } 291 + 292 + // Compute CID for this file to check if it already exists 293 + // Note: file.content is already gzipped+base64 encoded 294 + const fileCID = computeCID(file.content); 295 + 296 + // Normalize the file path for comparison (remove base folder prefix like "cobblemon/") 297 + const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 298 + 299 + // Check if we have an existing blob with the same CID 300 + // Try both the normalized path and the full path 301 + const existingBlob = existingBlobMap.get(normalizedPath) || existingBlobMap.get(file.name); 302 + 303 + if (existingBlob && existingBlob.cid === fileCID) { 304 + // Reuse existing blob - no need to upload 305 + logger.info(`[File Upload] Reusing existing blob for: ${file.name} (CID: ${fileCID})`); 306 + 307 + return { 308 + result: { 309 + hash: existingBlob.cid, 310 + blobRef: existingBlob.blobRef, 311 + ...(file.compressed && { 312 + encoding: 'gzip' as const, 313 + mimeType: file.originalMimeType || file.mimeType, 314 + base64: true 315 + }) 316 + }, 317 + filePath: file.name, 318 + sentMimeType: file.mimeType, 319 + returnedMimeType: existingBlob.blobRef.mimeType, 320 + reused: true 321 + }; 322 + } 323 + 324 + // File is new or changed - upload it 216 325 // If compressed, always upload as octet-stream 217 326 // Otherwise, workaround: PDS incorrectly processes text/html through image pipeline 218 327 const uploadMimeType = file.compressed || file.mimeType.startsWith('text/html') ··· 220 329 : file.mimeType; 221 330 222 331 const compressionInfo = file.compressed ? ' (gzipped)' : ''; 223 - logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`); 332 + logger.info(`[File Upload] Uploading new/changed file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo}, CID: ${fileCID})`); 224 333 225 334 const uploadResult = await agent.com.atproto.repo.uploadBlob( 226 335 file.content, ··· 244 353 }, 245 354 filePath: file.name, 246 355 sentMimeType: file.mimeType, 247 - returnedMimeType: returnedBlobRef.mimeType 356 + returnedMimeType: returnedBlobRef.mimeType, 357 + reused: false 248 358 }; 249 359 } catch (uploadError) { 250 360 logger.error('Upload failed for file', uploadError); ··· 255 365 // Wait for all uploads to complete 256 366 const uploadedBlobs = await Promise.all(uploadPromises); 257 367 368 + // Count reused vs uploaded blobs 369 + const reusedCount = uploadedBlobs.filter(b => (b as any).reused).length; 370 + const uploadedCount = uploadedBlobs.filter(b => !(b as any).reused).length; 371 + console.log(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`); 372 + logger.info(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`); 373 + 258 374 // Extract results and file paths in correct order 259 375 const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result); 260 376 const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath); 261 377 262 378 // Update directory with file blobs 379 + console.log('Updating directory with blob references...'); 263 380 const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths); 264 381 265 382 // Create manifest 383 + console.log('Creating manifest...'); 266 384 const manifest = createManifest(siteName, updatedDirectory, fileCount); 385 + console.log('Manifest created successfully'); 267 386 268 387 // Use site name as rkey 269 388 const rkey = siteName; 270 389 271 390 let record; 272 391 try { 392 + console.log('Putting record to PDS with rkey:', rkey); 273 393 record = await agent.com.atproto.repo.putRecord({ 274 394 repo: auth.did, 275 395 collection: 'place.wisp.fs', 276 396 rkey: rkey, 277 397 record: manifest 278 398 }); 399 + console.log('Record successfully created on PDS:', record.data.uri); 279 400 } catch (putRecordError: any) { 401 + console.error('FAILED to create record on PDS:', putRecordError); 280 402 logger.error('Failed to create record on PDS', putRecordError); 281 403 282 404 throw putRecordError; ··· 292 414 fileCount, 293 415 siteName, 294 416 skippedFiles, 295 - uploadedCount: uploadedFiles.length 417 + uploadedCount: validUploadedFiles.length 296 418 }; 297 419 420 + console.log('=== UPLOAD FILES COMPLETE ==='); 298 421 return result; 299 422 } catch (error) { 423 + console.error('=== UPLOAD ERROR ==='); 424 + console.error('Error details:', error); 425 + console.error('Stack trace:', error instanceof Error ? error.stack : 'N/A'); 300 426 logger.error('Upload error', error, { 301 427 message: error instanceof Error ? error.message : 'Unknown error', 302 428 name: error instanceof Error ? error.name : undefined