The codebase that powers boop.cat boop.cat

Init

scanash.com 8f4eea46

+19313
+81
.env.example
··· 1 + # Server & Connectivity 2 + PORT=8788 3 + NODE_ENV=production 4 + PUBLIC_URL=https://boop.cat 5 + # Generate a secure random string (e.g. `openssl rand -hex 32`) 6 + SESSION_SECRET= 7 + TRUST_PROXY=1 8 + COOKIE_SECURE=1 9 + 10 + # Database 11 + FSD_DATA_DIR=/fsd 12 + # Generate a 32-byte hex string (e.g. `openssl rand -hex 32`) 13 + ENV_ENCRYPTION_SECRET= 14 + 15 + # Edge Delivery (Static Sites) 16 + FSD_DELIVERY=edge 17 + FSD_EDGE_ROOT_DOMAIN=boop.cat 18 + 19 + # Cloudflare (Required for Edge) 20 + CF_API_TOKEN= 21 + CF_ACCOUNT_ID= 22 + CF_ZONE_ID= 23 + CF_KV_NAMESPACE_ID= 24 + 25 + # Backblaze B2 (Storage) 26 + B2_KEY_ID= 27 + B2_APP_KEY= 28 + B2_BUCKET_ID= 29 + # Match this with your edge/wrangler.toml (currently 'scan-blue-sites') 30 + B2_BUCKET_NAME=scan-blue-sites 31 + 32 + # Email (SMTP) 33 + MAIL_FROM=hello@boop.cat 34 + SMTP_HOST= 35 + SMTP_PORT=465 36 + SMTP_USER=hello@boop.cat 37 + SMTP_PASS= 38 + SMTP_FROM_NAME="boop.cat" 39 + 40 + # Auth: GitHub (Optional) 41 + GITHUB_CLIENT_ID= 42 + GITHUB_CLIENT_SECRET= 43 + GITHUB_CALLBACK_URL=https://boop.cat/auth/github/callback 44 + 45 + # GitHub App (Required for Auto-Deploy & Private Repos) 46 + # Create at https://github.com/settings/apps/new 47 + # Permissions: Contents(Read), Metadata(Read) | Events: Push 48 + GITHUB_APP_ID= 49 + GITHUB_APP_PRIVATE_KEY= 50 + GITHUB_APP_WEBHOOK_SECRET= 51 + GITHUB_APP_INSTALL_URL=https://github.com/apps/boop-host 52 + 53 + # Auth: Google (Optional) 54 + GOOGLE_CLIENT_ID= 55 + GOOGLE_CLIENT_SECRET= 56 + GOOGLE_CALLBACK_URL=https://boop.cat/auth/google/callback 57 + 58 + # Auth: ATProto / Bluesky (Optional) 59 + ATPROTO_PRIVATE_KEY_1= 60 + ATPROTO_CLIENT_ID=https://boop.cat/client-metadata.json 61 + ATPROTO_CLIENT_NAME="boop.cat" 62 + ATPROTO_REDIRECT_URI=https://boop.cat/auth/atproto/callback 63 + ATPROTO_SCOPE="atproto transition:generic account:email" 64 + ATPROTO_LOGO_URI=https://boop.cat/public/logo.svg 65 + ATPROTO_TOS_URI=https://boop.cat/tos 66 + ATPROTO_POLICY_URI=https://boop.cat/privacy 67 + 68 + # Admin API 69 + ADMIN_API_KEY= 70 + 71 + # Security / Anti-bot 72 + TURNSTILE_SITE_KEY= 73 + TURNSTILE_SECRET_KEY= 74 + 75 + # DMCA Process 76 + DISCORD_DMCA_WEBHOOK_URL= 77 + IMAP_HOST= 78 + IMAP_PORT=993 79 + IMAP_USER=dmca@boop.cat 80 + IMAP_PASSWORD= 81 + IMAP_TLS=true
+32
.gitignore
··· 1 + node_modules/ 2 + 3 + .DS_Store 4 + 5 + *.log 6 + npm-debug.log* 7 + yarn-debug.log* 8 + yarn-error.log* 9 + pnpm-debug.log* 10 + 11 + .env 12 + .env.* 13 + !.env.example 14 + 15 + dist/ 16 + client/dist/ 17 + 18 + .vite/ 19 + 20 + .fsd/ 21 + 22 + .wrangler/ 23 + .dev.vars 24 + 25 + coverage/ 26 + .env 27 + 28 + # Go 29 + /backend-go/boop-cat 30 + *.test 31 + *.out 32 + *.exe
+38
.prettierignore
··· 1 + node_modules/ 2 + 3 + .DS_Store 4 + 5 + *.log 6 + npm-debug.log* 7 + yarn-debug.log* 8 + yarn-error.log* 9 + pnpm-debug.log* 10 + 11 + .env 12 + .env.* 13 + !.env.example 14 + 15 + dist/ 16 + client/dist/ 17 + 18 + .vite/ 19 + 20 + .fsd/ 21 + 22 + .wrangler/ 23 + .dev.vars 24 + 25 + coverage/ 26 + 27 + # Go 28 + /backend-go/boop-cat 29 + *.test 30 + *.out 31 + *.exe 32 + 33 + # Lock files 34 + bun.lock 35 + package-lock.json 36 + 37 + # Keys 38 + *.pem
+8
.prettierrc
··· 1 + { 2 + "semi": true, 3 + "singleQuote": true, 4 + "tabWidth": 2, 5 + "trailingComma": "none", 6 + "printWidth": 120, 7 + "bracketSpacing": true 8 + }
+54
Dockerfile
··· 1 + FROM node:22-bookworm AS builder-fe 2 + WORKDIR /app 3 + RUN corepack enable 4 + COPY package.json package-lock.json ./ 5 + RUN npm ci 6 + COPY client ./client 7 + COPY package.json ./ 8 + RUN npm run build 9 + 10 + FROM golang:1.23-bookworm AS builder-be 11 + WORKDIR /app 12 + COPY backend-go ./ 13 + RUN go mod tidy 14 + RUN CGO_ENABLED=1 GOOS=linux go build -o boop-cat . 15 + 16 + FROM node:22-bookworm 17 + WORKDIR /app 18 + 19 + ENV BUN_INSTALL=/usr/local/bun 20 + ENV PATH=/usr/local/bun/bin:$PATH 21 + ENV DENO_INSTALL=/usr/local/deno 22 + ENV PATH=/usr/local/deno/bin:$PATH 23 + ENV NODE_ENV=production 24 + 25 + RUN apt-get update \ 26 + && apt-get install -y --no-install-recommends \ 27 + ca-certificates \ 28 + curl \ 29 + git \ 30 + bash \ 31 + unzip \ 32 + python3 \ 33 + make \ 34 + g++ \ 35 + && rm -rf /var/lib/apt/lists/* 36 + 37 + RUN corepack enable \ 38 + && corepack prepare pnpm@9.0.0 --activate \ 39 + && corepack prepare yarn@1.22.22 --activate 40 + 41 + RUN curl -fsSL https://bun.sh/install | bash \ 42 + && ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun 43 + 44 + RUN curl -fsSL https://deno.land/install.sh | sh \ 45 + && ln -sf /usr/local/deno/bin/deno /usr/local/bin/deno 46 + 47 + COPY --from=builder-fe /app/client/dist /client/dist 48 + 49 + COPY --from=builder-be /app/boop-cat /app/boop-cat 50 + 51 + ENV PORT=8788 52 + EXPOSE 8788 53 + 54 + CMD ["/app/boop-cat"]
+16
LICENSE
··· 1 + Creative Commons Attribution 4.0 International (CC BY 4.0) License 2 + 3 + Copyright (c) 2025 boop.cat 4 + 5 + You are free to: 6 + 7 + - Share — copy and redistribute the material in any medium or format 8 + - Adapt — remix, transform, and build upon the material for any purpose, even commercially 9 + 10 + Under the following terms: 11 + 12 + - **Attribution** — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. 13 + 14 + No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits. 15 + 16 + **Full license text:** https://creativecommons.org/licenses/by/4.0/
+90
README.md
··· 1 + # boop.cat codebase 2 + 3 + This is boop.cat's codebase, and it was released under the [Creative Commons Attribution 4.0 International (CC BY 4.0) License](https://creativecommons.org/licenses/by/4.0/). 4 + 5 + ## Features 6 + 7 + - **Instant Deployment**: Connect any public or private Git repository. 8 + - **Auto-Deploy**: Automatically triggers a new build on every `push` to your main branch. (for GitHub only, use API for other platforms) 9 + - **Edge Delivery**: Powered by Cloudflare Workers for global caching and low latency. 10 + - **Managed SSL**: Automatic HTTPS for every site and custom domain. 11 + - **Environment Variables**: Full support for build-time environment variables. 12 + - **Clean API**: Manage your sites and deployments programmatically. 13 + 14 + ## Tech Stack 15 + 16 + - **Backend**: Go (chi router, SQLite) 17 + - **Frontend**: React (Vite, Lucide Icons) 18 + - **Storage**: Backblaze B2 (Object storage) 19 + - **Delivery**: Cloudflare Workers (Edge Computing) & KV (Metadata) 20 + - **Database**: SQLite (Local file-based database) 21 + 22 + ## Getting Started 23 + 24 + ### 1. Prerequisites 25 + 26 + - [Go](https://go.dev/) 1.23+ 27 + - [Node.js](https://nodejs.org/) 22+ (with `pnpm` or `bun`) 28 + - [Cloudflare Account](https://dash.cloudflare.com/) (Token, Account ID, Zone ID) 29 + - [Backblaze B2 Account](https://www.backblaze.com/b2/cloud-storage.html) (Key ID, App Key, Bucket) 30 + 31 + ### 2. Installation 32 + 33 + ```bash 34 + # Clone the repository 35 + git clone https://tangled.org/scanash.com/boombox 36 + cd boombox 37 + 38 + # Install frontend dependencies 39 + npm install 40 + ``` 41 + 42 + ### 3. Configuration 43 + 44 + Copy the example environment file and fill in your credentials: 45 + 46 + ```bash 47 + cp .env.example .env 48 + ``` 49 + 50 + Key variables to configure: 51 + 52 + - `SESSION_SECRET`: Random string for sessions. 53 + - `FSD_DATA_DIR`: Path where the SQLite database will be stored. 54 + - `CF_*`: Your Cloudflare API credentials. 55 + - `B2_*`: Your Backblaze B2 storage credentials. 56 + 57 + ### 4. Running Locally 58 + 59 + **Start the Go Backend:** 60 + 61 + ```bash 62 + cd backend-go 63 + go run main.go 64 + ``` 65 + 66 + The backend serves the frontend from `client/dist`. For development, you can run the Vite dev server separately: 67 + 68 + **Start Vite Dev Server:** 69 + 70 + ```bash 71 + cd client 72 + npm run dev 73 + ``` 74 + 75 + ## Docker Deployment 76 + 77 + The project includes a multi-stage `Dockerfile` that builds both the React frontend and Go backend into a single production-ready image. 78 + 79 + ```bash 80 + docker build -t boop-cat . 81 + docker run -p 8788:8788 --env-file .env boop-cat 82 + ``` 83 + 84 + ## API Documentation 85 + 86 + The platform provides a REST API for managing sites. See the **API Documentation** page within the dashboard for details and examples. 87 + 88 + ## License 89 + 90 + This project is licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0) License. See [LICENSE](LICENSE) for details.
+112
backend-go/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "os" 5 + "strconv" 6 + "strings" 7 + ) 8 + 9 + type Config struct { 10 + Port int 11 + Env string 12 + TrustProxy bool 13 + 14 + DBPath string 15 + 16 + SessionSecret string 17 + CookieSecure bool 18 + 19 + DeliveryMode string 20 + EdgeRootDomain string 21 + 22 + B2KeyID string 23 + B2AppKey string 24 + B2BucketID string 25 + B2BucketName string 26 + 27 + CFAPIToken string 28 + CFAccountID string 29 + CFZoneID string 30 + CFKVNSRoutes string 31 + CFKVNSFBound string 32 + 33 + GitHubClientID string 34 + GitHubClientSecret string 35 + GoogleClientID string 36 + GoogleClientSecret string 37 + AtprotoPrivateKey string 38 + 39 + AdminAPIKey string 40 + 41 + RateAPIV1WindowMs int 42 + RateAPIV1Max int 43 + } 44 + 45 + func Load() *Config { 46 + return &Config{ 47 + Port: getEnvInt("PORT", 8787), 48 + Env: getEnv("NODE_ENV", "development"), 49 + TrustProxy: getEnvBool("TRUST_PROXY", false), 50 + 51 + DBPath: getEnv("FSD_DB_PATH", ""), 52 + 53 + SessionSecret: getEnv("SESSION_SECRET", ""), 54 + CookieSecure: getEnvBool("COOKIE_SECURE", false), 55 + 56 + DeliveryMode: strings.ToLower(getEnv("FSD_DELIVERY", "")), 57 + EdgeRootDomain: strings.ToLower(strings.TrimSpace(getEnv("FSD_EDGE_ROOT_DOMAIN", ""))), 58 + 59 + B2KeyID: getEnv("B2_KEY_ID", ""), 60 + B2AppKey: getEnv("B2_APP_KEY", ""), 61 + B2BucketID: getEnv("B2_BUCKET_ID", ""), 62 + B2BucketName: getEnv("B2_BUCKET_NAME", ""), 63 + 64 + CFAPIToken: getEnv("CF_API_TOKEN", ""), 65 + CFAccountID: getEnv("CF_ACCOUNT_ID", ""), 66 + CFZoneID: getEnv("CF_ZONE_ID", ""), 67 + CFKVNSRoutes: getEnv("CF_KV_NS_ROUTES", ""), 68 + CFKVNSFBound: getEnv("CF_KV_NS_FBOUND", ""), 69 + 70 + GitHubClientID: getEnv("GITHUB_CLIENT_ID", ""), 71 + GitHubClientSecret: getEnv("GITHUB_CLIENT_SECRET", ""), 72 + GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""), 73 + GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""), 74 + AtprotoPrivateKey: getEnv("ATPROTO_PRIVATE_KEY_1", ""), 75 + 76 + AdminAPIKey: getEnv("ADMIN_API_KEY", ""), 77 + 78 + RateAPIV1WindowMs: getEnvInt("RATE_API_V1_WINDOW_MS", 15*60*1000), 79 + RateAPIV1Max: getEnvInt("RATE_API_V1_MAX", 100), 80 + } 81 + } 82 + 83 + func (c *Config) IsProd() bool { 84 + return c.Env == "production" 85 + } 86 + 87 + func (c *Config) EdgeEnabled() bool { 88 + return c.DeliveryMode == "edge" && c.EdgeRootDomain != "" 89 + } 90 + 91 + func getEnv(key, fallback string) string { 92 + if v := os.Getenv(key); v != "" { 93 + return v 94 + } 95 + return fallback 96 + } 97 + 98 + func getEnvInt(key string, fallback int) int { 99 + if v := os.Getenv(key); v != "" { 100 + if i, err := strconv.Atoi(v); err == nil { 101 + return i 102 + } 103 + } 104 + return fallback 105 + } 106 + 107 + func getEnvBool(key string, fallback bool) bool { 108 + if v := os.Getenv(key); v != "" { 109 + return v == "1" || v == "true" 110 + } 111 + return fallback 112 + }
+4
backend-go/cookies.txt
··· 1 + # Netscape HTTP Cookie File 2 + # https://curl.se/docs/http-cookies.html 3 + # This file was generated by libcurl! Edit at your own risk. 4 +
+38
backend-go/db/admin.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "time" 6 + ) 7 + 8 + type BannedIP struct { 9 + IP string 10 + Reason string 11 + UserID string 12 + CreatedAt string 13 + } 14 + 15 + func BanIP(db *sql.DB, ip, userID, reason string) error { 16 + 17 + var exists string 18 + err := db.QueryRow(`SELECT ip FROM banned_ips WHERE ip = ?`, ip).Scan(&exists) 19 + if err == nil { 20 + return nil 21 + } 22 + 23 + now := time.Now().UTC().Format(time.RFC3339) 24 + _, err = db.Exec(` 25 + INSERT INTO banned_ips (ip, reason, userId, createdAt) 26 + VALUES (?, ?, ?, ?) 27 + `, ip, reason, userID, now) 28 + return err 29 + } 30 + 31 + func IsIPBanned(db *sql.DB, ip string) (bool, string) { 32 + var reason string 33 + err := db.QueryRow(`SELECT reason FROM banned_ips WHERE ip = ?`, ip).Scan(&reason) 34 + if err != nil { 35 + return false, "" 36 + } 37 + return true, reason 38 + }
+112
backend-go/db/api_keys.go
··· 1 + package db 2 + 3 + import ( 4 + "crypto/sha256" 5 + "database/sql" 6 + "encoding/hex" 7 + "time" 8 + ) 9 + 10 + type APIKey struct { 11 + ID string `json:"id"` 12 + UserID string `json:"userId"` 13 + Name string `json:"name"` 14 + KeyHash string `json:"-"` 15 + KeyPrefix string `json:"prefix"` 16 + CreatedAt string `json:"createdAt"` 17 + LastUsedAt sql.NullString `json:"lastUsedAt"` 18 + } 19 + 20 + type User struct { 21 + ID string 22 + Email string 23 + Username sql.NullString 24 + EmailVerified bool 25 + Banned bool 26 + } 27 + 28 + func ListAPIKeys(db *sql.DB, userID string) ([]APIKey, error) { 29 + rows, err := db.Query(` 30 + SELECT id, userId, name, keyHash, keyPrefix, createdAt, lastUsedAt 31 + FROM apiKeys WHERE userId = ? 32 + `, userID) 33 + if err != nil { 34 + return nil, err 35 + } 36 + defer rows.Close() 37 + 38 + var keys []APIKey 39 + for rows.Next() { 40 + var k APIKey 41 + if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &k.KeyPrefix, &k.CreatedAt, &k.LastUsedAt); err != nil { 42 + return nil, err 43 + } 44 + keys = append(keys, k) 45 + } 46 + return keys, rows.Err() 47 + } 48 + 49 + func CreateAPIKey(db *sql.DB, id, userID, name, keyHash, keyPrefix string) error { 50 + _, err := db.Exec(` 51 + INSERT INTO apiKeys (id, userId, name, keyHash, keyPrefix, createdAt, lastUsedAt) 52 + VALUES (?, ?, ?, ?, ?, ?, NULL) 53 + `, id, userID, name, keyHash, keyPrefix, time.Now().UTC().Format(time.RFC3339)) 54 + return err 55 + } 56 + 57 + func DeleteAPIKey(db *sql.DB, userID, keyID string) error { 58 + result, err := db.Exec(`DELETE FROM apiKeys WHERE id = ? AND userId = ?`, keyID, userID) 59 + if err != nil { 60 + return err 61 + } 62 + rows, _ := result.RowsAffected() 63 + if rows == 0 { 64 + return sql.ErrNoRows 65 + } 66 + return nil 67 + } 68 + 69 + func CountAPIKeys(db *sql.DB, userID string) (int, error) { 70 + var count int 71 + err := db.QueryRow(`SELECT COUNT(*) FROM apiKeys WHERE userId = ?`, userID).Scan(&count) 72 + return count, err 73 + } 74 + 75 + func ValidateAPIKey(db *sql.DB, key string) (*User, string, error) { 76 + 77 + hash := sha256.Sum256([]byte(key)) 78 + keyHash := hex.EncodeToString(hash[:]) 79 + 80 + var apiKey APIKey 81 + err := db.QueryRow(` 82 + SELECT id, userId, name, keyHash, keyPrefix, createdAt, lastUsedAt 83 + FROM apiKeys WHERE keyHash = ? 84 + `, keyHash).Scan(&apiKey.ID, &apiKey.UserID, &apiKey.Name, &apiKey.KeyHash, &apiKey.KeyPrefix, &apiKey.CreatedAt, &apiKey.LastUsedAt) 85 + if err != nil { 86 + return nil, "", err 87 + } 88 + 89 + var user User 90 + var emailVerified, banned int 91 + err = db.QueryRow(` 92 + SELECT id, email, username, emailVerified, banned 93 + FROM users WHERE id = ? 94 + `, apiKey.UserID).Scan(&user.ID, &user.Email, &user.Username, &emailVerified, &banned) 95 + if err != nil { 96 + return nil, "", err 97 + } 98 + user.EmailVerified = emailVerified != 0 99 + user.Banned = banned != 0 100 + 101 + if user.Banned { 102 + return nil, "", sql.ErrNoRows 103 + } 104 + if !user.EmailVerified { 105 + return nil, "", sql.ErrNoRows 106 + } 107 + 108 + _, _ = db.Exec(`UPDATE apiKeys SET lastUsedAt = ? WHERE id = ?`, 109 + time.Now().UTC().Format(time.RFC3339), apiKey.ID) 110 + 111 + return &user, apiKey.ID, nil 112 + }
+143
backend-go/db/atproto.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "strings" 6 + "time" 7 + 8 + "github.com/nrednav/cuid2" 9 + ) 10 + 11 + type ATProtoUserResult struct { 12 + User *UserFull 13 + Error string 14 + Linked bool 15 + } 16 + 17 + func FindOrCreateUserFromAtproto(db *sql.DB, did, handle, email, avatar, linkToUserID string) (*ATProtoUserResult, error) { 18 + 19 + var existingUserID string 20 + var existingDisplayName sql.NullString 21 + err := db.QueryRow(`SELECT userId, displayName FROM oauthAccounts WHERE provider = 'atproto' AND providerAccountId = ?`, did).Scan(&existingUserID, &existingDisplayName) 22 + 23 + if err == nil { 24 + 25 + if linkToUserID != "" && existingUserID != linkToUserID { 26 + return &ATProtoUserResult{Error: "oauth-account-already-linked"}, nil 27 + } 28 + 29 + user, err := GetUserByID(db, existingUserID) 30 + if err != nil { 31 + return nil, err 32 + } 33 + if user == nil { 34 + return &ATProtoUserResult{User: nil}, nil 35 + } 36 + 37 + if handle != "" && (!existingDisplayName.Valid || existingDisplayName.String == "") { 38 + db.Exec(`UPDATE oauthAccounts SET displayName = ? WHERE provider = 'atproto' AND providerAccountId = ?`, handle, did) 39 + } 40 + 41 + updates := false 42 + if (!user.Username.Valid || user.Username.String == "" || user.Username.String == did) && handle != "" { 43 + user.Username = sql.NullString{String: handle, Valid: true} 44 + updates = true 45 + } 46 + if (!user.AvatarURL.Valid || user.AvatarURL.String == "") && avatar != "" { 47 + user.AvatarURL = sql.NullString{String: avatar, Valid: true} 48 + updates = true 49 + } 50 + 51 + if email != "" && (isTempEmail(user.Email) || strings.HasPrefix(user.Email, did)) { 52 + 53 + existing, _ := GetUserByEmail(db, email) 54 + if existing == nil || existing.ID == user.ID { 55 + user.Email = email 56 + updates = true 57 + } 58 + } 59 + 60 + if updates { 61 + 62 + _, _ = db.Exec(`UPDATE users SET username = ?, avatarUrl = ?, email = ? WHERE id = ?`, 63 + user.Username, user.AvatarURL, user.Email, user.ID) 64 + } 65 + 66 + UpdateLastLogin(db, user.ID) 67 + 68 + return &ATProtoUserResult{User: user}, nil 69 + } else if err != sql.ErrNoRows { 70 + return nil, err 71 + } 72 + 73 + if linkToUserID != "" { 74 + 75 + user, err := GetUserByID(db, linkToUserID) 76 + if err != nil { 77 + return nil, err 78 + } 79 + if user == nil { 80 + return &ATProtoUserResult{Error: "user-not-found"}, nil 81 + } 82 + 83 + id := cuid2.Generate() 84 + now := time.Now().UTC().Format(time.RFC3339) 85 + _, err = db.Exec(`INSERT INTO oauthAccounts (id, provider, providerAccountId, displayName, userId, createdAt) VALUES (?, ?, ?, ?, ?, ?)`, 86 + id, "atproto", did, handle, user.ID, now) 87 + if err != nil { 88 + return nil, err 89 + } 90 + 91 + return &ATProtoUserResult{User: user, Linked: true}, nil 92 + } 93 + 94 + finalEmail := email 95 + if finalEmail == "" { 96 + if handle != "" { 97 + finalEmail = handle + "@atproto.local" 98 + } else { 99 + finalEmail = did + "@atproto.local" 100 + } 101 + } 102 + 103 + if email != "" { 104 + existing, _ := GetUserByEmail(db, email) 105 + if existing != nil { 106 + finalEmail = did + "@atproto.local" 107 + } 108 + } 109 + 110 + username := handle 111 + if username == "" { 112 + username = did 113 + } 114 + 115 + uid := cuid2.Generate() 116 + now := time.Now().UTC().Format(time.RFC3339) 117 + 118 + emailVerified := 0 119 + if email != "" { 120 + emailVerified = 1 121 + } 122 + 123 + _, err = db.Exec(`INSERT INTO users (id, email, username, avatarUrl, emailVerified, createdAt, lastLoginAt) VALUES (?, ?, ?, ?, ?, ?, ?)`, 124 + uid, finalEmail, username, avatar, emailVerified, now, now) 125 + if err != nil { 126 + return nil, err 127 + } 128 + 129 + oauthID := cuid2.Generate() 130 + _, err = db.Exec(`INSERT INTO oauthAccounts (id, provider, providerAccountId, displayName, userId, createdAt) VALUES (?, ?, ?, ?, ?, ?)`, 131 + oauthID, "atproto", did, handle, uid, now) 132 + if err != nil { 133 + return nil, err 134 + } 135 + 136 + user, _ := GetUserByID(db, uid) 137 + return &ATProtoUserResult{User: user}, nil 138 + } 139 + 140 + func isTempEmail(email string) bool { 141 + 142 + return len(email) > 14 && email[len(email)-14:] == "@atproto.local" 143 + }
+136
backend-go/db/custom_domains.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "time" 7 + ) 8 + 9 + type CustomDomain struct { 10 + ID string 11 + SiteID string 12 + Hostname string 13 + CFCustomHostnameID sql.NullString 14 + Status string 15 + SSLStatus sql.NullString 16 + VerificationRecords sql.NullString 17 + CreatedAt string 18 + } 19 + 20 + type CustomDomainResponse struct { 21 + ID string `json:"id"` 22 + SiteID string `json:"siteId"` 23 + Hostname string `json:"hostname"` 24 + CfID string `json:"cfId,omitempty"` 25 + Status string `json:"status"` 26 + SSLStatus string `json:"sslStatus"` 27 + VerificationRecords interface{} `json:"verificationRecords"` 28 + CreatedAt string `json:"createdAt"` 29 + } 30 + 31 + func (d *CustomDomain) ToResponse() CustomDomainResponse { 32 + resp := CustomDomainResponse{ 33 + ID: d.ID, 34 + SiteID: d.SiteID, 35 + Hostname: d.Hostname, 36 + Status: d.Status, 37 + CreatedAt: d.CreatedAt, 38 + } 39 + if d.CFCustomHostnameID.Valid { 40 + resp.CfID = d.CFCustomHostnameID.String 41 + } 42 + if d.SSLStatus.Valid { 43 + resp.SSLStatus = d.SSLStatus.String 44 + } 45 + if d.VerificationRecords.Valid && d.VerificationRecords.String != "" { 46 + json.Unmarshal([]byte(d.VerificationRecords.String), &resp.VerificationRecords) 47 + } else { 48 + resp.VerificationRecords = []interface{}{} 49 + } 50 + return resp 51 + } 52 + 53 + func CreateCustomDomain(db *sql.DB, id, siteID, hostname, cfID, status, sslStatus, records string) error { 54 + _, err := db.Exec(` 55 + INSERT INTO customDomains (id, siteId, hostname, cfCustomHostnameId, status, sslStatus, verificationRecords, createdAt) 56 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 57 + `, id, siteID, hostname, toNull(cfID), status, toNull(sslStatus), toNull(records), time.Now().UTC().Format(time.RFC3339)) 58 + return err 59 + } 60 + 61 + func GetCustomDomainByID(db *sql.DB, id string) (*CustomDomain, error) { 62 + var d CustomDomain 63 + err := db.QueryRow(` 64 + SELECT id, siteId, hostname, cfCustomHostnameId, status, sslStatus, verificationRecords, createdAt 65 + FROM customDomains WHERE id = ? 66 + `, id).Scan(&d.ID, &d.SiteID, &d.Hostname, &d.CFCustomHostnameID, &d.Status, &d.SSLStatus, &d.VerificationRecords, &d.CreatedAt) 67 + if err != nil { 68 + return nil, err 69 + } 70 + return &d, nil 71 + } 72 + 73 + func GetCustomDomainByHostname(db *sql.DB, hostname string) (*CustomDomain, error) { 74 + var d CustomDomain 75 + err := db.QueryRow(` 76 + SELECT id, siteId, hostname, cfCustomHostnameId, status, sslStatus, verificationRecords, createdAt 77 + FROM customDomains WHERE hostname = ? 78 + `, hostname).Scan(&d.ID, &d.SiteID, &d.Hostname, &d.CFCustomHostnameID, &d.Status, &d.SSLStatus, &d.VerificationRecords, &d.CreatedAt) 79 + if err != nil { 80 + return nil, err 81 + } 82 + return &d, nil 83 + } 84 + 85 + func ListCustomDomains(db *sql.DB, siteID string) ([]CustomDomain, error) { 86 + rows, err := db.Query(` 87 + SELECT id, siteId, hostname, cfCustomHostnameId, status, sslStatus, verificationRecords, createdAt 88 + FROM customDomains WHERE siteId = ? 89 + ORDER BY createdAt ASC 90 + `, siteID) 91 + if err != nil { 92 + return nil, err 93 + } 94 + defer rows.Close() 95 + 96 + var domains []CustomDomain 97 + for rows.Next() { 98 + var d CustomDomain 99 + if err := rows.Scan(&d.ID, &d.SiteID, &d.Hostname, &d.CFCustomHostnameID, &d.Status, &d.SSLStatus, &d.VerificationRecords, &d.CreatedAt); err != nil { 100 + return nil, err 101 + } 102 + domains = append(domains, d) 103 + } 104 + return domains, nil 105 + } 106 + 107 + func UpdateCustomDomainStatus(db *sql.DB, id, status, sslStatus, records string, cfID string) error { 108 + 109 + if cfID != "" { 110 + _, err := db.Exec(` 111 + UPDATE customDomains SET status = ?, sslStatus = ?, verificationRecords = ?, cfCustomHostnameId = ? WHERE id = ? 112 + `, status, sslStatus, records, cfID, id) 113 + return err 114 + } 115 + _, err := db.Exec(` 116 + UPDATE customDomains SET status = ?, sslStatus = ?, verificationRecords = ? WHERE id = ? 117 + `, status, sslStatus, records, id) 118 + return err 119 + } 120 + 121 + func DeleteCustomDomain(db *sql.DB, id string) error { 122 + _, err := db.Exec("DELETE FROM customDomains WHERE id = ?", id) 123 + return err 124 + } 125 + 126 + func CountCustomDomainsForUser(db *sql.DB, userID string) (int, error) { 127 + 128 + var count int 129 + err := db.QueryRow(` 130 + SELECT COUNT(cd.id) 131 + FROM customDomains cd 132 + JOIN sites s ON cd.siteId = s.id 133 + WHERE s.userId = ? 134 + `, userID).Scan(&count) 135 + return count, err 136 + }
+203
backend-go/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "os" 6 + "path/filepath" 7 + "sync" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + ) 11 + 12 + var ( 13 + instance *sql.DB 14 + once sync.Once 15 + ) 16 + 17 + func GetDB(dbPath string) (*sql.DB, error) { 18 + var initErr error 19 + 20 + once.Do(func() { 21 + path := dbPath 22 + if path == "" { 23 + dataDir := os.Getenv("FSD_DATA_DIR") 24 + if dataDir == "" { 25 + dataDir = ".fsd" 26 + } 27 + if err := os.MkdirAll(dataDir, 0755); err != nil { 28 + initErr = err 29 + return 30 + } 31 + path = filepath.Join(dataDir, "data.sqlite") 32 + } 33 + 34 + db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_foreign_keys=on") 35 + if err != nil { 36 + initErr = err 37 + return 38 + } 39 + 40 + if err := initSchema(db); err != nil { 41 + initErr = err 42 + return 43 + } 44 + 45 + instance = db 46 + }) 47 + 48 + return instance, initErr 49 + } 50 + 51 + func initSchema(db *sql.DB) error { 52 + schema := ` 53 + CREATE TABLE IF NOT EXISTS users ( 54 + id TEXT PRIMARY KEY, 55 + email TEXT NOT NULL UNIQUE, 56 + username TEXT UNIQUE, 57 + avatarUrl TEXT, 58 + passwordHash TEXT, 59 + emailVerified INTEGER NOT NULL DEFAULT 0, 60 + banned INTEGER DEFAULT 0, 61 + createdAt TEXT, 62 + lastLoginAt TEXT 63 + ); 64 + 65 + CREATE TABLE IF NOT EXISTS sites ( 66 + id TEXT PRIMARY KEY, 67 + userId TEXT, 68 + name TEXT NOT NULL, 69 + domain TEXT, 70 + gitUrl TEXT, 71 + gitBranch TEXT, 72 + gitSubdir TEXT, 73 + path TEXT, 74 + envJson TEXT, 75 + configJson TEXT, 76 + envText TEXT, 77 + buildCommand TEXT, 78 + outputDir TEXT, 79 + createdAt TEXT, 80 + currentDeploymentId TEXT, 81 + FOREIGN KEY(userId) REFERENCES users(id) ON DELETE CASCADE 82 + ); 83 + 84 + CREATE TABLE IF NOT EXISTS deployments ( 85 + id TEXT PRIMARY KEY, 86 + userId TEXT, 87 + siteId TEXT, 88 + createdAt TEXT, 89 + status TEXT, 90 + image TEXT, 91 + containerName TEXT, 92 + containerId TEXT, 93 + hostPort INTEGER, 94 + containerPort INTEGER, 95 + url TEXT, 96 + logsPath TEXT, 97 + commitSha TEXT, 98 + commitMessage TEXT, 99 + commitMessage TEXT, 100 + commitAuthor TEXT, 101 + commitAvatar TEXT, 102 + FOREIGN KEY(userId) REFERENCES users(id) ON DELETE CASCADE, 103 + FOREIGN KEY(siteId) REFERENCES sites(id) ON DELETE CASCADE 104 + ); 105 + 106 + CREATE TABLE IF NOT EXISTS oauthAccounts ( 107 + id TEXT PRIMARY KEY, 108 + provider TEXT, 109 + providerAccountId TEXT, 110 + displayName TEXT, 111 + userId TEXT, 112 + accessToken TEXT, 113 + createdAt TEXT, 114 + FOREIGN KEY(userId) REFERENCES users(id) ON DELETE CASCADE 115 + ); 116 + 117 + CREATE TABLE IF NOT EXISTS emailVerifications ( 118 + id TEXT PRIMARY KEY, 119 + userId TEXT, 120 + token TEXT UNIQUE, 121 + newEmail TEXT, 122 + createdAt TEXT, 123 + expiresAt INTEGER, 124 + usedAt TEXT, 125 + FOREIGN KEY(userId) REFERENCES users(id) ON DELETE CASCADE 126 + ); 127 + 128 + CREATE TABLE IF NOT EXISTS customDomains ( 129 + id TEXT PRIMARY KEY, 130 + siteId TEXT, 131 + hostname TEXT NOT NULL, 132 + cfCustomHostnameId TEXT, 133 + status TEXT NOT NULL DEFAULT 'pending', 134 + sslStatus TEXT, 135 + verificationRecords TEXT, 136 + createdAt TEXT, 137 + FOREIGN KEY(siteId) REFERENCES sites(id) ON DELETE CASCADE 138 + ); 139 + 140 + CREATE TABLE IF NOT EXISTS apiKeys ( 141 + id TEXT PRIMARY KEY, 142 + userId TEXT NOT NULL, 143 + name TEXT NOT NULL, 144 + keyHash TEXT NOT NULL, 145 + keyPrefix TEXT NOT NULL, 146 + createdAt TEXT, 147 + lastUsedAt TEXT, 148 + FOREIGN KEY(userId) REFERENCES users(id) ON DELETE CASCADE 149 + ); 150 + 151 + CREATE TABLE IF NOT EXISTS bannedIPs ( 152 + ip TEXT PRIMARY KEY, 153 + reason TEXT, 154 + userId TEXT, 155 + createdAt TEXT 156 + ); 157 + 158 + CREATE TABLE IF NOT EXISTS userIPs ( 159 + id TEXT PRIMARY KEY, 160 + userId TEXT, 161 + ipHash TEXT NOT NULL, 162 + createdAt TEXT, 163 + FOREIGN KEY(userId) REFERENCES users(id) ON DELETE CASCADE 164 + ); 165 + 166 + CREATE TABLE IF NOT EXISTS githubAppInstallations ( 167 + id TEXT PRIMARY KEY, 168 + odId TEXT, 169 + userId TEXT, 170 + installationId TEXT NOT NULL, 171 + accountLogin TEXT, 172 + accountType TEXT, 173 + createdAt TEXT, 174 + FOREIGN KEY(userId) REFERENCES users(id) ON DELETE CASCADE 175 + ); 176 + 177 + CREATE TABLE IF NOT EXISTS atprotoStates ( 178 + key TEXT PRIMARY KEY, 179 + internalStateJson TEXT, 180 + createdAt TEXT, 181 + expiresAt INTEGER 182 + ); 183 + 184 + CREATE TABLE IF NOT EXISTS atprotoSessions ( 185 + sub TEXT PRIMARY KEY, 186 + sessionJson TEXT, 187 + updatedAt TEXT 188 + ); 189 + ` 190 + 191 + _, err := db.Exec(schema) 192 + if err != nil { 193 + return err 194 + } 195 + 196 + db.Exec(`ALTER TABLE customDomains ADD COLUMN cfCustomHostnameId TEXT`) 197 + 198 + db.Exec(`ALTER TABLE customDomains ADD COLUMN sslStatus TEXT`) 199 + 200 + db.Exec(`ALTER TABLE deployments ADD COLUMN commitAvatar TEXT`) 201 + 202 + return nil 203 + }
+122
backend-go/db/deployments.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "time" 6 + ) 7 + 8 + type Deployment struct { 9 + ID string 10 + UserID string 11 + SiteID string 12 + CreatedAt string 13 + Status string 14 + URL sql.NullString 15 + CommitSha sql.NullString 16 + CommitMessage sql.NullString 17 + CommitAuthor sql.NullString 18 + CommitAvatar sql.NullString 19 + LogsPath sql.NullString 20 + } 21 + 22 + type DeploymentResponse struct { 23 + ID string `json:"id"` 24 + Status string `json:"status"` 25 + URL *string `json:"url"` 26 + CreatedAt string `json:"createdAt"` 27 + CommitSha *string `json:"commitSha"` 28 + CommitMessage *string `json:"commitMessage"` 29 + CommitAuthor *string `json:"commitAuthor"` 30 + CommitAvatar *string `json:"commitAvatar"` 31 + } 32 + 33 + func (d *Deployment) ToResponse() DeploymentResponse { 34 + resp := DeploymentResponse{ 35 + ID: d.ID, 36 + Status: d.Status, 37 + CreatedAt: d.CreatedAt, 38 + } 39 + if d.URL.Valid { 40 + resp.URL = &d.URL.String 41 + } 42 + if d.CommitSha.Valid { 43 + resp.CommitSha = &d.CommitSha.String 44 + } 45 + if d.CommitMessage.Valid { 46 + resp.CommitMessage = &d.CommitMessage.String 47 + } 48 + if d.CommitAuthor.Valid { 49 + resp.CommitAuthor = &d.CommitAuthor.String 50 + } 51 + if d.CommitAvatar.Valid { 52 + resp.CommitAvatar = &d.CommitAvatar.String 53 + } 54 + return resp 55 + } 56 + 57 + func CreateDeployment(db *sql.DB, id, userID, siteID, status string, commitSha, commitMessage, commitAuthor, commitAvatar *string) error { 58 + _, err := db.Exec(` 59 + INSERT INTO deployments (id, userId, siteId, createdAt, status, commitSha, commitMessage, commitAuthor, commitAvatar) 60 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 61 + `, id, userID, siteID, time.Now().UTC().Format(time.RFC3339), status, commitSha, commitMessage, commitAuthor, commitAvatar) 62 + return err 63 + } 64 + 65 + func UpdateDeploymentStatus(db *sql.DB, id, status, urlStr string) error { 66 + var urlVal sql.NullString 67 + if urlStr != "" { 68 + urlVal = sql.NullString{String: urlStr, Valid: true} 69 + } 70 + _, err := db.Exec(`UPDATE deployments SET status = ?, url = ? WHERE id = ?`, status, urlVal, id) 71 + return err 72 + } 73 + 74 + func UpdateDeploymentLogs(db *sql.DB, id, logsPath string) error { 75 + _, err := db.Exec(`UPDATE deployments SET logsPath = ? WHERE id = ?`, logsPath, id) 76 + return err 77 + } 78 + 79 + func StopOtherDeployments(db *sql.DB, siteID, currentDeployID string) error { 80 + _, err := db.Exec(` 81 + UPDATE deployments 82 + SET status = 'stopped' 83 + WHERE siteId = ? AND id != ? AND status = 'running' 84 + `, siteID, currentDeployID) 85 + return err 86 + } 87 + 88 + func GetDeploymentByID(db *sql.DB, id string) (*Deployment, error) { 89 + var d Deployment 90 + err := db.QueryRow(` 91 + SELECT id, userId, siteId, createdAt, status, url, commitSha, commitMessage, commitAuthor, commitAvatar, logsPath 92 + FROM deployments WHERE id = ? 93 + `, id).Scan(&d.ID, &d.UserID, &d.SiteID, &d.CreatedAt, &d.Status, 94 + &d.URL, &d.CommitSha, &d.CommitMessage, &d.CommitAuthor, &d.CommitAvatar, &d.LogsPath) 95 + if err != nil { 96 + return nil, err 97 + } 98 + return &d, nil 99 + } 100 + 101 + func ListDeployments(db *sql.DB, userID, siteID string) ([]Deployment, error) { 102 + rows, err := db.Query(` 103 + SELECT id, userId, siteId, createdAt, status, url, commitSha, commitMessage, commitAuthor, commitAvatar, logsPath 104 + FROM deployments WHERE userId = ? AND siteId = ? 105 + ORDER BY createdAt DESC 106 + `, userID, siteID) 107 + if err != nil { 108 + return nil, err 109 + } 110 + defer rows.Close() 111 + 112 + var deps []Deployment 113 + for rows.Next() { 114 + var d Deployment 115 + if err := rows.Scan(&d.ID, &d.UserID, &d.SiteID, &d.CreatedAt, &d.Status, 116 + &d.URL, &d.CommitSha, &d.CommitMessage, &d.CommitAuthor, &d.CommitAvatar, &d.LogsPath); err != nil { 117 + return nil, err 118 + } 119 + deps = append(deps, d) 120 + } 121 + return deps, rows.Err() 122 + }
+52
backend-go/db/email.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "time" 6 + ) 7 + 8 + type EmailVerification struct { 9 + ID string 10 + UserID string 11 + Token string 12 + NewEmail sql.NullString 13 + CreatedAt string 14 + ExpiresAt int64 15 + UsedAt sql.NullString 16 + } 17 + 18 + func CreateVerificationToken(db *sql.DB, id, userID, token string, expiresAt int64) error { 19 + _, err := db.Exec(` 20 + INSERT INTO emailVerifications (id, userId, token, createdAt, expiresAt) 21 + VALUES (?, ?, ?, ?, ?) 22 + `, id, userID, token, time.Now().UTC().Format(time.RFC3339), expiresAt) 23 + return err 24 + } 25 + 26 + func GetVerificationToken(db *sql.DB, token string) (*EmailVerification, error) { 27 + var ev EmailVerification 28 + err := db.QueryRow(` 29 + SELECT id, userId, token, newEmail, createdAt, expiresAt, usedAt 30 + FROM emailVerifications 31 + WHERE token = ? AND usedAt IS NULL 32 + `, token).Scan(&ev.ID, &ev.UserID, &ev.Token, &ev.NewEmail, &ev.CreatedAt, &ev.ExpiresAt, &ev.UsedAt) 33 + if err != nil { 34 + return nil, err 35 + } 36 + return &ev, nil 37 + } 38 + 39 + func MarkTokenUsed(db *sql.DB, id string) error { 40 + _, err := db.Exec(`UPDATE emailVerifications SET usedAt = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id) 41 + return err 42 + } 43 + 44 + func UpdateUserEmailVerified(db *sql.DB, userID string) error { 45 + _, err := db.Exec(`UPDATE users SET emailVerified = 1 WHERE id = ?`, userID) 46 + return err 47 + } 48 + 49 + func UpdateUserPassword(db *sql.DB, userID, passwordHash string) error { 50 + _, err := db.Exec(`UPDATE users SET passwordHash = ? WHERE id = ?`, passwordHash, userID) 51 + return err 52 + }
+96
backend-go/db/github.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "strings" 6 + "time" 7 + ) 8 + 9 + type GitHubInstallation struct { 10 + ID string 11 + OdID sql.NullString 12 + UserID sql.NullString 13 + InstallationID string 14 + AccountLogin sql.NullString 15 + AccountType sql.NullString 16 + CreatedAt string 17 + } 18 + 19 + func AddGitHubInstallation(db *sql.DB, id, installationID, accountLogin, accountType string, userID string) error { 20 + 21 + var exists string 22 + err := db.QueryRow("SELECT id FROM githubAppInstallations WHERE installationId = ?", installationID).Scan(&exists) 23 + if err == nil { 24 + 25 + if userID != "" { 26 + _, err = db.Exec("UPDATE githubAppInstallations SET userId = ? WHERE installationId = ?", userID, installationID) 27 + } 28 + return err 29 + } 30 + 31 + _, err = db.Exec(` 32 + INSERT INTO githubAppInstallations (id, userId, installationId, accountLogin, accountType, createdAt) 33 + VALUES (?, ?, ?, ?, ?, ?) 34 + `, id, toNull(userID), installationID, toNull(accountLogin), toNull(accountType), time.Now().UTC().Format(time.RFC3339)) 35 + return err 36 + } 37 + 38 + func RemoveGitHubInstallation(db *sql.DB, installationID string) error { 39 + _, err := db.Exec("DELETE FROM githubAppInstallations WHERE installationId = ?", installationID) 40 + return err 41 + } 42 + 43 + func GetSitesByRepo(db *sql.DB, repoURL, branch string) ([]Site, error) { 44 + 45 + rows, err := db.Query(` 46 + SELECT id, userId, name, domain, gitUrl, gitBranch, gitSubdir, 47 + path, buildCommand, outputDir, createdAt, currentDeploymentId 48 + FROM sites WHERE gitUrl IS NOT NULL 49 + `) 50 + if err != nil { 51 + return nil, err 52 + } 53 + defer rows.Close() 54 + 55 + var matches []Site 56 + 57 + clean := func(u string) string { 58 + return strings.TrimSuffix(strings.ToLower(u), ".git") 59 + } 60 + target := clean(repoURL) 61 + targetBranch := branch 62 + 63 + for rows.Next() { 64 + var s Site 65 + if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.Domain, &s.GitURL, &s.GitBranch, 66 + &s.GitSubdir, &s.Path, &s.BuildCommand, &s.OutputDir, &s.CreatedAt, &s.CurrentDeploymentID); err != nil { 67 + continue 68 + } 69 + 70 + if !s.GitURL.Valid || s.GitURL.String == "" { 71 + continue 72 + } 73 + 74 + siteBranch := "main" 75 + if s.GitBranch.Valid && s.GitBranch.String != "" { 76 + siteBranch = s.GitBranch.String 77 + } 78 + if siteBranch != targetBranch { 79 + continue 80 + } 81 + 82 + siteGit := clean(s.GitURL.String) 83 + 84 + if siteGit == target || strings.HasSuffix(target, siteGit) || strings.HasSuffix(siteGit, target) { 85 + matches = append(matches, s) 86 + } 87 + } 88 + return matches, nil 89 + } 90 + 91 + func toNull(s string) sql.NullString { 92 + if s == "" { 93 + return sql.NullString{Valid: false} 94 + } 95 + return sql.NullString{String: s, Valid: true} 96 + }
+80
backend-go/db/oauth.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "time" 6 + ) 7 + 8 + type OAuthAccount struct { 9 + ID string 10 + Provider string 11 + ProviderAccountID string 12 + DisplayName sql.NullString 13 + UserID string 14 + AccessToken sql.NullString 15 + CreatedAt string 16 + } 17 + 18 + func CreateOAuthAccount(db *sql.DB, id, provider, providerAccountID, userID, accessToken, displayName string) error { 19 + now := time.Now().UTC().Format(time.RFC3339) 20 + _, err := db.Exec(` 21 + INSERT INTO oauthAccounts (id, provider, providerAccountId, userId, accessToken, displayName, createdAt) 22 + VALUES (?, ?, ?, ?, ?, ?, ?) 23 + `, id, provider, providerAccountID, userID, accessToken, displayName, now) 24 + return err 25 + } 26 + 27 + func FindOAuthAccount(db *sql.DB, provider, providerAccountID string) (*OAuthAccount, error) { 28 + var acc OAuthAccount 29 + err := db.QueryRow(` 30 + SELECT id, provider, providerAccountId, displayName, userId, accessToken, createdAt 31 + FROM oauthAccounts WHERE provider = ? AND providerAccountId = ? 32 + `, provider, providerAccountID).Scan(&acc.ID, &acc.Provider, &acc.ProviderAccountID, &acc.DisplayName, &acc.UserID, &acc.AccessToken, &acc.CreatedAt) 33 + if err != nil { 34 + return nil, err 35 + } 36 + return &acc, nil 37 + } 38 + 39 + func ListOAuthAccounts(db *sql.DB, userID string) ([]OAuthAccount, error) { 40 + rows, err := db.Query(` 41 + SELECT id, provider, providerAccountId, displayName, userId, accessToken, createdAt 42 + FROM oauthAccounts WHERE userId = ? 43 + `, userID) 44 + if err != nil { 45 + return nil, err 46 + } 47 + defer rows.Close() 48 + 49 + var accounts []OAuthAccount 50 + for rows.Next() { 51 + var acc OAuthAccount 52 + if err := rows.Scan(&acc.ID, &acc.Provider, &acc.ProviderAccountID, &acc.DisplayName, &acc.UserID, &acc.AccessToken, &acc.CreatedAt); err != nil { 53 + return nil, err 54 + } 55 + accounts = append(accounts, acc) 56 + } 57 + return accounts, rows.Err() 58 + } 59 + 60 + func DeleteOAuthAccount(db *sql.DB, userID, accountID string) error { 61 + _, err := db.Exec(`DELETE FROM oauthAccounts WHERE id = ? AND userId = ?`, accountID, userID) 62 + return err 63 + } 64 + 65 + func GetGitHubToken(db *sql.DB, userID string) (string, error) { 66 + var token sql.NullString 67 + err := db.QueryRow(` 68 + SELECT accessToken FROM oauthAccounts WHERE userId = ? AND provider = 'github' LIMIT 1 69 + `, userID).Scan(&token) 70 + if err == sql.ErrNoRows { 71 + return "", nil 72 + } 73 + if err != nil { 74 + return "", err 75 + } 76 + if token.Valid { 77 + return token.String, nil 78 + } 79 + return "", nil 80 + }
+222
backend-go/db/sites.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "time" 7 + ) 8 + 9 + type Site struct { 10 + ID string 11 + UserID string 12 + Name string 13 + Domain string 14 + GitURL sql.NullString 15 + GitBranch sql.NullString 16 + GitSubdir sql.NullString 17 + Path sql.NullString 18 + EnvText sql.NullString 19 + BuildCommand sql.NullString 20 + OutputDir sql.NullString 21 + CreatedAt string 22 + CurrentDeploymentID sql.NullString 23 + } 24 + 25 + type SiteResponse struct { 26 + ID string `json:"id"` 27 + Name string `json:"name"` 28 + Domain string `json:"domain"` 29 + GitURL *string `json:"gitUrl"` 30 + GitBranch *string `json:"gitBranch"` 31 + GitSubdir *string `json:"gitSubdir,omitempty"` 32 + BuildCommand *string `json:"buildCommand,omitempty"` 33 + OutputDir *string `json:"outputDir,omitempty"` 34 + CreatedAt string `json:"createdAt"` 35 + CurrentDeploymentID *string `json:"currentDeploymentId"` 36 + EnvText string `json:"envText,omitempty"` 37 + } 38 + 39 + func (s *Site) ToResponse() SiteResponse { 40 + resp := SiteResponse{ 41 + ID: s.ID, 42 + Name: s.Name, 43 + Domain: s.Domain, 44 + CreatedAt: s.CreatedAt, 45 + } 46 + if s.EnvText.Valid { 47 + resp.EnvText = s.EnvText.String 48 + } 49 + if s.GitURL.Valid { 50 + resp.GitURL = &s.GitURL.String 51 + } 52 + if s.GitBranch.Valid { 53 + resp.GitBranch = &s.GitBranch.String 54 + } 55 + if s.GitSubdir.Valid && s.GitSubdir.String != "" { 56 + resp.GitSubdir = &s.GitSubdir.String 57 + } 58 + if s.BuildCommand.Valid && s.BuildCommand.String != "" { 59 + resp.BuildCommand = &s.BuildCommand.String 60 + } 61 + if s.OutputDir.Valid && s.OutputDir.String != "" { 62 + resp.OutputDir = &s.OutputDir.String 63 + } 64 + if s.CurrentDeploymentID.Valid { 65 + resp.CurrentDeploymentID = &s.CurrentDeploymentID.String 66 + } 67 + return resp 68 + } 69 + 70 + func ListSites(db *sql.DB, userID string) ([]Site, error) { 71 + rows, err := db.Query(` 72 + SELECT id, userId, name, domain, gitUrl, gitBranch, gitSubdir, 73 + path, envText, buildCommand, outputDir, createdAt, currentDeploymentId 74 + FROM sites WHERE userId = ? 75 + `, userID) 76 + if err != nil { 77 + return nil, err 78 + } 79 + defer rows.Close() 80 + 81 + var sites []Site 82 + for rows.Next() { 83 + var s Site 84 + if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.Domain, &s.GitURL, &s.GitBranch, 85 + &s.GitSubdir, &s.Path, &s.EnvText, &s.BuildCommand, &s.OutputDir, &s.CreatedAt, &s.CurrentDeploymentID); err != nil { 86 + return nil, err 87 + } 88 + sites = append(sites, s) 89 + } 90 + return sites, rows.Err() 91 + } 92 + 93 + func GetAllSites(db *sql.DB) ([]Site, error) { 94 + rows, err := db.Query(` 95 + SELECT id, userId, name, domain, gitUrl, gitBranch, gitSubdir, 96 + path, envText, buildCommand, outputDir, createdAt, currentDeploymentId 97 + FROM sites 98 + `) 99 + if err != nil { 100 + return nil, err 101 + } 102 + defer rows.Close() 103 + 104 + var sites []Site 105 + for rows.Next() { 106 + var s Site 107 + if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.Domain, &s.GitURL, &s.GitBranch, 108 + &s.GitSubdir, &s.Path, &s.EnvText, &s.BuildCommand, &s.OutputDir, &s.CreatedAt, &s.CurrentDeploymentID); err != nil { 109 + return nil, err 110 + } 111 + sites = append(sites, s) 112 + } 113 + return sites, rows.Err() 114 + } 115 + 116 + func GetSiteByID(db *sql.DB, userID, siteID string) (*Site, error) { 117 + var s Site 118 + err := db.QueryRow(` 119 + SELECT id, userId, name, domain, gitUrl, gitBranch, gitSubdir, 120 + path, envText, buildCommand, outputDir, createdAt, currentDeploymentId 121 + FROM sites WHERE id = ? AND userId = ? 122 + `, siteID, userID).Scan(&s.ID, &s.UserID, &s.Name, &s.Domain, &s.GitURL, &s.GitBranch, 123 + &s.GitSubdir, &s.Path, &s.EnvText, &s.BuildCommand, &s.OutputDir, &s.CreatedAt, &s.CurrentDeploymentID) 124 + if err != nil { 125 + return nil, err 126 + } 127 + return &s, nil 128 + } 129 + 130 + func GetSiteByIDAdmin(db *sql.DB, siteID string) (*Site, error) { 131 + var s Site 132 + err := db.QueryRow(` 133 + SELECT id, userId, name, domain, gitUrl, gitBranch, gitSubdir, 134 + path, envText, buildCommand, outputDir, createdAt, currentDeploymentId 135 + FROM sites WHERE id = ? 136 + `, siteID).Scan(&s.ID, &s.UserID, &s.Name, &s.Domain, &s.GitURL, &s.GitBranch, 137 + &s.GitSubdir, &s.Path, &s.EnvText, &s.BuildCommand, &s.OutputDir, &s.CreatedAt, &s.CurrentDeploymentID) 138 + if err != nil { 139 + return nil, err 140 + } 141 + return &s, nil 142 + } 143 + 144 + func UpdateSiteCurrentDeployment(db *sql.DB, siteID, deployID string) error { 145 + _, err := db.Exec(`UPDATE sites SET currentDeploymentId = ? WHERE id = ?`, deployID, siteID) 146 + return err 147 + } 148 + 149 + func nullStringToPtr(ns sql.NullString) *string { 150 + if ns.Valid { 151 + return &ns.String 152 + } 153 + return nil 154 + } 155 + 156 + func GetSitesByUserID(db *sql.DB, userID string) ([]Site, error) { 157 + return ListSites(db, userID) 158 + } 159 + 160 + func DeleteSite(db *sql.DB, userID, siteID string) error { 161 + 162 + _, err := db.Exec(`DELETE FROM sites WHERE id = ? AND userId = ?`, siteID, userID) 163 + return err 164 + } 165 + 166 + func GetSiteByDomain(db *sql.DB, domain string) (*Site, error) { 167 + var s Site 168 + err := db.QueryRow(` 169 + SELECT id, userId, name, domain, gitUrl, gitBranch, gitSubdir, 170 + path, envText, buildCommand, outputDir, createdAt, currentDeploymentId 171 + FROM sites WHERE domain = ? 172 + `, domain).Scan(&s.ID, &s.UserID, &s.Name, &s.Domain, &s.GitURL, &s.GitBranch, 173 + &s.GitSubdir, &s.Path, &s.EnvText, &s.BuildCommand, &s.OutputDir, &s.CreatedAt, &s.CurrentDeploymentID) 174 + if err != nil { 175 + return nil, err 176 + } 177 + return &s, nil 178 + } 179 + 180 + func mustMarshal(v interface{}) []byte { 181 + b, _ := json.Marshal(v) 182 + return b 183 + } 184 + 185 + func CreateSite(db *sql.DB, id, userID, name, domain, gitUrl, gitBranch, gitSubdir, buildCommand, outputDir string) error { 186 + 187 + toNull := func(s string) sql.NullString { 188 + if s == "" { 189 + return sql.NullString{Valid: false} 190 + } 191 + return sql.NullString{String: s, Valid: true} 192 + } 193 + 194 + _, err := db.Exec(` 195 + INSERT INTO sites (id, userId, name, domain, gitUrl, gitBranch, gitSubdir, buildCommand, outputDir, createdAt) 196 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 197 + `, id, userID, name, domain, toNull(gitUrl), toNull(gitBranch), toNull(gitSubdir), toNull(buildCommand), toNull(outputDir), 198 + time.Now().UTC().Format(time.RFC3339)) 199 + return err 200 + } 201 + 202 + func UpdateSiteSettings(db *sql.DB, id, name, domain, gitUrl, branch, subdir, buildCmd, outputDir string) error { 203 + toNull := func(s string) sql.NullString { 204 + if s == "" { 205 + return sql.NullString{Valid: false} 206 + } 207 + return sql.NullString{String: s, Valid: true} 208 + } 209 + 210 + _, err := db.Exec(` 211 + UPDATE sites 212 + SET name = ?, domain = ?, gitUrl = ?, gitBranch = ?, gitSubdir = ?, buildCommand = ?, outputDir = ? 213 + WHERE id = ? 214 + `, name, domain, toNull(gitUrl), toNull(branch), toNull(subdir), toNull(buildCmd), toNull(outputDir), id) 215 + return err 216 + } 217 + 218 + func UpdateSiteEnv(db *sql.DB, id, envText string) error { 219 + 220 + _, err := db.Exec(`UPDATE sites SET envText = ? WHERE id = ?`, envText, id) 221 + return err 222 + }
+97
backend-go/db/users.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "time" 6 + 7 + "golang.org/x/crypto/bcrypt" 8 + ) 9 + 10 + type UserFull struct { 11 + ID string 12 + Email string 13 + Username sql.NullString 14 + AvatarURL sql.NullString 15 + PasswordHash sql.NullString 16 + EmailVerified bool 17 + Banned bool 18 + CreatedAt string 19 + LastLoginAt sql.NullString 20 + } 21 + 22 + func CreateUser(db *sql.DB, id, email, password string) (*UserFull, error) { 23 + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + now := time.Now().UTC().Format(time.RFC3339) 29 + _, err = db.Exec(` 30 + INSERT INTO users (id, email, passwordHash, createdAt, emailVerified, banned) 31 + VALUES (?, ?, ?, ?, 0, 0) 32 + `, id, email, string(passwordHash), now) 33 + if err != nil { 34 + return nil, err 35 + } 36 + 37 + return &UserFull{ 38 + ID: id, 39 + Email: email, 40 + PasswordHash: sql.NullString{String: string(passwordHash), Valid: true}, 41 + CreatedAt: now, 42 + EmailVerified: false, 43 + Banned: false, 44 + }, nil 45 + } 46 + 47 + func GetUserByEmail(db *sql.DB, email string) (*UserFull, error) { 48 + var u UserFull 49 + var emailVerified, banned int 50 + err := db.QueryRow(` 51 + SELECT id, email, username, avatarUrl, passwordHash, emailVerified, banned, createdAt, lastLoginAt 52 + FROM users WHERE email = ? 53 + `, email).Scan(&u.ID, &u.Email, &u.Username, &u.AvatarURL, &u.PasswordHash, &emailVerified, &banned, &u.CreatedAt, &u.LastLoginAt) 54 + if err != nil { 55 + return nil, err 56 + } 57 + u.EmailVerified = emailVerified != 0 58 + u.Banned = banned != 0 59 + return &u, nil 60 + } 61 + 62 + func GetUserByID(db *sql.DB, id string) (*UserFull, error) { 63 + var u UserFull 64 + var emailVerified, banned int 65 + err := db.QueryRow(` 66 + SELECT id, email, username, avatarUrl, passwordHash, emailVerified, banned, createdAt, lastLoginAt 67 + FROM users WHERE id = ? 68 + `, id).Scan(&u.ID, &u.Email, &u.Username, &u.AvatarURL, &u.PasswordHash, &emailVerified, &banned, &u.CreatedAt, &u.LastLoginAt) 69 + if err != nil { 70 + return nil, err 71 + } 72 + u.EmailVerified = emailVerified != 0 73 + u.Banned = banned != 0 74 + return &u, nil 75 + } 76 + 77 + func UpdateLastLogin(db *sql.DB, userID string) error { 78 + _, err := db.Exec(`UPDATE users SET lastLoginAt = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), userID) 79 + return err 80 + } 81 + 82 + func (u *UserFull) VerifyPassword(password string) bool { 83 + if !u.PasswordHash.Valid { 84 + return false 85 + } 86 + err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash.String), []byte(password)) 87 + return err == nil 88 + } 89 + 90 + func SetUserBanned(db *sql.DB, userID string, banned bool) error { 91 + b := 0 92 + if banned { 93 + b = 1 94 + } 95 + _, err := db.Exec(`UPDATE users SET banned = ? WHERE id = ?`, b, userID) 96 + return err 97 + }
+333
backend-go/deploy/b2.go
··· 1 + package deploy 2 + 3 + import ( 4 + "bytes" 5 + "crypto/sha1" 6 + "encoding/base64" 7 + "encoding/hex" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/url" 13 + "sync" 14 + "time" 15 + ) 16 + 17 + type B2Client struct { 18 + KeyID string 19 + AppKey string 20 + BucketID string 21 + AuthToken string 22 + APIURL string 23 + DownloadURL string 24 + Client *http.Client 25 + mu sync.Mutex 26 + } 27 + 28 + func NewB2Client(keyID, appKey, bucketID string) *B2Client { 29 + return &B2Client{ 30 + KeyID: keyID, 31 + AppKey: appKey, 32 + BucketID: bucketID, 33 + Client: &http.Client{Timeout: 60 * time.Second}, 34 + } 35 + } 36 + 37 + func (c *B2Client) Authorize() error { 38 + req, err := http.NewRequest("GET", "https://api.backblazeb2.com/b2api/v2/b2_authorize_account", nil) 39 + if err != nil { 40 + return err 41 + } 42 + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", c.KeyID, c.AppKey))) 43 + req.Header.Set("Authorization", "Basic "+auth) 44 + 45 + resp, err := c.Client.Do(req) 46 + if err != nil { 47 + return err 48 + } 49 + defer resp.Body.Close() 50 + 51 + if resp.StatusCode != 200 { 52 + return fmt.Errorf("authorize failed: %d", resp.StatusCode) 53 + } 54 + 55 + var res struct { 56 + AuthorizationToken string `json:"authorizationToken"` 57 + APIURL string `json:"apiUrl"` 58 + DownloadURL string `json:"downloadUrl"` 59 + AbsoluteMinimumPartSize int `json:"absoluteMinimumPartSize"` 60 + } 61 + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { 62 + return err 63 + } 64 + 65 + c.mu.Lock() 66 + c.AuthToken = res.AuthorizationToken 67 + c.APIURL = res.APIURL 68 + c.DownloadURL = res.DownloadURL 69 + c.mu.Unlock() 70 + 71 + return nil 72 + } 73 + 74 + func (c *B2Client) GetUploadURL() (string, string, error) { 75 + c.mu.Lock() 76 + apiURL := c.APIURL 77 + authToken := c.AuthToken 78 + c.mu.Unlock() 79 + 80 + if apiURL == "" { 81 + return "", "", fmt.Errorf("not authorized") 82 + } 83 + 84 + body, _ := json.Marshal(map[string]string{ 85 + "bucketId": c.BucketID, 86 + }) 87 + 88 + req, _ := http.NewRequest("POST", apiURL+"/b2api/v2/b2_get_upload_url", bytes.NewBuffer(body)) 89 + req.Header.Set("Authorization", authToken) 90 + req.Header.Set("Content-Type", "application/json") 91 + 92 + resp, err := c.Client.Do(req) 93 + if err != nil { 94 + return "", "", err 95 + } 96 + defer resp.Body.Close() 97 + 98 + if resp.StatusCode != 200 { 99 + return "", "", fmt.Errorf("get_upload_url failed: %d", resp.StatusCode) 100 + } 101 + 102 + var res struct { 103 + UploadURL string `json:"uploadUrl"` 104 + AuthorizationToken string `json:"authorizationToken"` 105 + } 106 + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { 107 + return "", "", err 108 + } 109 + 110 + return res.UploadURL, res.AuthorizationToken, nil 111 + } 112 + 113 + func (c *B2Client) UploadFile(fileName string, content []byte, contentType string) error { 114 + 115 + c.mu.Lock() 116 + apiURL := c.APIURL 117 + c.mu.Unlock() 118 + 119 + if apiURL == "" { 120 + if err := c.Authorize(); err != nil { 121 + return err 122 + } 123 + } 124 + 125 + uploadURL, uploadToken, err := c.GetUploadURL() 126 + if err != nil { 127 + 128 + if err := c.Authorize(); err != nil { 129 + return err 130 + } 131 + uploadURL, uploadToken, err = c.GetUploadURL() 132 + if err != nil { 133 + return err 134 + } 135 + } 136 + 137 + hash := sha1.Sum(content) 138 + sha1Str := hex.EncodeToString(hash[:]) 139 + 140 + encodedName := url.PathEscape(fileName) 141 + 142 + req, _ := http.NewRequest("POST", uploadURL, bytes.NewBuffer(content)) 143 + req.Header.Set("Authorization", uploadToken) 144 + req.Header.Set("X-Bz-File-Name", encodedName) 145 + req.Header.Set("Content-Type", contentType) 146 + req.Header.Set("X-Bz-Content-Sha1", sha1Str) 147 + 148 + resp, err := c.Client.Do(req) 149 + if err != nil { 150 + return err 151 + } 152 + defer resp.Body.Close() 153 + 154 + if resp.StatusCode != 200 { 155 + body, _ := io.ReadAll(resp.Body) 156 + return fmt.Errorf("upload_file failed: %d %s", resp.StatusCode, string(body)) 157 + } 158 + 159 + return nil 160 + } 161 + 162 + func (c *B2Client) ListFileNames(prefix string, startFileName string, maxFileCount int) ([]string, string, error) { 163 + c.mu.Lock() 164 + apiURL := c.APIURL 165 + authToken := c.AuthToken 166 + c.mu.Unlock() 167 + 168 + if apiURL == "" { 169 + if err := c.Authorize(); err != nil { 170 + return nil, "", err 171 + } 172 + c.mu.Lock() 173 + apiURL = c.APIURL 174 + authToken = c.AuthToken 175 + c.mu.Unlock() 176 + } 177 + 178 + bodyMap := map[string]interface{}{ 179 + "bucketId": c.BucketID, 180 + "maxFileCount": maxFileCount, 181 + } 182 + if prefix != "" { 183 + bodyMap["prefix"] = prefix 184 + } 185 + if startFileName != "" { 186 + bodyMap["startFileName"] = startFileName 187 + } 188 + 189 + body, _ := json.Marshal(bodyMap) 190 + 191 + req, _ := http.NewRequest("POST", apiURL+"/b2api/v2/b2_list_file_names", bytes.NewBuffer(body)) 192 + req.Header.Set("Authorization", authToken) 193 + req.Header.Set("Content-Type", "application/json") 194 + 195 + resp, err := c.Client.Do(req) 196 + if err != nil { 197 + return nil, "", err 198 + } 199 + defer resp.Body.Close() 200 + 201 + if resp.StatusCode != 200 { 202 + return nil, "", fmt.Errorf("list_file_names failed: %d", resp.StatusCode) 203 + } 204 + 205 + var res struct { 206 + Files []struct { 207 + FileName string `json:"fileName"` 208 + FileID string `json:"fileId"` 209 + } `json:"files"` 210 + NextFileName *string `json:"nextFileName"` 211 + } 212 + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { 213 + return nil, "", err 214 + } 215 + 216 + var names []string 217 + for _, f := range res.Files { 218 + names = append(names, f.FileName) 219 + } 220 + 221 + next := "" 222 + if res.NextFileName != nil { 223 + next = *res.NextFileName 224 + } 225 + 226 + return names, next, nil 227 + } 228 + 229 + func (c *B2Client) DeleteFileVersion(fileName, fileID string) error { 230 + c.mu.Lock() 231 + apiURL := c.APIURL 232 + authToken := c.AuthToken 233 + c.mu.Unlock() 234 + 235 + if apiURL == "" { 236 + if err := c.Authorize(); err != nil { 237 + return err 238 + } 239 + c.mu.Lock() 240 + apiURL = c.APIURL 241 + authToken = c.AuthToken 242 + c.mu.Unlock() 243 + } 244 + 245 + body, _ := json.Marshal(map[string]string{ 246 + "fileName": fileName, 247 + "fileId": fileID, 248 + }) 249 + 250 + req, _ := http.NewRequest("POST", apiURL+"/b2api/v2/b2_delete_file_version", bytes.NewBuffer(body)) 251 + req.Header.Set("Authorization", authToken) 252 + req.Header.Set("Content-Type", "application/json") 253 + 254 + resp, err := c.Client.Do(req) 255 + if err != nil { 256 + return err 257 + } 258 + defer resp.Body.Close() 259 + 260 + if resp.StatusCode != 200 { 261 + return fmt.Errorf("delete_file_version failed: %d", resp.StatusCode) 262 + } 263 + 264 + return nil 265 + } 266 + 267 + func (c *B2Client) DeleteFilesWithPrefix(prefix string) error { 268 + for { 269 + 270 + c.mu.Lock() 271 + apiURL := c.APIURL 272 + authToken := c.AuthToken 273 + c.mu.Unlock() 274 + 275 + if apiURL == "" { 276 + if err := c.Authorize(); err != nil { 277 + return err 278 + } 279 + c.mu.Lock() 280 + apiURL = c.APIURL 281 + authToken = c.AuthToken 282 + c.mu.Unlock() 283 + } 284 + 285 + bodyMap := map[string]interface{}{ 286 + "bucketId": c.BucketID, 287 + "maxFileCount": 100, 288 + "prefix": prefix, 289 + } 290 + body, _ := json.Marshal(bodyMap) 291 + 292 + req, _ := http.NewRequest("POST", apiURL+"/b2api/v2/b2_list_file_names", bytes.NewBuffer(body)) 293 + req.Header.Set("Authorization", authToken) 294 + req.Header.Set("Content-Type", "application/json") 295 + 296 + resp, err := c.Client.Do(req) 297 + if err != nil { 298 + return err 299 + } 300 + defer resp.Body.Close() 301 + 302 + if resp.StatusCode != 200 { 303 + return fmt.Errorf("list_file_names failed: %d", resp.StatusCode) 304 + } 305 + 306 + var res struct { 307 + Files []struct { 308 + FileName string `json:"fileName"` 309 + FileID string `json:"fileId"` 310 + } `json:"files"` 311 + NextFileName *string `json:"nextFileName"` 312 + } 313 + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { 314 + return err 315 + } 316 + 317 + if len(res.Files) == 0 { 318 + break 319 + } 320 + 321 + for _, f := range res.Files { 322 + if err := c.DeleteFileVersion(f.FileName, f.FileID); err != nil { 323 + 324 + fmt.Printf("Failed to delete %s: %v\n", f.FileName, err) 325 + } 326 + } 327 + 328 + if res.NextFileName == nil { 329 + break 330 + } 331 + } 332 + return nil 333 + }
+168
backend-go/deploy/build.go
··· 1 + package deploy 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/exec" 8 + "path/filepath" 9 + ) 10 + 11 + func fileExists(path string) bool { 12 + _, err := os.Stat(path) 13 + return err == nil 14 + } 15 + 16 + type BuildSystem struct { 17 + RootDir string 18 + Env []string 19 + Logger func(string) 20 + } 21 + 22 + func (b *BuildSystem) DetectPackageManager() string { 23 + if fileExists(filepath.Join(b.RootDir, "bun.lockb")) || fileExists(filepath.Join(b.RootDir, "bun.lock")) { 24 + return "bun" 25 + } 26 + if fileExists(filepath.Join(b.RootDir, "pnpm-lock.yaml")) { 27 + return "pnpm" 28 + } 29 + if fileExists(filepath.Join(b.RootDir, "yarn.lock")) { 30 + return "yarn" 31 + } 32 + if fileExists(filepath.Join(b.RootDir, "package-lock.json")) { 33 + return "npm" 34 + } 35 + 36 + return "npm" 37 + } 38 + 39 + func (b *BuildSystem) InstallArgs(pm string) []string { 40 + 41 + switch pm { 42 + case "npm": 43 + if fileExists(filepath.Join(b.RootDir, "package-lock.json")) { 44 + return []string{"ci", "--include=dev"} 45 + } 46 + return []string{"install", "--include=dev"} 47 + case "yarn": 48 + if fileExists(filepath.Join(b.RootDir, "yarn.lock")) { 49 + return []string{"install", "--frozen-lockfile", "--production=false"} 50 + } 51 + return []string{"install", "--production=false"} 52 + case "pnpm": 53 + return []string{"install", "--frozen-lockfile", "--production=false"} 54 + case "bun": 55 + return []string{"install"} 56 + } 57 + return []string{"install"} 58 + } 59 + 60 + func (b *BuildSystem) BuildArgs(pm string) []string { 61 + switch pm { 62 + case "yarn", "pnpm": 63 + return []string{"build"} 64 + case "deno": 65 + return []string{"task", "build"} 66 + default: 67 + return []string{"run", "build"} 68 + } 69 + } 70 + 71 + func (b *BuildSystem) RunCommand(ctx context.Context, name string, args ...string) error { 72 + cmd := exec.CommandContext(ctx, name, args...) 73 + cmd.Dir = b.RootDir 74 + cmd.Env = append(os.Environ(), b.Env...) 75 + 76 + stdout, _ := cmd.StdoutPipe() 77 + stderr, _ := cmd.StderrPipe() 78 + 79 + if err := cmd.Start(); err != nil { 80 + return err 81 + } 82 + 83 + go func() { 84 + buf := make([]byte, 1024) 85 + for { 86 + n, err := stdout.Read(buf) 87 + if n > 0 && b.Logger != nil { 88 + b.Logger(string(buf[:n])) 89 + } 90 + if err != nil { 91 + break 92 + } 93 + } 94 + }() 95 + 96 + go func() { 97 + buf := make([]byte, 1024) 98 + for { 99 + n, err := stderr.Read(buf) 100 + if n > 0 && b.Logger != nil { 101 + b.Logger(string(buf[:n])) 102 + } 103 + if err != nil { 104 + break 105 + } 106 + } 107 + }() 108 + 109 + return cmd.Wait() 110 + } 111 + 112 + func (b *BuildSystem) Build(ctx context.Context, customCommand string) (string, error) { 113 + 114 + pm := b.DetectPackageManager() 115 + 116 + if fileExists(filepath.Join(b.RootDir, "package.json")) { 117 + installArgs := b.InstallArgs(pm) 118 + 119 + if b.Logger != nil { 120 + b.Logger(fmt.Sprintf("Installing dependencies with %s %v...\n", pm, installArgs)) 121 + } 122 + 123 + if err := b.RunCommand(ctx, pm, installArgs...); err != nil { 124 + return "", fmt.Errorf("install failed: %w", err) 125 + } 126 + } 127 + 128 + if customCommand != "" { 129 + if b.Logger != nil { 130 + b.Logger(fmt.Sprintf("Running custom build command: %s\n", customCommand)) 131 + } 132 + if err := b.RunCommand(ctx, "sh", "-c", customCommand); err != nil { 133 + return "", fmt.Errorf("build failed: %w", err) 134 + } 135 + } else if fileExists(filepath.Join(b.RootDir, "package.json")) { 136 + 137 + buildArgs := b.BuildArgs(pm) 138 + if b.Logger != nil { 139 + b.Logger(fmt.Sprintf("Building with %s %v...\n", pm, buildArgs)) 140 + } 141 + if err := b.RunCommand(ctx, pm, buildArgs...); err != nil { 142 + return "", fmt.Errorf("build failed: %w", err) 143 + } 144 + } 145 + 146 + return b.DetectOutputDirectory() 147 + } 148 + 149 + func (b *BuildSystem) DetectOutputDirectory() (string, error) { 150 + candidates := []string{"dist", "build", "public", ".svelte-kit/output", "out", "_site"} 151 + for _, c := range candidates { 152 + path := filepath.Join(b.RootDir, c) 153 + 154 + if fileExists(path) { 155 + 156 + return c, nil 157 + } 158 + } 159 + 160 + if fileExists(filepath.Join(b.RootDir, "index.html")) { 161 + if b.Logger != nil { 162 + b.Logger("No build directory detected, but index.html found. Using root directory.") 163 + } 164 + return ".", nil 165 + } 166 + 167 + return "", fmt.Errorf("could not detect build output directory") 168 + }
+310
backend-go/deploy/cloudflare.go
··· 1 + package deploy 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "net/url" 10 + "time" 11 + ) 12 + 13 + type CloudflareClient struct { 14 + AccountID string 15 + NamespaceID string 16 + APIToken string 17 + Client *http.Client 18 + } 19 + 20 + func NewCloudflareClient(accountID, namespaceID, token string) *CloudflareClient { 21 + return &CloudflareClient{ 22 + AccountID: accountID, 23 + NamespaceID: namespaceID, 24 + APIToken: token, 25 + Client: &http.Client{Timeout: 30 * time.Second}, 26 + } 27 + } 28 + 29 + func (c *CloudflareClient) Do(method, path string, body interface{}) (*http.Response, error) { 30 + var bodyReader io.Reader 31 + if body != nil { 32 + jsonBody, _ := json.Marshal(body) 33 + bodyReader = bytes.NewBuffer(jsonBody) 34 + } 35 + 36 + url := "https://api.cloudflare.com/client/v4" + path 37 + req, err := http.NewRequest(method, url, bodyReader) 38 + if err != nil { 39 + return nil, err 40 + } 41 + req.Header.Set("Authorization", "Bearer "+c.APIToken) 42 + req.Header.Set("Content-Type", "application/json") 43 + 44 + return c.Client.Do(req) 45 + } 46 + 47 + func (c *CloudflareClient) KVPut(key, value string) error { 48 + encodedKey := url.PathEscape(key) 49 + url := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/values/%s", 50 + c.AccountID, c.NamespaceID, encodedKey) 51 + 52 + fullURL := "https://api.cloudflare.com/client/v4" + url 53 + req, _ := http.NewRequest("PUT", fullURL, bytes.NewBuffer([]byte(value))) 54 + req.Header.Set("Authorization", "Bearer "+c.APIToken) 55 + req.Header.Set("Content-Type", "text/plain") 56 + 57 + resp, err := c.Client.Do(req) 58 + if err != nil { 59 + return err 60 + } 61 + defer resp.Body.Close() 62 + 63 + if resp.StatusCode != 200 { 64 + return fmt.Errorf("kv_put failed: %s", resp.Status) 65 + } 66 + return nil 67 + } 68 + 69 + func (c *CloudflareClient) KVGet(key string) (string, error) { 70 + encodedKey := url.PathEscape(key) 71 + url := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/values/%s", 72 + c.AccountID, c.NamespaceID, encodedKey) 73 + 74 + fullURL := "https://api.cloudflare.com/client/v4" + url 75 + req, _ := http.NewRequest("GET", fullURL, nil) 76 + req.Header.Set("Authorization", "Bearer "+c.APIToken) 77 + 78 + resp, err := c.Client.Do(req) 79 + if err != nil { 80 + return "", err 81 + } 82 + defer resp.Body.Close() 83 + 84 + if resp.StatusCode == 404 { 85 + return "", nil 86 + } 87 + if resp.StatusCode != 200 { 88 + return "", fmt.Errorf("kv_get failed: %s", resp.Status) 89 + } 90 + 91 + b, _ := io.ReadAll(resp.Body) 92 + return string(b), nil 93 + } 94 + 95 + func (c *CloudflareClient) KVDelete(key string) error { 96 + encodedKey := url.PathEscape(key) 97 + url := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/values/%s", 98 + c.AccountID, c.NamespaceID, encodedKey) 99 + 100 + fullURL := "https://api.cloudflare.com/client/v4" + url 101 + req, _ := http.NewRequest("DELETE", fullURL, nil) 102 + req.Header.Set("Authorization", "Bearer "+c.APIToken) 103 + 104 + resp, err := c.Client.Do(req) 105 + if err != nil { 106 + return err 107 + } 108 + defer resp.Body.Close() 109 + return nil 110 + } 111 + 112 + type Response struct { 113 + Result json.RawMessage `json:"result"` 114 + Success bool `json:"success"` 115 + Errors []struct { 116 + Message string `json:"message"` 117 + } `json:"errors"` 118 + } 119 + 120 + func (c *CloudflareClient) GetZoneID(domain string) (string, error) { 121 + resp, err := c.Do("GET", "/zones?name="+domain, nil) 122 + if err != nil { 123 + return "", err 124 + } 125 + defer resp.Body.Close() 126 + 127 + var res struct { 128 + Result []struct { 129 + ID string `json:"id"` 130 + Name string `json:"name"` 131 + } `json:"result"` 132 + } 133 + json.NewDecoder(resp.Body).Decode(&res) 134 + 135 + if len(res.Result) == 0 { 136 + return "", fmt.Errorf("zone not found") 137 + } 138 + return res.Result[0].ID, nil 139 + } 140 + 141 + func (c *CloudflareClient) CreateCustomHostname(zoneID, hostname string) (json.RawMessage, error) { 142 + body := map[string]interface{}{ 143 + "hostname": hostname, 144 + "ssl": map[string]interface{}{ 145 + "method": "http", 146 + "type": "dv", 147 + "settings": map[string]interface{}{ 148 + "min_tls_version": "1.2", 149 + "http2": "on", 150 + }, 151 + }, 152 + } 153 + 154 + resp, err := c.Do("POST", fmt.Sprintf("/zones/%s/custom_hostnames", zoneID), body) 155 + if err != nil { 156 + return nil, err 157 + } 158 + defer resp.Body.Close() 159 + 160 + var res Response 161 + json.NewDecoder(resp.Body).Decode(&res) 162 + 163 + if !res.Success { 164 + msg := "unknown" 165 + if len(res.Errors) > 0 { 166 + msg = res.Errors[0].Message 167 + } 168 + return nil, fmt.Errorf("create_custom_hostname failed: %s", msg) 169 + } 170 + return res.Result, nil 171 + } 172 + 173 + func (c *CloudflareClient) GetCustomHostname(zoneID, id string) (json.RawMessage, error) { 174 + resp, err := c.Do("GET", fmt.Sprintf("/zones/%s/custom_hostnames/%s", zoneID, id), nil) 175 + if err != nil { 176 + return nil, err 177 + } 178 + defer resp.Body.Close() 179 + 180 + var res Response 181 + json.NewDecoder(resp.Body).Decode(&res) 182 + return res.Result, nil 183 + } 184 + 185 + func (c *CloudflareClient) GetCustomHostnameIDByName(zoneID, hostname string) (string, error) { 186 + resp, err := c.Do("GET", fmt.Sprintf("/zones/%s/custom_hostnames?hostname=%s", zoneID, url.QueryEscape(hostname)), nil) 187 + if err != nil { 188 + return "", err 189 + } 190 + defer resp.Body.Close() 191 + 192 + var res struct { 193 + Result []struct { 194 + ID string `json:"id"` 195 + } `json:"result"` 196 + } 197 + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { 198 + return "", err 199 + } 200 + 201 + if len(res.Result) == 0 { 202 + return "", fmt.Errorf("hostname not found") 203 + } 204 + return res.Result[0].ID, nil 205 + } 206 + 207 + func (c *CloudflareClient) DeleteCustomHostname(zoneID, id string) error { 208 + resp, err := c.Do("DELETE", fmt.Sprintf("/zones/%s/custom_hostnames/%s", zoneID, id), nil) 209 + if err != nil { 210 + return err 211 + } 212 + defer resp.Body.Close() 213 + return nil 214 + } 215 + 216 + func (c *CloudflareClient) UpdateFallbackOrigin(zoneID, origin string) error { 217 + body := map[string]string{"origin": origin} 218 + resp, err := c.Do("PUT", fmt.Sprintf("/zones/%s/custom_hostnames/fallback_origin", zoneID), body) 219 + if err != nil { 220 + return err 221 + } 222 + defer resp.Body.Close() 223 + return nil 224 + } 225 + 226 + type DNSRecord struct { 227 + ID string `json:"id"` 228 + Type string `json:"type"` 229 + Name string `json:"name"` 230 + Content string `json:"content"` 231 + Proxied bool `json:"proxied"` 232 + } 233 + 234 + func (c *CloudflareClient) GetDNSRecords(zoneID, name string) ([]DNSRecord, error) { 235 + path := fmt.Sprintf("/zones/%s/dns_records", zoneID) 236 + if name != "" { 237 + path += "?name=" + url.QueryEscape(name) 238 + } 239 + resp, err := c.Do("GET", path, nil) 240 + if err != nil { 241 + return nil, err 242 + } 243 + defer resp.Body.Close() 244 + 245 + var res struct { 246 + Result []DNSRecord `json:"result"` 247 + } 248 + json.NewDecoder(resp.Body).Decode(&res) 249 + return res.Result, nil 250 + } 251 + 252 + func (c *CloudflareClient) CreateDNSRecord(zoneID string, record DNSRecord) error { 253 + path := fmt.Sprintf("/zones/%s/dns_records", zoneID) 254 + resp, err := c.Do("POST", path, record) 255 + if err != nil { 256 + return err 257 + } 258 + defer resp.Body.Close() 259 + if resp.StatusCode >= 400 { 260 + return fmt.Errorf("create_dns failed: %s", resp.Status) 261 + } 262 + return nil 263 + } 264 + 265 + func (c *CloudflareClient) UpdateDNSRecord(zoneID, recordID string, record DNSRecord) error { 266 + path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID) 267 + resp, err := c.Do("PATCH", path, record) 268 + if err != nil { 269 + return err 270 + } 271 + defer resp.Body.Close() 272 + if resp.StatusCode >= 400 { 273 + return fmt.Errorf("update_dns failed: %s", resp.Status) 274 + } 275 + return nil 276 + } 277 + 278 + func (c *CloudflareClient) EnsureRouting(subdomain, siteID, deployID string) error { 279 + if subdomain != "" { 280 + if err := c.KVPut("host:"+subdomain, siteID); err != nil { 281 + return err 282 + } 283 + } 284 + if deployID != "" { 285 + if err := c.KVPut("current:"+siteID, deployID); err != nil { 286 + return err 287 + } 288 + } 289 + return nil 290 + } 291 + 292 + func (c *CloudflareClient) RemoveRouting(subdomain, siteID, domain string) error { 293 + if domain != "" { 294 + 295 + if err := c.KVDelete("host:" + domain); err != nil { 296 + return err 297 + } 298 + } 299 + if subdomain != "" { 300 + if err := c.KVDelete("host:" + subdomain); err != nil { 301 + return err 302 + } 303 + } 304 + if siteID != "" { 305 + if err := c.KVDelete("current:" + siteID); err != nil { 306 + return err 307 + } 308 + } 309 + return nil 310 + }
+138
backend-go/deploy/encryption.go
··· 1 + package deploy 2 + 3 + import ( 4 + "crypto/aes" 5 + "crypto/cipher" 6 + "crypto/rand" 7 + "crypto/sha256" 8 + "crypto/sha512" 9 + "encoding/base64" 10 + "fmt" 11 + "io" 12 + "os" 13 + "strings" 14 + 15 + "golang.org/x/crypto/pbkdf2" 16 + ) 17 + 18 + const ( 19 + saltLength = 16 20 + keyLength = 32 21 + ) 22 + 23 + func getContentKey() []byte { 24 + secret := os.Getenv("ENV_ENCRYPTION_SECRET") 25 + if secret == "" { 26 + return nil 27 + } 28 + 29 + h := sha256.New() 30 + h.Write([]byte(secret + ":salt")) 31 + salt := h.Sum(nil)[:saltLength] 32 + 33 + return pbkdf2.Key([]byte(secret), salt, 100000, keyLength, sha512.New) 34 + } 35 + 36 + func Encrypt(plaintext string) (string, error) { 37 + if plaintext == "" { 38 + return "", nil 39 + } 40 + 41 + key := getContentKey() 42 + if key == nil { 43 + return plaintext, nil 44 + } 45 + 46 + block, err := aes.NewCipher(key) 47 + if err != nil { 48 + return "", err 49 + } 50 + 51 + gcm, err := cipher.NewGCM(block) 52 + if err != nil { 53 + return "", err 54 + } 55 + 56 + nonce := make([]byte, gcm.NonceSize()) 57 + 58 + gcm16, err := cipher.NewGCMWithNonceSize(block, 16) 59 + if err != nil { 60 + return "", err 61 + } 62 + 63 + nonce = make([]byte, 16) 64 + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 65 + return "", err 66 + } 67 + 68 + ciphertext := gcm16.Seal(nil, nonce, []byte(plaintext), nil) 69 + 70 + tagSize := gcm16.Overhead() 71 + if len(ciphertext) < tagSize { 72 + return "", fmt.Errorf("ciphertext too short") 73 + } 74 + 75 + realCiphertext := ciphertext[:len(ciphertext)-tagSize] 76 + authTag := ciphertext[len(ciphertext)-tagSize:] 77 + 78 + ivB64 := base64.StdEncoding.EncodeToString(nonce) 79 + authTagB64 := base64.StdEncoding.EncodeToString(authTag) 80 + encryptedB64 := base64.StdEncoding.EncodeToString(realCiphertext) 81 + 82 + return fmt.Sprintf("enc:v1:%s:%s:%s", ivB64, authTagB64, encryptedB64), nil 83 + } 84 + 85 + func Decrypt(ciphertext string) (string, error) { 86 + if ciphertext == "" { 87 + return "", nil 88 + } 89 + if !strings.HasPrefix(ciphertext, "enc:v1:") { 90 + return ciphertext, nil 91 + } 92 + 93 + key := getContentKey() 94 + if key == nil { 95 + return ciphertext, nil 96 + } 97 + 98 + parts := strings.Split(ciphertext, ":") 99 + if len(parts) != 5 { 100 + return "", fmt.Errorf("invalid encrypted format") 101 + } 102 + 103 + ivB64 := parts[2] 104 + authTagB64 := parts[3] 105 + encryptedB64 := parts[4] 106 + 107 + iv, err := base64.StdEncoding.DecodeString(ivB64) 108 + if err != nil { 109 + return "", err 110 + } 111 + authTag, err := base64.StdEncoding.DecodeString(authTagB64) 112 + if err != nil { 113 + return "", err 114 + } 115 + encrypted, err := base64.StdEncoding.DecodeString(encryptedB64) 116 + if err != nil { 117 + return "", err 118 + } 119 + 120 + block, err := aes.NewCipher(key) 121 + if err != nil { 122 + return "", err 123 + } 124 + 125 + gcm16, err := cipher.NewGCMWithNonceSize(block, 16) 126 + if err != nil { 127 + return "", err 128 + } 129 + 130 + fullCiphertext := append(encrypted, authTag...) 131 + 132 + plaintextBytes, err := gcm16.Open(nil, iv, fullCiphertext, nil) 133 + if err != nil { 134 + return "", err 135 + } 136 + 137 + return string(plaintextBytes), nil 138 + }
+571
backend-go/deploy/engine.go
··· 1 + package deploy 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "fmt" 8 + "io/ioutil" 9 + "log" 10 + "net/http" 11 + "os" 12 + "path/filepath" 13 + "strings" 14 + "sync" 15 + "time" 16 + 17 + "github.com/nrednav/cuid2" 18 + 19 + "boop-cat/db" 20 + "boop-cat/lib" 21 + ) 22 + 23 + type Engine struct { 24 + DB *sql.DB 25 + WorkDir string 26 + B2KeyID string 27 + B2AppKey string 28 + B2BucketID string 29 + CFToken string 30 + CFAccountID string 31 + CFNamespaceID string 32 + deploymentsMux sync.Mutex 33 + deployments map[string]context.CancelFunc 34 + } 35 + 36 + func NewEngine(database *sql.DB, b2KeyID, b2AppKey, b2BucketID, cfToken, cfAccount, cfNamespace string) *Engine { 37 + 38 + workDir := filepath.Join(os.TempDir(), "fsd-builds") 39 + os.MkdirAll(workDir, 0755) 40 + 41 + return &Engine{ 42 + DB: database, 43 + WorkDir: workDir, 44 + B2KeyID: b2KeyID, 45 + B2AppKey: b2AppKey, 46 + B2BucketID: b2BucketID, 47 + CFToken: cfToken, 48 + CFAccountID: cfAccount, 49 + CFNamespaceID: cfNamespace, 50 + deployments: make(map[string]context.CancelFunc), 51 + } 52 + } 53 + 54 + func (e *Engine) DeploySite(siteID, userID string, logStream chan<- string) (*db.Deployment, error) { 55 + 56 + var commitSha, commitMessage, commitAuthor, commitAvatar *string 57 + 58 + site, err := db.GetSiteByID(e.DB, userID, siteID) 59 + if err == nil && site.GitURL.Valid && strings.Contains(site.GitURL.String, "github.com") { 60 + 61 + ghToken, _ := db.GetGitHubToken(e.DB, userID) 62 + 63 + parts := strings.Split(strings.TrimPrefix(site.GitURL.String, "https://github.com/"), "/") 64 + if len(parts) >= 2 { 65 + owner := parts[0] 66 + repo := strings.TrimSuffix(parts[1], ".git") 67 + branch := "main" 68 + if site.GitBranch.Valid { 69 + branch = site.GitBranch.String 70 + } 71 + 72 + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", owner, repo, branch) 73 + req, _ := http.NewRequest("GET", apiURL, nil) 74 + if ghToken != "" { 75 + req.Header.Set("Authorization", "Bearer "+ghToken) 76 + } 77 + 78 + client := &http.Client{Timeout: 2 * time.Second} 79 + resp, err := client.Do(req) 80 + if err == nil { 81 + defer resp.Body.Close() 82 + if resp.StatusCode == 200 { 83 + var res struct { 84 + SHA string `json:"sha"` 85 + Commit struct { 86 + Message string `json:"message"` 87 + Author struct { 88 + Name string `json:"name"` 89 + } `json:"author"` 90 + } `json:"commit"` 91 + Author struct { 92 + AvatarURL string `json:"avatar_url"` 93 + } `json:"author"` 94 + Committer struct { 95 + AvatarURL string `json:"avatar_url"` 96 + } `json:"committer"` 97 + } 98 + 99 + if err := json.NewDecoder(resp.Body).Decode(&res); err == nil { 100 + 101 + sha := res.SHA 102 + commitSha = &sha 103 + 104 + msg := res.Commit.Message 105 + commitMessage = &msg 106 + 107 + auth := res.Commit.Author.Name 108 + commitAuthor = &auth 109 + 110 + if res.Author.AvatarURL != "" { 111 + av := res.Author.AvatarURL 112 + commitAvatar = &av 113 + } else if res.Committer.AvatarURL != "" { 114 + av := res.Committer.AvatarURL 115 + commitAvatar = &av 116 + } 117 + } 118 + } 119 + } 120 + } 121 + } 122 + 123 + deployID := cuid2.Generate() 124 + err = db.CreateDeployment(e.DB, deployID, userID, siteID, "building", commitSha, commitMessage, commitAuthor, commitAvatar) 125 + if err != nil { 126 + return nil, fmt.Errorf("failed to create deployment record: %w", err) 127 + } 128 + 129 + ctx, cancel := context.WithCancel(context.Background()) 130 + e.deploymentsMux.Lock() 131 + e.deployments[deployID] = cancel 132 + e.deploymentsMux.Unlock() 133 + 134 + go func() { 135 + defer func() { 136 + e.deploymentsMux.Lock() 137 + delete(e.deployments, deployID) 138 + e.deploymentsMux.Unlock() 139 + cancel() 140 + if logStream != nil { 141 + close(logStream) 142 + } 143 + }() 144 + 145 + logsDir := filepath.Join(e.WorkDir, "logs") 146 + os.MkdirAll(logsDir, 0755) 147 + logsPath := filepath.Join(logsDir, deployID+".log") 148 + logFile, _ := os.Create(logsPath) 149 + 150 + db.UpdateDeploymentLogs(e.DB, deployID, logsPath) 151 + 152 + logger := func(msg string) { 153 + log.Printf("[Deploy %s] %s", deployID, msg) 154 + if logFile != nil { 155 + logFile.WriteString(fmt.Sprintf("[%s] %s\n", time.Now().Format(time.RFC3339), msg)) 156 + } 157 + if logStream != nil { 158 + logStream <- msg 159 + } 160 + } 161 + 162 + err := e.runPipeline(ctx, siteID, userID, deployID, logger) 163 + if logFile != nil { 164 + logFile.Close() 165 + } 166 + if err != nil { 167 + logger(fmt.Sprintf("Deployment failed: %v", err)) 168 + if ctx.Err() == context.Canceled { 169 + db.UpdateDeploymentStatus(e.DB, deployID, "canceled", "") 170 + } else { 171 + db.UpdateDeploymentStatus(e.DB, deployID, "failed", "") 172 + } 173 + } else { 174 + logger("Deployment successful") 175 + } 176 + }() 177 + 178 + return db.GetDeploymentByID(e.DB, deployID) 179 + } 180 + 181 + func (e *Engine) CancelDeployment(deployID string) error { 182 + e.deploymentsMux.Lock() 183 + cancel, ok := e.deployments[deployID] 184 + e.deploymentsMux.Unlock() 185 + 186 + if !ok { 187 + return fmt.Errorf("deployment not found or not running") 188 + } 189 + 190 + cancel() 191 + return nil 192 + } 193 + 194 + func (e *Engine) runPipeline(ctx context.Context, siteID, userID, deployID string, logger func(string)) error { 195 + 196 + site, err := db.GetSiteByID(e.DB, userID, siteID) 197 + if err != nil { 198 + return err 199 + } 200 + 201 + logger(fmt.Sprintf("Starting deployment for site %s (%s)", site.Name, site.ID)) 202 + 203 + if ctx.Err() != nil { 204 + return ctx.Err() 205 + } 206 + 207 + buildDir := filepath.Join(e.WorkDir, deployID) 208 + 209 + logger("Cloning repository...") 210 + if !site.GitURL.Valid { 211 + return fmt.Errorf("site has no git url") 212 + } 213 + repoURL := site.GitURL.String 214 + 215 + ghToken, err := db.GetGitHubToken(e.DB, userID) 216 + if err == nil && ghToken != "" && strings.Contains(repoURL, "github.com") { 217 + 218 + if strings.HasPrefix(repoURL, "https://github.com/") { 219 + logger("Injecting GitHub authentication token...") 220 + 221 + repoURL = strings.Replace(repoURL, "https://github.com/", fmt.Sprintf("https://oauth2:%s@github.com/", ghToken), 1) 222 + } 223 + } else if err != nil { 224 + logger(fmt.Sprintf("Warning: Failed to check for GitHub token: %v", err)) 225 + } else if ghToken == "" { 226 + logger("No GitHub token found for user. Private repos may fail.") 227 + } 228 + 229 + branch := "main" 230 + if site.GitBranch.Valid { 231 + branch = site.GitBranch.String 232 + } 233 + 234 + err = GitClone(ctx, repoURL, branch, buildDir, 1, logger) 235 + if err != nil { 236 + return fmt.Errorf("git clone failed: %w", err) 237 + } 238 + 239 + if ctx.Err() != nil { 240 + return ctx.Err() 241 + } 242 + head, err := GitCurrentHead(buildDir) 243 + if err == nil { 244 + 245 + avatarURL := "" 246 + if strings.Contains(site.GitURL.String, "github.com") { 247 + 248 + parts := strings.Split(strings.TrimPrefix(site.GitURL.String, "https://github.com/"), "/") 249 + if len(parts) >= 2 { 250 + owner := parts[0] 251 + repo := strings.TrimSuffix(parts[1], ".git") 252 + 253 + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", owner, repo, head.SHA) 254 + req, _ := http.NewRequest("GET", apiURL, nil) 255 + if ghToken != "" { 256 + req.Header.Set("Authorization", "Bearer "+ghToken) 257 + } 258 + 259 + client := &http.Client{Timeout: 5 * time.Second} 260 + resp, err := client.Do(req) 261 + if err == nil { 262 + defer resp.Body.Close() 263 + var res struct { 264 + Author struct { 265 + AvatarURL string `json:"avatar_url"` 266 + } `json:"author"` 267 + Committer struct { 268 + AvatarURL string `json:"avatar_url"` 269 + } `json:"committer"` 270 + } 271 + if err := json.NewDecoder(resp.Body).Decode(&res); err == nil { 272 + if res.Author.AvatarURL != "" { 273 + avatarURL = res.Author.AvatarURL 274 + } else if res.Committer.AvatarURL != "" { 275 + avatarURL = res.Committer.AvatarURL 276 + } 277 + } 278 + } 279 + } 280 + } 281 + 282 + e.DB.Exec(`UPDATE deployments SET commitSha=?, commitMessage=?, commitAuthor=?, commitAvatar=? WHERE id=?`, 283 + head.SHA, head.Message, head.Author, avatarURL, deployID) 284 + } 285 + 286 + logger("Building project...") 287 + 288 + envVars := []string{} 289 + if site.EnvText.Valid && site.EnvText.String != "" { 290 + decryptedEnv := lib.Decrypt(site.EnvText.String) 291 + envVars = parseEnvText(decryptedEnv) 292 + } 293 + 294 + bs := &BuildSystem{ 295 + RootDir: buildDir, 296 + Env: envVars, 297 + Logger: logger, 298 + } 299 + 300 + customBuildCmd := "" 301 + if site.BuildCommand.Valid { 302 + customBuildCmd = site.BuildCommand.String 303 + } 304 + 305 + outputDirName, err := bs.Build(ctx, customBuildCmd) 306 + if err != nil { 307 + return fmt.Errorf("build failed: %w", err) 308 + } 309 + 310 + fullOutputDir := filepath.Join(buildDir, outputDirName) 311 + if !fileExists(fullOutputDir) { 312 + return fmt.Errorf("output directory %s not found", outputDirName) 313 + } 314 + 315 + logger("Build complete. Starting upload...") 316 + db.UpdateDeploymentStatus(e.DB, deployID, "running", "") 317 + 318 + logger("Uploading to storage...") 319 + b2 := NewB2Client(e.B2KeyID, e.B2AppKey, e.B2BucketID) 320 + 321 + prefix := fmt.Sprintf("sites/%s/%s", siteID, deployID) 322 + 323 + files, _ := ListFilesRecursive(fullOutputDir) 324 + logger(fmt.Sprintf("Found %d files to upload", len(files))) 325 + 326 + const maxConcurrency = 20 327 + semaphore := make(chan struct{}, maxConcurrency) 328 + var wg sync.WaitGroup 329 + var uploadErr error 330 + var errMutex sync.Mutex 331 + 332 + for _, fPath := range files { 333 + if ctx.Err() != nil { 334 + break 335 + } 336 + 337 + wg.Add(1) 338 + semaphore <- struct{}{} 339 + 340 + go func(path string) { 341 + defer wg.Done() 342 + defer func() { <-semaphore }() 343 + 344 + errMutex.Lock() 345 + if uploadErr != nil { 346 + errMutex.Unlock() 347 + return 348 + } 349 + errMutex.Unlock() 350 + 351 + relPath, _ := filepath.Rel(fullOutputDir, path) 352 + key := fmt.Sprintf("%s/%s", prefix, relPath) 353 + key = filepath.ToSlash(key) 354 + 355 + content, err := ioutil.ReadFile(path) 356 + if err != nil { 357 + errMutex.Lock() 358 + if uploadErr == nil { 359 + uploadErr = fmt.Errorf("read failed for %s: %w", relPath, err) 360 + } 361 + errMutex.Unlock() 362 + return 363 + } 364 + 365 + contentType := "application/octet-stream" 366 + if strings.HasSuffix(key, ".html") { 367 + contentType = "text/html" 368 + } 369 + if strings.HasSuffix(key, ".css") { 370 + contentType = "text/css" 371 + } 372 + if strings.HasSuffix(key, ".js") { 373 + contentType = "application/javascript" 374 + } 375 + if strings.HasSuffix(key, ".json") { 376 + contentType = "application/json" 377 + } 378 + if strings.HasSuffix(key, ".png") { 379 + contentType = "image/png" 380 + } 381 + if strings.HasSuffix(key, ".jpg") { 382 + contentType = "image/jpeg" 383 + } 384 + if strings.HasSuffix(key, ".svg") { 385 + contentType = "image/svg+xml" 386 + } 387 + 388 + err = b2.UploadFile(key, content, contentType) 389 + if err != nil { 390 + errMutex.Lock() 391 + if uploadErr == nil { 392 + uploadErr = fmt.Errorf("upload failed for %s: %w", relPath, err) 393 + } 394 + errMutex.Unlock() 395 + } 396 + }(fPath) 397 + } 398 + 399 + wg.Wait() 400 + 401 + if uploadErr != nil { 402 + return uploadErr 403 + } 404 + if ctx.Err() != nil { 405 + return ctx.Err() 406 + } 407 + logger("Upload complete") 408 + 409 + logger("Updating routing...") 410 + cf := NewCloudflareClient(e.CFAccountID, e.CFNamespaceID, e.CFToken) 411 + rootDomain := os.Getenv("FSD_EDGE_ROOT_DOMAIN") 412 + 413 + if site.Domain != "" { 414 + 415 + routingKey := site.Domain 416 + 417 + if rootDomain != "" && strings.HasSuffix(site.Domain, "."+rootDomain) { 418 + routingKey = strings.TrimSuffix(site.Domain, "."+rootDomain) 419 + } else if rootDomain != "" && site.Domain == rootDomain { 420 + 421 + routingKey = "@" 422 + } 423 + 424 + err = cf.EnsureRouting(routingKey, siteID, deployID) 425 + if err != nil { 426 + return fmt.Errorf("routing update failed: %w", err) 427 + } 428 + } 429 + 430 + customDomains, _ := db.ListCustomDomains(e.DB, siteID) 431 + for _, cd := range customDomains { 432 + 433 + hostname := strings.ToLower(strings.TrimSpace(cd.Hostname)) 434 + hostname = strings.TrimPrefix(hostname, "http://") 435 + hostname = strings.TrimPrefix(hostname, "https://") 436 + 437 + logger(fmt.Sprintf("Updating routing for custom domain: %s", hostname)) 438 + err = cf.EnsureRouting(hostname, siteID, deployID) 439 + if err != nil { 440 + logger(fmt.Sprintf("Failed to update routing for %s: %v", hostname, err)) 441 + } 442 + } 443 + 444 + if site.Domain == "" && len(customDomains) == 0 { 445 + 446 + err = cf.EnsureRouting("", siteID, deployID) 447 + if err != nil { 448 + return fmt.Errorf("routing update failed: %w", err) 449 + } 450 + } 451 + 452 + if rootDomain == "" { 453 + rootDomain = os.Getenv("FSD_EDGE_ROOT_DOMAIN") 454 + } 455 + if rootDomain == "" { 456 + rootDomain = "boop.cat" 457 + } 458 + 459 + finalURL := "" 460 + 461 + if len(customDomains) > 0 { 462 + for _, cd := range customDomains { 463 + 464 + if cd.Status == "active" || cd.Status == "live" { 465 + finalURL = fmt.Sprintf("https://%s", cd.Hostname) 466 + break 467 + } 468 + } 469 + 470 + if finalURL == "" && len(customDomains) > 0 { 471 + finalURL = fmt.Sprintf("https://%s", customDomains[0].Hostname) 472 + } 473 + } 474 + 475 + if finalURL == "" && site.Domain != "" { 476 + if strings.HasSuffix(site.Domain, "."+rootDomain) || site.Domain == rootDomain { 477 + finalURL = fmt.Sprintf("https://%s", site.Domain) 478 + } else { 479 + finalURL = fmt.Sprintf("https://%s.%s", site.Domain, rootDomain) 480 + } 481 + } 482 + 483 + db.UpdateDeploymentStatus(e.DB, deployID, "running", finalURL) 484 + db.UpdateSiteCurrentDeployment(e.DB, siteID, deployID) 485 + 486 + if err := db.StopOtherDeployments(e.DB, siteID, deployID); err != nil { 487 + logger(fmt.Sprintf("Warning: Failed to stop other deployments: %v", err)) 488 + } 489 + 490 + logger("Deployment successful!") 491 + return nil 492 + } 493 + 494 + func ListFilesRecursive(root string) ([]string, error) { 495 + var files []string 496 + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 497 + if err != nil { 498 + return err 499 + } 500 + 501 + if info.IsDir() { 502 + if info.Name() == ".git" || info.Name() == "node_modules" { 503 + return filepath.SkipDir 504 + } 505 + } 506 + if !info.IsDir() { 507 + 508 + if !strings.Contains(path, "/.git/") && !strings.Contains(path, "\\.git\\") { 509 + files = append(files, path) 510 + } 511 + } 512 + return nil 513 + }) 514 + return files, err 515 + } 516 + 517 + func parseEnvText(envText string) []string { 518 + var result []string 519 + lines := strings.Split(envText, "\n") 520 + for _, line := range lines { 521 + line = strings.TrimSpace(line) 522 + 523 + if line == "" || strings.HasPrefix(line, "#") { 524 + continue 525 + } 526 + 527 + if idx := strings.Index(line, "="); idx > 0 { 528 + key := strings.TrimSpace(line[:idx]) 529 + value := strings.TrimSpace(line[idx+1:]) 530 + 531 + if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) { 532 + value = value[1 : len(value)-1] 533 + } 534 + result = append(result, fmt.Sprintf("%s=%s", key, value)) 535 + } 536 + } 537 + return result 538 + } 539 + 540 + func (e *Engine) CleanupSite(siteID string, userID string) error { 541 + 542 + cf := NewCloudflareClient(e.CFAccountID, e.CFNamespaceID, e.CFToken) 543 + 544 + site, err := db.GetSiteByID(e.DB, userID, siteID) 545 + if err == nil && site != nil { 546 + 547 + rootDomain := os.Getenv("FSD_EDGE_ROOT_DOMAIN") 548 + routingKey := site.Domain 549 + if rootDomain != "" && strings.HasSuffix(site.Domain, "."+rootDomain) { 550 + routingKey = strings.TrimSuffix(site.Domain, "."+rootDomain) 551 + } 552 + cf.RemoveRouting(routingKey, site.ID, site.Domain) 553 + } 554 + 555 + customDomains, _ := db.ListCustomDomains(e.DB, siteID) 556 + for _, cd := range customDomains { 557 + cf.RemoveRouting("", siteID, cd.Hostname) 558 + 559 + } 560 + 561 + b2 := NewB2Client(e.B2KeyID, e.B2AppKey, e.B2BucketID) 562 + 563 + prefix := fmt.Sprintf("sites/%s/", siteID) 564 + 565 + err = b2.DeleteFilesWithPrefix(prefix) 566 + if err != nil { 567 + return fmt.Errorf("failed to delete files: %w", err) 568 + } 569 + 570 + return nil 571 + }
+123
backend-go/deploy/git.go
··· 1 + package deploy 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/exec" 8 + "path/filepath" 9 + "strings" 10 + ) 11 + 12 + func ensureDir(path string) error { 13 + return os.MkdirAll(path, 0755) 14 + } 15 + 16 + func GitClone(ctx context.Context, repoURL, branch, targetDir string, depth int, logger func(string)) error { 17 + if err := os.RemoveAll(targetDir); err != nil { 18 + return fmt.Errorf("failed to clear target dir: %w", err) 19 + } 20 + if err := ensureDir(filepath.Dir(targetDir)); err != nil { 21 + return fmt.Errorf("failed to create parent dir: %w", err) 22 + } 23 + 24 + args := []string{"clone", "--no-tags", "--depth", fmt.Sprintf("%d", depth)} 25 + if branch != "" { 26 + args = append(args, "--branch", branch) 27 + } 28 + args = append(args, repoURL, targetDir) 29 + 30 + cmd := exec.CommandContext(ctx, "git", args...) 31 + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") 32 + 33 + stdout, _ := cmd.StdoutPipe() 34 + stderr, _ := cmd.StderrPipe() 35 + 36 + if err := cmd.Start(); err != nil { 37 + return err 38 + } 39 + 40 + sanitize := func(s string) string { 41 + 42 + if strings.Contains(s, "@github.com") { 43 + parts := strings.Split(s, "@github.com") 44 + if len(parts) > 1 { 45 + 46 + lastSpace := strings.LastIndex(parts[0], " ") 47 + if lastSpace != -1 { 48 + return parts[0][:lastSpace+1] + "***" + "@github.com" + parts[1] 49 + } 50 + 51 + lastSlash := strings.LastIndex(parts[0], "//") 52 + if lastSlash != -1 { 53 + return parts[0][:lastSlash+2] + "***" + "@github.com" + parts[1] 54 + } 55 + } 56 + } 57 + return s 58 + } 59 + 60 + go func() { 61 + buf := make([]byte, 1024) 62 + for { 63 + n, err := stdout.Read(buf) 64 + if n > 0 && logger != nil { 65 + logger(sanitize(string(buf[:n]))) 66 + } 67 + if err != nil { 68 + break 69 + } 70 + } 71 + }() 72 + 73 + go func() { 74 + buf := make([]byte, 1024) 75 + for { 76 + n, err := stderr.Read(buf) 77 + if n > 0 && logger != nil { 78 + logger(sanitize(string(buf[:n]))) 79 + } 80 + if err != nil { 81 + break 82 + } 83 + } 84 + }() 85 + 86 + return cmd.Wait() 87 + } 88 + 89 + func GitCheckout(targetDir, ref string) error { 90 + cmd := exec.Command("git", "checkout", ref) 91 + cmd.Dir = targetDir 92 + return cmd.Run() 93 + } 94 + 95 + type CommitInfo struct { 96 + SHA string 97 + Message string 98 + Author string 99 + } 100 + 101 + func GitCurrentHead(targetDir string) (*CommitInfo, error) { 102 + 103 + shaCmd := exec.Command("git", "rev-parse", "HEAD") 104 + shaCmd.Dir = targetDir 105 + shaOut, err := shaCmd.Output() 106 + if err != nil { 107 + return nil, err 108 + } 109 + 110 + msgCmd := exec.Command("git", "log", "-1", "--pretty=%s") 111 + msgCmd.Dir = targetDir 112 + msgOut, _ := msgCmd.Output() 113 + 114 + authCmd := exec.Command("git", "log", "-1", "--pretty=%an <%ae>") 115 + authCmd.Dir = targetDir 116 + authOut, _ := authCmd.Output() 117 + 118 + return &CommitInfo{ 119 + SHA: strings.TrimSpace(string(shaOut)), 120 + Message: strings.TrimSpace(string(msgOut)), 121 + Author: strings.TrimSpace(string(authOut)), 122 + }, nil 123 + }
+71
backend-go/deploy/preview.go
··· 1 + package deploy 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "os/exec" 7 + "path/filepath" 8 + "strings" 9 + 10 + "github.com/nrednav/cuid2" 11 + ) 12 + 13 + type PreviewResult struct { 14 + Name string `json:"name"` 15 + Description string `json:"description"` 16 + DefaultBranch string `json:"defaultBranch"` 17 + RootFiles []string `json:"rootFiles"` 18 + Subdirs []string `json:"subdirs"` 19 + IsPrivate bool `json:"isPrivate"` 20 + } 21 + 22 + func (e *Engine) PreviewGitRepo(gitURL string) (*PreviewResult, error) { 23 + 24 + cmd := exec.Command("git", "ls-remote", gitURL, "HEAD") 25 + 26 + if err := cmd.Run(); err != nil { 27 + return nil, fmt.Errorf("GIT_CLONE_FAILED: Repo not found or private") 28 + } 29 + 30 + tmpDir := filepath.Join(os.TempDir(), "fsd-preview-"+cuid2.Generate()) 31 + defer os.RemoveAll(tmpDir) 32 + 33 + cloneCmd := exec.Command("git", "clone", "--depth", "1", gitURL, tmpDir) 34 + if out, err := cloneCmd.CombinedOutput(); err != nil { 35 + return nil, fmt.Errorf("GIT_CLONE_FAILED: %v - %s", err, string(out)) 36 + } 37 + 38 + entries, err := os.ReadDir(tmpDir) 39 + if err != nil { 40 + return nil, err 41 + } 42 + 43 + var rootFiles []string 44 + var subdirs []string 45 + 46 + for _, ent := range entries { 47 + name := ent.Name() 48 + if name == ".git" { 49 + continue 50 + } 51 + if ent.IsDir() { 52 + subdirs = append(subdirs, name) 53 + } else { 54 + rootFiles = append(rootFiles, name) 55 + } 56 + } 57 + 58 + parts := strings.Split(strings.TrimSuffix(gitURL, ".git"), "/") 59 + name := "" 60 + if len(parts) > 0 { 61 + name = parts[len(parts)-1] 62 + } 63 + 64 + return &PreviewResult{ 65 + Name: name, 66 + DefaultBranch: "main", 67 + RootFiles: rootFiles, 68 + Subdirs: subdirs, 69 + IsPrivate: false, 70 + }, nil 71 + }
+28
backend-go/go.mod
··· 1 + module boop-cat 2 + 3 + go 1.23.0 4 + 5 + require ( 6 + github.com/emersion/go-imap v1.2.1 7 + github.com/emersion/go-message v0.18.2 8 + github.com/go-chi/chi/v5 v5.2.2 9 + github.com/go-chi/cors v1.2.1 10 + github.com/go-jose/go-jose/v3 v3.0.4 11 + github.com/gorilla/sessions v1.4.0 12 + github.com/joho/godotenv v1.5.1 13 + github.com/markbates/goth v1.82.0 14 + github.com/mattn/go-sqlite3 v1.14.24 15 + github.com/nrednav/cuid2 v1.1.0 16 + golang.org/x/crypto v0.39.0 17 + ) 18 + 19 + require ( 20 + cloud.google.com/go/compute/metadata v0.3.0 // indirect 21 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect 22 + github.com/gorilla/context v1.1.1 // indirect 23 + github.com/gorilla/mux v1.6.2 // indirect 24 + github.com/gorilla/securecookie v1.1.2 // indirect 25 + golang.org/x/oauth2 v0.27.0 // indirect 26 + golang.org/x/sys v0.33.0 // indirect 27 + golang.org/x/text v0.26.0 // indirect 28 + )
+96
backend-go/go.sum
··· 1 + cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= 2 + cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 3 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 + github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= 7 + github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= 8 + github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= 9 + github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= 10 + github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= 11 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= 12 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 13 + github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= 14 + github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= 15 + github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 16 + github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= 17 + github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 18 + github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 19 + github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 20 + github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 21 + github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 22 + github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 23 + github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 24 + github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 25 + github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 26 + github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= 27 + github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 28 + github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 29 + github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 30 + github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 31 + github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 32 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 33 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 34 + github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ= 35 + github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk= 36 + github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 37 + github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 38 + github.com/nrednav/cuid2 v1.1.0 h1:Y2P9Fo1Iz7lKuwcn+fS0mbxkNvEqoNLUtm0+moHCnYc= 39 + github.com/nrednav/cuid2 v1.1.0/go.mod h1:jBjkJAI+QLM4EUGvtwGDHC1cP1QQrRNfLo/A7qJFDhA= 40 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 41 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 43 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 44 + github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 45 + github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 46 + github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 47 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 48 + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 49 + golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 50 + golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 51 + golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 52 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 53 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 54 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 55 + golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 56 + golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 57 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 58 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 59 + golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 60 + golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 61 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 62 + golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 63 + golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 64 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 65 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 66 + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 + golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 72 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 73 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 74 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 75 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 76 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 77 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 78 + golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 79 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 80 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 81 + golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 82 + golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 83 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 84 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 85 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 86 + golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 87 + golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 88 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 89 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 90 + golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 91 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 92 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 93 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 94 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 96 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+202
backend-go/handlers/account.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "golang.org/x/crypto/bcrypt" 10 + 11 + "boop-cat/db" 12 + "boop-cat/middleware" 13 + ) 14 + 15 + type AccountHandler struct { 16 + DB *sql.DB 17 + } 18 + 19 + func NewAccountHandler(database *sql.DB) *AccountHandler { 20 + return &AccountHandler{DB: database} 21 + } 22 + 23 + func (h *AccountHandler) Routes() chi.Router { 24 + r := chi.NewRouter() 25 + r.Use(middleware.RequireLogin) 26 + 27 + r.Get("/linked-accounts", h.ListLinkedAccounts) 28 + r.Delete("/linked-accounts/{id}", h.UnlinkAccount) 29 + r.Post("/email", h.ChangeEmail) 30 + r.Post("/password", h.ChangePassword) 31 + 32 + return r 33 + } 34 + 35 + func (h *AccountHandler) ListLinkedAccounts(w http.ResponseWriter, r *http.Request) { 36 + userID := middleware.GetUserID(r.Context()) 37 + 38 + rows, err := h.DB.Query(` 39 + SELECT id, provider, displayName, createdAt 40 + FROM oauthAccounts WHERE userId = ? 41 + `, userID) 42 + if err != nil { 43 + jsonError(w, "db-error", http.StatusInternalServerError) 44 + return 45 + } 46 + defer rows.Close() 47 + 48 + var accounts []map[string]interface{} 49 + for rows.Next() { 50 + var id, provider string 51 + var displayName, createdAt sql.NullString 52 + if err := rows.Scan(&id, &provider, &displayName, &createdAt); err != nil { 53 + continue 54 + } 55 + acc := map[string]interface{}{ 56 + "id": id, 57 + "provider": provider, 58 + } 59 + if displayName.Valid { 60 + acc["displayName"] = displayName.String 61 + } 62 + if createdAt.Valid { 63 + acc["createdAt"] = createdAt.String 64 + } 65 + accounts = append(accounts, acc) 66 + } 67 + 68 + if accounts == nil { 69 + accounts = []map[string]interface{}{} 70 + } 71 + 72 + w.Header().Set("Content-Type", "application/json") 73 + json.NewEncoder(w).Encode(map[string]interface{}{ 74 + "accounts": accounts, 75 + }) 76 + } 77 + 78 + func (h *AccountHandler) UnlinkAccount(w http.ResponseWriter, r *http.Request) { 79 + userID := middleware.GetUserID(r.Context()) 80 + accountID := chi.URLParam(r, "id") 81 + 82 + user, err := db.GetUserByID(h.DB, userID) 83 + if err != nil { 84 + jsonError(w, "user-not-found", http.StatusNotFound) 85 + return 86 + } 87 + 88 + var count int 89 + h.DB.QueryRow(`SELECT COUNT(*) FROM oauthAccounts WHERE userId = ?`, userID).Scan(&count) 90 + 91 + hasPassword := user.PasswordHash.Valid && user.PasswordHash.String != "" 92 + 93 + if count <= 1 && !hasPassword { 94 + jsonError(w, "cannot-unlink-only-auth", http.StatusBadRequest) 95 + return 96 + } 97 + 98 + result, err := h.DB.Exec(`DELETE FROM oauthAccounts WHERE id = ? AND userId = ?`, accountID, userID) 99 + if err != nil { 100 + jsonError(w, "db-error", http.StatusInternalServerError) 101 + return 102 + } 103 + 104 + affected, _ := result.RowsAffected() 105 + if affected == 0 { 106 + jsonError(w, "account-not-found", http.StatusNotFound) 107 + return 108 + } 109 + 110 + w.Header().Set("Content-Type", "application/json") 111 + w.Write([]byte(`{"ok":true}`)) 112 + } 113 + 114 + func (h *AccountHandler) ChangeEmail(w http.ResponseWriter, r *http.Request) { 115 + userID := middleware.GetUserID(r.Context()) 116 + 117 + var req struct { 118 + NewEmail string `json:"newEmail"` 119 + CurrentPassword string `json:"currentPassword"` 120 + } 121 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 122 + jsonError(w, "invalid-json", http.StatusBadRequest) 123 + return 124 + } 125 + 126 + if req.NewEmail == "" { 127 + jsonError(w, "email-required", http.StatusBadRequest) 128 + return 129 + } 130 + 131 + user, err := db.GetUserByID(h.DB, userID) 132 + if err != nil { 133 + jsonError(w, "user-not-found", http.StatusNotFound) 134 + return 135 + } 136 + 137 + if !user.PasswordHash.Valid || !user.VerifyPassword(req.CurrentPassword) { 138 + jsonError(w, "invalid-password", http.StatusUnauthorized) 139 + return 140 + } 141 + 142 + existing, _ := db.GetUserByEmail(h.DB, req.NewEmail) 143 + if existing != nil && existing.ID != userID { 144 + jsonError(w, "email-already-registered", http.StatusConflict) 145 + return 146 + } 147 + 148 + _, err = h.DB.Exec(`UPDATE users SET email = ? WHERE id = ?`, req.NewEmail, userID) 149 + if err != nil { 150 + jsonError(w, "db-error", http.StatusInternalServerError) 151 + return 152 + } 153 + 154 + w.Header().Set("Content-Type", "application/json") 155 + w.Write([]byte(`{"ok":true}`)) 156 + } 157 + 158 + func (h *AccountHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { 159 + userID := middleware.GetUserID(r.Context()) 160 + 161 + var req struct { 162 + CurrentPassword string `json:"currentPassword"` 163 + NewPassword string `json:"newPassword"` 164 + } 165 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 166 + jsonError(w, "invalid-json", http.StatusBadRequest) 167 + return 168 + } 169 + 170 + if req.NewPassword == "" || len(req.NewPassword) < 8 { 171 + jsonError(w, "password-too-short", http.StatusBadRequest) 172 + return 173 + } 174 + 175 + user, err := db.GetUserByID(h.DB, userID) 176 + if err != nil { 177 + jsonError(w, "user-not-found", http.StatusNotFound) 178 + return 179 + } 180 + 181 + if user.PasswordHash.Valid && user.PasswordHash.String != "" { 182 + if !user.VerifyPassword(req.CurrentPassword) { 183 + jsonError(w, "invalid-password", http.StatusUnauthorized) 184 + return 185 + } 186 + } 187 + 188 + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) 189 + if err != nil { 190 + jsonError(w, "hash-failed", http.StatusInternalServerError) 191 + return 192 + } 193 + 194 + _, err = h.DB.Exec(`UPDATE users SET passwordHash = ? WHERE id = ?`, string(hash), userID) 195 + if err != nil { 196 + jsonError(w, "db-error", http.StatusInternalServerError) 197 + return 198 + } 199 + 200 + w.Header().Set("Content-Type", "application/json") 201 + w.Write([]byte(`{"ok":true}`)) 202 + }
+178
backend-go/handlers/admin.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "net/http" 7 + "os" 8 + 9 + "boop-cat/db" 10 + "github.com/go-chi/chi/v5" 11 + ) 12 + 13 + type AdminHandler struct { 14 + DB *sql.DB 15 + } 16 + 17 + func NewAdminHandler(database *sql.DB) *AdminHandler { 18 + return &AdminHandler{DB: database} 19 + } 20 + 21 + func (h *AdminHandler) RequireAdminKey(next http.Handler) http.Handler { 22 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 + key := r.Header.Get("x-admin-api-key") 24 + if key == "" || key != os.Getenv("ADMIN_API_KEY") { 25 + jsonError(w, "unauthorized", http.StatusUnauthorized) 26 + return 27 + } 28 + next.ServeHTTP(w, r) 29 + }) 30 + } 31 + 32 + func (h *AdminHandler) Routes() chi.Router { 33 + r := chi.NewRouter() 34 + r.Use(h.RequireAdminKey) 35 + 36 + r.Post("/poll-dmca", h.PollDMCA) 37 + r.Post("/ban", h.BanUser) 38 + r.Get("/lookup", h.LookupDomain) 39 + r.Get("/sites", h.ListSites) 40 + 41 + return r 42 + } 43 + 44 + type banRequest struct { 45 + UserID string `json:"userId"` 46 + Ban bool `json:"ban"` 47 + IP string `json:"ip,omitempty"` 48 + } 49 + 50 + func (h *AdminHandler) BanUser(w http.ResponseWriter, r *http.Request) { 51 + var req banRequest 52 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 53 + jsonError(w, "invalid-params", http.StatusBadRequest) 54 + return 55 + } 56 + if req.UserID == "" { 57 + jsonError(w, "invalid-params", http.StatusBadRequest) 58 + return 59 + } 60 + 61 + user, err := db.GetUserByID(h.DB, req.UserID) 62 + if err != nil { 63 + jsonError(w, "user-not-found", http.StatusNotFound) 64 + return 65 + } 66 + 67 + deletedSites := []map[string]interface{}{} 68 + 69 + if req.Ban { 70 + 71 + sites, _ := db.GetSitesByUserID(h.DB, user.ID) 72 + 73 + for _, s := range sites { 74 + 75 + if err := db.DeleteSite(h.DB, user.ID, s.ID); err == nil { 76 + deletedSites = append(deletedSites, map[string]interface{}{ 77 + "id": s.ID, 78 + "name": s.Name, 79 + }) 80 + } 81 + } 82 + 83 + if req.IP != "" { 84 + _ = db.BanIP(h.DB, req.IP, user.ID, "Banned with user "+user.Email) 85 + } 86 + } 87 + 88 + _ = db.SetUserBanned(h.DB, user.ID, req.Ban) 89 + 90 + user.Banned = req.Ban 91 + 92 + response := map[string]interface{}{ 93 + "ok": true, 94 + "user": map[string]interface{}{ 95 + "id": user.ID, 96 + "email": user.Email, 97 + "banned": user.Banned, 98 + }, 99 + "deletedSites": deletedSites, 100 + "bannedIP": nil, 101 + } 102 + if req.Ban && req.IP != "" { 103 + response["bannedIP"] = req.IP 104 + } 105 + 106 + w.Header().Set("Content-Type", "application/json") 107 + json.NewEncoder(w).Encode(response) 108 + } 109 + 110 + func (h *AdminHandler) LookupDomain(w http.ResponseWriter, r *http.Request) { 111 + domain := r.URL.Query().Get("domain") 112 + if domain == "" { 113 + jsonError(w, "missing-domain", http.StatusBadRequest) 114 + return 115 + } 116 + 117 + site, err := db.GetSiteByDomain(h.DB, domain) 118 + if err != nil { 119 + 120 + cd, err := db.GetCustomDomainByHostname(h.DB, domain) 121 + if err != nil || cd == nil { 122 + jsonError(w, "not-found", http.StatusNotFound) 123 + return 124 + } 125 + 126 + site, err = db.GetSiteByIDAdmin(h.DB, cd.SiteID) 127 + if err != nil { 128 + jsonError(w, "site-not-found", http.StatusNotFound) 129 + return 130 + } 131 + } 132 + 133 + user, _ := db.GetUserByID(h.DB, site.UserID) 134 + 135 + response := map[string]interface{}{ 136 + "ok": true, 137 + "site": map[string]interface{}{ 138 + "id": site.ID, 139 + "name": site.Name, 140 + "domain": site.Domain, 141 + }, 142 + "user": nil, 143 + } 144 + 145 + if user != nil { 146 + response["user"] = map[string]interface{}{ 147 + "id": user.ID, 148 + "email": user.Email, 149 + "username": user.Username, 150 + "banned": user.Banned, 151 + } 152 + } 153 + 154 + w.Header().Set("Content-Type", "application/json") 155 + json.NewEncoder(w).Encode(response) 156 + } 157 + 158 + func (h *AdminHandler) ListSites(w http.ResponseWriter, r *http.Request) { 159 + 160 + sites, err := db.GetAllSites(h.DB) 161 + if err != nil { 162 + jsonError(w, "db-error", http.StatusInternalServerError) 163 + return 164 + } 165 + 166 + w.Header().Set("Content-Type", "application/json") 167 + json.NewEncoder(w).Encode(map[string]interface{}{ 168 + "ok": true, 169 + "sites": sites, 170 + "total": len(sites), 171 + }) 172 + } 173 + 174 + func (h *AdminHandler) PollDMCA(w http.ResponseWriter, r *http.Request) { 175 + 176 + w.Header().Set("Content-Type", "application/json") 177 + w.Write([]byte(`{"ok":true,"message":"Polling initiated (stub)."}`)) 178 + }
+120
backend-go/handlers/api_keys.go
··· 1 + package handlers 2 + 3 + import ( 4 + "crypto/sha256" 5 + "database/sql" 6 + "encoding/hex" 7 + "encoding/json" 8 + "net/http" 9 + "time" 10 + 11 + "github.com/go-chi/chi/v5" 12 + "github.com/nrednav/cuid2" 13 + 14 + "boop-cat/db" 15 + "boop-cat/middleware" 16 + ) 17 + 18 + type APIKeysHandler struct { 19 + DB *sql.DB 20 + } 21 + 22 + func NewAPIKeysHandler(database *sql.DB) *APIKeysHandler { 23 + return &APIKeysHandler{DB: database} 24 + } 25 + 26 + func (h *APIKeysHandler) Routes() chi.Router { 27 + r := chi.NewRouter() 28 + r.Use(middleware.RequireLogin) 29 + 30 + r.Get("/", h.ListAPIKeys) 31 + r.Post("/", h.CreateAPIKey) 32 + r.Delete("/{id}", h.DeleteAPIKey) 33 + 34 + return r 35 + } 36 + 37 + func (h *APIKeysHandler) ListAPIKeys(w http.ResponseWriter, r *http.Request) { 38 + userID := middleware.GetUserID(r.Context()) 39 + if userID == "" { 40 + jsonError(w, "unauthorized", http.StatusUnauthorized) 41 + return 42 + } 43 + 44 + keys, err := db.ListAPIKeys(h.DB, userID) 45 + if err != nil { 46 + jsonError(w, "list-keys-failed", http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + w.Header().Set("Content-Type", "application/json") 51 + json.NewEncoder(w).Encode(keys) 52 + } 53 + 54 + func (h *APIKeysHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { 55 + userID := middleware.GetUserID(r.Context()) 56 + if userID == "" { 57 + jsonError(w, "unauthorized", http.StatusUnauthorized) 58 + return 59 + } 60 + 61 + var req struct { 62 + Name string `json:"name"` 63 + } 64 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 65 + jsonError(w, "invalid-json", http.StatusBadRequest) 66 + return 67 + } 68 + 69 + if req.Name == "" { 70 + jsonError(w, "name-required", http.StatusBadRequest) 71 + return 72 + } 73 + 74 + id := cuid2.Generate() 75 + rawKey := "sk_" + cuid2.Generate() + cuid2.Generate() 76 + prefix := rawKey[:6] 77 + 78 + hash := sha256.Sum256([]byte(rawKey)) 79 + keyHash := hex.EncodeToString(hash[:]) 80 + 81 + err := db.CreateAPIKey(h.DB, id, userID, req.Name, keyHash, prefix) 82 + if err != nil { 83 + jsonError(w, "create-key-failed", http.StatusInternalServerError) 84 + return 85 + } 86 + 87 + resp := map[string]interface{}{ 88 + "id": id, 89 + "name": req.Name, 90 + "keyPrefix": prefix, 91 + "key": rawKey, 92 + "createdAt": time.Now().UTC().Format(time.RFC3339), 93 + } 94 + 95 + w.Header().Set("Content-Type", "application/json") 96 + json.NewEncoder(w).Encode(resp) 97 + } 98 + 99 + func (h *APIKeysHandler) DeleteAPIKey(w http.ResponseWriter, r *http.Request) { 100 + userID := middleware.GetUserID(r.Context()) 101 + if userID == "" { 102 + jsonError(w, "unauthorized", http.StatusUnauthorized) 103 + return 104 + } 105 + 106 + keyID := chi.URLParam(r, "id") 107 + if keyID == "" { 108 + jsonError(w, "id-required", http.StatusBadRequest) 109 + return 110 + } 111 + 112 + err := db.DeleteAPIKey(h.DB, userID, keyID) 113 + if err != nil { 114 + jsonError(w, "delete-key-failed", http.StatusInternalServerError) 115 + return 116 + } 117 + 118 + w.Header().Set("Content-Type", "application/json") 119 + w.Write([]byte(`{"ok":true}`)) 120 + }
+146
backend-go/handlers/api_v1.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/go-chi/chi/v5" 10 + 11 + "boop-cat/db" 12 + "boop-cat/deploy" 13 + "boop-cat/middleware" 14 + ) 15 + 16 + type APIV1Handler struct { 17 + DB *sql.DB 18 + Engine *deploy.Engine 19 + } 20 + 21 + func NewAPIV1Handler(database *sql.DB, engine *deploy.Engine) *APIV1Handler { 22 + return &APIV1Handler{DB: database, Engine: engine} 23 + } 24 + 25 + func (h *APIV1Handler) Routes() chi.Router { 26 + r := chi.NewRouter() 27 + r.Use(middleware.RequireAPIKey(h.DB)) 28 + 29 + r.Get("/sites", h.ListSites) 30 + r.Get("/sites/{id}", h.GetSite) 31 + r.Post("/sites/{id}/deploy", h.TriggerDeploy) 32 + r.Get("/sites/{id}/deployments", h.ListDeployments) 33 + 34 + return r 35 + } 36 + 37 + func (h *APIV1Handler) ListSites(w http.ResponseWriter, r *http.Request) { 38 + userID := middleware.GetUserID(r.Context()) 39 + 40 + sites, err := db.ListSites(h.DB, userID) 41 + if err != nil { 42 + jsonError(w, "list-sites-failed", http.StatusInternalServerError) 43 + return 44 + } 45 + 46 + var resp []db.SiteResponse 47 + for _, s := range sites { 48 + resp = append(resp, s.ToResponse()) 49 + } 50 + 51 + w.Header().Set("Content-Type", "application/json") 52 + json.NewEncoder(w).Encode(map[string]interface{}{ 53 + "sites": resp, 54 + }) 55 + } 56 + 57 + func (h *APIV1Handler) GetSite(w http.ResponseWriter, r *http.Request) { 58 + userID := middleware.GetUserID(r.Context()) 59 + siteID := chi.URLParam(r, "id") 60 + 61 + site, err := db.GetSiteByID(h.DB, userID, siteID) 62 + if err != nil || site == nil { 63 + jsonError(w, "site-not-found", http.StatusNotFound) 64 + return 65 + } 66 + 67 + w.Header().Set("Content-Type", "application/json") 68 + json.NewEncoder(w).Encode(site.ToResponse()) 69 + } 70 + 71 + func (h *APIV1Handler) TriggerDeploy(w http.ResponseWriter, r *http.Request) { 72 + userID := middleware.GetUserID(r.Context()) 73 + siteID := chi.URLParam(r, "id") 74 + 75 + site, err := db.GetSiteByID(h.DB, userID, siteID) 76 + if err != nil || site == nil { 77 + jsonError(w, "site-not-found", http.StatusNotFound) 78 + return 79 + } 80 + 81 + wait := r.URL.Query().Get("wait") == "true" 82 + 83 + if wait { 84 + 85 + logStream := make(chan string, 10) 86 + 87 + w.Header().Set("Content-Type", "text/plain") 88 + w.Header().Set("X-Content-Type-Options", "nosniff") 89 + 90 + if flusher, ok := w.(http.Flusher); ok { 91 + flusher.Flush() 92 + } 93 + 94 + go func() { 95 + _, err := h.Engine.DeploySite(siteID, userID, logStream) 96 + if err != nil { 97 + logStream <- fmt.Sprintf("Error starting deployment: %v", err) 98 + close(logStream) 99 + } 100 + }() 101 + 102 + for msg := range logStream { 103 + fmt.Fprintf(w, "%s\n", msg) 104 + if flusher, ok := w.(http.Flusher); ok { 105 + flusher.Flush() 106 + } 107 + } 108 + return 109 + } 110 + 111 + d, err := h.Engine.DeploySite(siteID, userID, nil) 112 + if err != nil { 113 + jsonError(w, "deploy-failed: "+err.Error(), http.StatusInternalServerError) 114 + return 115 + } 116 + 117 + w.Header().Set("Content-Type", "application/json") 118 + json.NewEncoder(w).Encode(d.ToResponse()) 119 + } 120 + 121 + func (h *APIV1Handler) ListDeployments(w http.ResponseWriter, r *http.Request) { 122 + userID := middleware.GetUserID(r.Context()) 123 + siteID := chi.URLParam(r, "id") 124 + 125 + site, err := db.GetSiteByID(h.DB, userID, siteID) 126 + if err != nil || site == nil { 127 + jsonError(w, "site-not-found", http.StatusNotFound) 128 + return 129 + } 130 + 131 + deps, err := db.ListDeployments(h.DB, userID, siteID) 132 + if err != nil { 133 + jsonError(w, "list-deployments-failed", http.StatusInternalServerError) 134 + return 135 + } 136 + 137 + var resp []db.DeploymentResponse 138 + for _, d := range deps { 139 + resp = append(resp, d.ToResponse()) 140 + } 141 + 142 + w.Header().Set("Content-Type", "application/json") 143 + json.NewEncoder(w).Encode(map[string]interface{}{ 144 + "deployments": resp, 145 + }) 146 + }
+751
backend-go/handlers/atproto.go
··· 1 + package handlers 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/sha256" 6 + "crypto/x509" 7 + "database/sql" 8 + "encoding/base64" 9 + "encoding/json" 10 + "encoding/pem" 11 + "fmt" 12 + "io" 13 + "net/http" 14 + "net/url" 15 + "os" 16 + "strings" 17 + "sync" 18 + "time" 19 + 20 + "boop-cat/db" 21 + "boop-cat/lib" 22 + "boop-cat/middleware" 23 + 24 + "github.com/go-chi/chi/v5" 25 + "github.com/go-jose/go-jose/v3" 26 + "github.com/go-jose/go-jose/v3/jwt" 27 + ) 28 + 29 + type atprotoStateData struct { 30 + DID string 31 + CodeVerifier string 32 + PDS string 33 + LinkToUserID string 34 + CreatedAt time.Time 35 + } 36 + 37 + var ( 38 + atprotoStates = make(map[string]*atprotoStateData) 39 + atprotoStatesMux sync.RWMutex 40 + ) 41 + 42 + func storeATProtoState(state, did, verifier, pds, linkToUserID string) { 43 + atprotoStatesMux.Lock() 44 + defer atprotoStatesMux.Unlock() 45 + atprotoStates[state] = &atprotoStateData{ 46 + DID: did, 47 + CodeVerifier: verifier, 48 + PDS: pds, 49 + LinkToUserID: linkToUserID, 50 + CreatedAt: time.Now(), 51 + } 52 + } 53 + 54 + func getATProtoState(state string) *atprotoStateData { 55 + atprotoStatesMux.RLock() 56 + defer atprotoStatesMux.RUnlock() 57 + return atprotoStates[state] 58 + } 59 + 60 + func deleteATProtoState(state string) { 61 + atprotoStatesMux.Lock() 62 + defer atprotoStatesMux.Unlock() 63 + delete(atprotoStates, state) 64 + } 65 + 66 + type ATProtoHandler struct { 67 + DB *sql.DB 68 + } 69 + 70 + func NewATProtoHandler(database *sql.DB) *ATProtoHandler { 71 + return &ATProtoHandler{DB: database} 72 + } 73 + 74 + func (h *ATProtoHandler) Routes() chi.Router { 75 + r := chi.NewRouter() 76 + r.Get("/client-metadata.json", h.ServeClientMetadata) 77 + r.Get("/jwks.json", h.ServeJWKS) 78 + r.Get("/auth/atproto", h.BeginAuth) 79 + r.Get("/auth/atproto/callback", h.Callback) 80 + return r 81 + } 82 + 83 + func baseUrl(r *http.Request) string { 84 + 85 + publicURL := os.Getenv("PUBLIC_URL") 86 + if publicURL != "" { 87 + return publicURL 88 + } 89 + scheme := "http" 90 + if r.TLS != nil { 91 + scheme = "https" 92 + } 93 + 94 + if r.Host == "localhost" || r.Host == "127.0.0.1" { 95 + port := os.Getenv("PORT") 96 + if port == "" { 97 + port = "8788" 98 + } 99 + return fmt.Sprintf("%s://%s:%s", scheme, r.Host, port) 100 + } 101 + return fmt.Sprintf("%s://%s", scheme, r.Host) 102 + } 103 + 104 + func (h *ATProtoHandler) ServeClientMetadata(w http.ResponseWriter, r *http.Request) { 105 + b := baseUrl(r) 106 + clientID := os.Getenv("ATPROTO_CLIENT_ID") 107 + if clientID == "" { 108 + clientID = fmt.Sprintf("%s/client-metadata.json", b) 109 + } 110 + 111 + meta := map[string]interface{}{ 112 + "client_id": clientID, 113 + "client_name": os.Getenv("ATPROTO_CLIENT_NAME"), 114 + "client_uri": b, 115 + "logo_uri": os.Getenv("ATPROTO_LOGO_URI"), 116 + "tos_uri": os.Getenv("ATPROTO_TOS_URI"), 117 + "policy_uri": os.Getenv("ATPROTO_POLICY_URI"), 118 + "redirect_uris": []string{fmt.Sprintf("%s/auth/atproto/callback", b)}, 119 + "grant_types": []string{"authorization_code", "refresh_token"}, 120 + "scope": os.Getenv("ATPROTO_SCOPE"), 121 + "response_types": []string{"code"}, 122 + "application_type": "web", 123 + "token_endpoint_auth_method": "private_key_jwt", 124 + "token_endpoint_auth_signing_alg": "ES256", 125 + "dpop_bound_access_tokens": true, 126 + "jwks_uri": fmt.Sprintf("%s/jwks.json", b), 127 + } 128 + 129 + if meta["client_name"] == "" || meta["client_name"] == nil { 130 + meta["client_name"] = "boop.cat" 131 + } 132 + if meta["logo_uri"] == "" || meta["logo_uri"] == nil { 133 + meta["logo_uri"] = fmt.Sprintf("%s/public/logo.svg", b) 134 + } 135 + if meta["tos_uri"] == "" || meta["tos_uri"] == nil { 136 + meta["tos_uri"] = fmt.Sprintf("%s/tos", b) 137 + } 138 + if meta["policy_uri"] == "" || meta["policy_uri"] == nil { 139 + meta["policy_uri"] = fmt.Sprintf("%s/privacy", b) 140 + } 141 + if meta["scope"] == "" || meta["scope"] == nil { 142 + meta["scope"] = "atproto transition:generic" 143 + } 144 + 145 + w.Header().Set("Content-Type", "application/json") 146 + json.NewEncoder(w).Encode(meta) 147 + } 148 + 149 + func (h *ATProtoHandler) ServeJWKS(w http.ResponseWriter, r *http.Request) { 150 + keyPEM := os.Getenv("ATPROTO_PRIVATE_KEY_1") 151 + if keyPEM == "" { 152 + jsonError(w, "atproto-not-configured", http.StatusNotFound) 153 + return 154 + } 155 + 156 + block, _ := pem.Decode([]byte(keyPEM)) 157 + if block == nil { 158 + jsonError(w, "invalid-key-format", http.StatusInternalServerError) 159 + return 160 + } 161 + 162 + privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) 163 + if err != nil { 164 + 165 + privKey, err = x509.ParseECPrivateKey(block.Bytes) 166 + if err != nil { 167 + jsonError(w, "invalid-key-parse", http.StatusInternalServerError) 168 + return 169 + } 170 + } 171 + 172 + ecKey, ok := privKey.(*ecdsa.PrivateKey) 173 + if !ok { 174 + jsonError(w, "key-not-ec", http.StatusInternalServerError) 175 + return 176 + } 177 + 178 + jwk := jose.JSONWebKey{ 179 + Key: &ecKey.PublicKey, 180 + KeyID: "key1", 181 + Algorithm: "ES256", 182 + Use: "sig", 183 + } 184 + 185 + jwks := jose.JSONWebKeySet{ 186 + Keys: []jose.JSONWebKey{jwk}, 187 + } 188 + 189 + w.Header().Set("Content-Type", "application/json") 190 + json.NewEncoder(w).Encode(jwks) 191 + } 192 + 193 + type AuthMetadata struct { 194 + Issuer string `json:"issuer"` 195 + AuthEndpoint string `json:"authorization_endpoint"` 196 + TokenEndpoint string `json:"token_endpoint"` 197 + PushedAuthRequestEndpoint string `json:"pushed_authorization_request_endpoint"` 198 + DPoPSigningAlgs []string `json:"dpop_signing_alg_values_supported"` 199 + } 200 + 201 + func (h *ATProtoHandler) BeginAuth(w http.ResponseWriter, r *http.Request) { 202 + handle := r.URL.Query().Get("handle") 203 + if handle == "" { 204 + http.Redirect(w, r, "/login?error=missing_handle", http.StatusFound) 205 + return 206 + } 207 + handle = strings.TrimSpace(handle) 208 + 209 + did, err := resolveHandle(handle) 210 + if err != nil { 211 + fmt.Printf("Resolve handle error: %v\n", err) 212 + http.Redirect(w, r, "/login?error=handle_resolution_failed", http.StatusFound) 213 + return 214 + } 215 + 216 + pds, err := getPDSEndpoint(did) 217 + if err != nil { 218 + fmt.Printf("Get PDS error: %v\n", err) 219 + http.Redirect(w, r, "/login?error=pds_discovery_failed", http.StatusFound) 220 + return 221 + } 222 + 223 + authMeta, err := getAuthMetadata(pds) 224 + if err != nil { 225 + fmt.Printf("Get Auth Meta error: %v\n", err) 226 + http.Redirect(w, r, "/login?error=oauth_discovery_failed", http.StatusFound) 227 + return 228 + } 229 + 230 + pkce := lib.GeneratePKCE() 231 + state := lib.GenerateSecureState() 232 + 233 + linkToUserID := "" 234 + if user := middleware.GetUser(r.Context()); user != nil { 235 + linkToUserID = user.ID 236 + } 237 + 238 + storeATProtoState(state, did, pkce.Verifier, pds, linkToUserID) 239 + 240 + redirectURI := fmt.Sprintf("%s/auth/atproto/callback", baseUrl(r)) 241 + clientID := fmt.Sprintf("%s/client-metadata.json", baseUrl(r)) 242 + 243 + v := url.Values{} 244 + v.Set("client_id", clientID) 245 + v.Set("response_type", "code") 246 + v.Set("redirect_uri", redirectURI) 247 + scope := os.Getenv("ATPROTO_SCOPE") 248 + if scope == "" { 249 + scope = "atproto transition:generic" 250 + } 251 + v.Set("scope", scope) 252 + v.Set("state", state) 253 + v.Set("code_challenge", pkce.Challenge) 254 + v.Set("code_challenge_method", "S256") 255 + v.Set("login_hint", handle) 256 + 257 + assertion, err := makeClientAssertion(clientID, authMeta.Issuer) 258 + if err != nil { 259 + fmt.Printf("Assertion Error: %v\n", err) 260 + http.Redirect(w, r, "/login?error=assertion_failed", http.StatusFound) 261 + return 262 + } 263 + v.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 264 + v.Set("client_assertion", assertion) 265 + 266 + if authMeta.PushedAuthRequestEndpoint != "" { 267 + 268 + executePAR := func(nonce string) (*http.Response, error) { 269 + dpop, err := makeDPoPHeader("POST", authMeta.PushedAuthRequestEndpoint, nonce) 270 + if err != nil { 271 + return nil, err 272 + } 273 + req, _ := http.NewRequest("POST", authMeta.PushedAuthRequestEndpoint, strings.NewReader(v.Encode())) 274 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 275 + req.Header.Set("DPoP", dpop) 276 + 277 + client := &http.Client{Timeout: 10 * time.Second} 278 + return client.Do(req) 279 + } 280 + 281 + resp, err := executePAR("") 282 + if err != nil { 283 + fmt.Printf("PAR Request Error: %v\n", err) 284 + http.Redirect(w, r, "/login?error=par_request_failed", http.StatusFound) 285 + return 286 + } 287 + 288 + if resp.StatusCode == 400 { 289 + nonce := resp.Header.Get("DPoP-Nonce") 290 + if nonce != "" { 291 + resp.Body.Close() 292 + resp, err = executePAR(nonce) 293 + if err != nil { 294 + http.Redirect(w, r, "/login?error=par_retry_failed", http.StatusFound) 295 + return 296 + } 297 + } 298 + } 299 + defer resp.Body.Close() 300 + 301 + if resp.StatusCode != 201 && resp.StatusCode != 200 { 302 + b, _ := io.ReadAll(resp.Body) 303 + fmt.Printf("PAR Failed: %d %s\n", resp.StatusCode, string(b)) 304 + http.Redirect(w, r, "/login?error=par_failed", http.StatusFound) 305 + return 306 + } 307 + 308 + var parRes struct { 309 + RequestURI string `json:"request_uri"` 310 + ExpiresIn int `json:"expires_in"` 311 + } 312 + if err := json.NewDecoder(resp.Body).Decode(&parRes); err != nil { 313 + http.Redirect(w, r, "/login?error=par_decode_failed", http.StatusFound) 314 + return 315 + } 316 + 317 + dest := fmt.Sprintf("%s?client_id=%s&request_uri=%s", 318 + authMeta.AuthEndpoint, url.QueryEscape(clientID), url.QueryEscape(parRes.RequestURI)) 319 + http.Redirect(w, r, dest, http.StatusFound) 320 + return 321 + } 322 + 323 + dest := authMeta.AuthEndpoint + "?" + v.Encode() 324 + http.Redirect(w, r, dest, http.StatusFound) 325 + } 326 + 327 + func (h *ATProtoHandler) Callback(w http.ResponseWriter, r *http.Request) { 328 + code := r.URL.Query().Get("code") 329 + state := r.URL.Query().Get("state") 330 + if code == "" || state == "" { 331 + http.Redirect(w, r, "/login?error=missing_params", http.StatusFound) 332 + return 333 + } 334 + 335 + stateData := getATProtoState(state) 336 + if stateData == nil { 337 + http.Redirect(w, r, "/login?error=invalid_state", http.StatusFound) 338 + return 339 + } 340 + did := stateData.DID 341 + 342 + pds, err := getPDSEndpoint(did) 343 + if err != nil { 344 + fmt.Printf("Callback PDS error: %v\n", err) 345 + http.Redirect(w, r, "/login?error=pds_rediscovery_failed", http.StatusFound) 346 + return 347 + } 348 + meta, err := getAuthMetadata(pds) 349 + if err != nil { 350 + fmt.Printf("Callback Metadata error: %v\n", err) 351 + http.Redirect(w, r, "/login?error=meta_rediscovery_failed", http.StatusFound) 352 + return 353 + } 354 + 355 + redirectURI := fmt.Sprintf("%s/auth/atproto/callback", baseUrl(r)) 356 + clientID := fmt.Sprintf("%s/client-metadata.json", baseUrl(r)) 357 + assertion, err := makeClientAssertion(clientID, meta.Issuer) 358 + if err != nil { 359 + http.Redirect(w, r, "/login?error=assertion_generation_failed", http.StatusFound) 360 + return 361 + } 362 + 363 + v := url.Values{} 364 + v.Set("grant_type", "authorization_code") 365 + v.Set("code", code) 366 + v.Set("redirect_uri", redirectURI) 367 + v.Set("client_id", clientID) 368 + v.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 369 + v.Set("client_assertion", assertion) 370 + 371 + if stateData != nil && stateData.CodeVerifier != "" { 372 + v.Set("code_verifier", stateData.CodeVerifier) 373 + deleteATProtoState(state) 374 + } 375 + 376 + executeTokenReq := func(nonce string) (*http.Response, error) { 377 + dpop, err := makeDPoPHeader("POST", meta.TokenEndpoint, nonce) 378 + if err != nil { 379 + return nil, err 380 + } 381 + req, _ := http.NewRequest("POST", meta.TokenEndpoint, strings.NewReader(v.Encode())) 382 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 383 + req.Header.Set("DPoP", dpop) 384 + req.Header.Set("Accept", "application/json") 385 + 386 + client := &http.Client{Timeout: 10 * time.Second} 387 + return client.Do(req) 388 + } 389 + 390 + resp, err := executeTokenReq("") 391 + if err != nil { 392 + fmt.Printf("Token Req Error: %v\n", err) 393 + http.Redirect(w, r, "/login?error=token_request_failed", http.StatusFound) 394 + return 395 + } 396 + 397 + if resp.StatusCode == 400 { 398 + nonce := resp.Header.Get("DPoP-Nonce") 399 + if nonce != "" { 400 + resp.Body.Close() 401 + resp, err = executeTokenReq(nonce) 402 + if err != nil { 403 + http.Redirect(w, r, "/login?error=token_retry_failed", http.StatusFound) 404 + return 405 + } 406 + } 407 + } 408 + defer resp.Body.Close() 409 + 410 + if resp.StatusCode != 200 { 411 + b, _ := io.ReadAll(resp.Body) 412 + fmt.Printf("Token Req Failed: %d %s\n", resp.StatusCode, string(b)) 413 + http.Redirect(w, r, "/login?error=token_exchange_failed", http.StatusFound) 414 + return 415 + } 416 + 417 + var tokenRes struct { 418 + AccessToken string `json:"access_token"` 419 + TokenType string `json:"token_type"` 420 + Scope string `json:"scope"` 421 + Sub string `json:"sub"` 422 + } 423 + if err := json.NewDecoder(resp.Body).Decode(&tokenRes); err != nil { 424 + http.Redirect(w, r, "/login?error=token_decode_failed", http.StatusFound) 425 + return 426 + } 427 + 428 + prof, err := fetchPublicProfile(tokenRes.Sub) 429 + 430 + handle := "" 431 + avatar := "" 432 + if prof != nil { 433 + handle = prof.Handle 434 + avatar = prof.Avatar 435 + } 436 + 437 + email := "" 438 + 439 + lastNonce := resp.Header.Get("DPoP-Nonce") 440 + 441 + fetchedEmail, err := fetchAtprotoEmail(pds, tokenRes.AccessToken, lastNonce) 442 + if err == nil && fetchedEmail != "" { 443 + email = fetchedEmail 444 + } else if err != nil { 445 + fmt.Printf("Email fetch warning: %v\n", err) 446 + } 447 + 448 + linkToUserID := stateData.LinkToUserID 449 + 450 + res, err := db.FindOrCreateUserFromAtproto(h.DB, tokenRes.Sub, handle, email, avatar, linkToUserID) 451 + if err != nil { 452 + fmt.Printf("DB Error: %v\n", err) 453 + http.Redirect(w, r, "/login?error=db_error", http.StatusFound) 454 + return 455 + } 456 + 457 + if res.Error != "" { 458 + if res.Error == "oauth-account-already-linked" { 459 + http.Redirect(w, r, "/dashboard/account?error=already-linked", http.StatusFound) 460 + return 461 + } 462 + http.Redirect(w, r, "/login?error="+res.Error, http.StatusFound) 463 + return 464 + } 465 + 466 + if res.User == nil { 467 + http.Redirect(w, r, "/login?error=user_creation_failed", http.StatusFound) 468 + return 469 + } 470 + 471 + if res.Linked { 472 + http.Redirect(w, r, "/dashboard/account", http.StatusFound) 473 + return 474 + } 475 + 476 + if err := middleware.LoginUser(w, r, res.User.ID); err != nil { 477 + fmt.Printf("Session Error: %v\n", err) 478 + http.Redirect(w, r, "/login?error=session_error", http.StatusFound) 479 + return 480 + } 481 + 482 + fmt.Printf("Login Success for User: %s\n", res.User.ID) 483 + http.Redirect(w, r, "/dashboard", http.StatusFound) 484 + } 485 + 486 + type PublicProfile struct { 487 + DID string `json:"did"` 488 + Handle string `json:"handle"` 489 + Avatar string `json:"avatar"` 490 + } 491 + 492 + func fetchPublicProfile(did string) (*PublicProfile, error) { 493 + resp, err := http.Get(fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=%s", did)) 494 + if err != nil { 495 + return nil, err 496 + } 497 + defer resp.Body.Close() 498 + if resp.StatusCode != 200 { 499 + return nil, fmt.Errorf("profile fetch failed") 500 + } 501 + var p PublicProfile 502 + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { 503 + return nil, err 504 + } 505 + return &p, nil 506 + } 507 + 508 + func getPrivateKey() (*ecdsa.PrivateKey, error) { 509 + keyPEM := os.Getenv("ATPROTO_PRIVATE_KEY_1") 510 + block, _ := pem.Decode([]byte(keyPEM)) 511 + if block == nil { 512 + return nil, fmt.Errorf("invalid key") 513 + } 514 + privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) 515 + if err != nil { 516 + privKey, err = x509.ParseECPrivateKey(block.Bytes) 517 + if err != nil { 518 + return nil, err 519 + } 520 + } 521 + return privKey.(*ecdsa.PrivateKey), nil 522 + } 523 + 524 + func makeDPoPHeader(method, targetURL, nonce string) (string, error) { 525 + return makeDPoPHeaderWithAth(method, targetURL, nonce, "") 526 + } 527 + 528 + func makeDPoPHeaderWithAth(method, targetURL, nonce, accessToken string) (string, error) { 529 + key, err := getPrivateKey() 530 + if err != nil { 531 + return "", err 532 + } 533 + 534 + jwk := jose.JSONWebKey{ 535 + Key: &key.PublicKey, 536 + KeyID: "key1", 537 + Algorithm: "ES256", 538 + Use: "sig", 539 + } 540 + 541 + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, &jose.SignerOptions{ 542 + ExtraHeaders: map[jose.HeaderKey]interface{}{ 543 + "typ": "dpop+jwt", 544 + "jwk": jwk, 545 + }, 546 + }) 547 + if err != nil { 548 + return "", err 549 + } 550 + 551 + claims := map[string]interface{}{ 552 + "jti": fmt.Sprintf("%d", time.Now().UnixNano()), 553 + "htm": method, 554 + "htu": targetURL, 555 + "iat": time.Now().Unix(), 556 + } 557 + if nonce != "" { 558 + claims["nonce"] = nonce 559 + } 560 + 561 + if accessToken != "" { 562 + hash := sha256.Sum256([]byte(accessToken)) 563 + claims["ath"] = base64.RawURLEncoding.EncodeToString(hash[:]) 564 + } 565 + 566 + return jwt.Signed(signer).Claims(claims).CompactSerialize() 567 + } 568 + 569 + func makeClientAssertion(clientID, audience string) (string, error) { 570 + key, err := getPrivateKey() 571 + if err != nil { 572 + return "", err 573 + } 574 + 575 + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, &jose.SignerOptions{ 576 + ExtraHeaders: map[jose.HeaderKey]interface{}{ 577 + "typ": "JWT", 578 + "kid": "key1", 579 + }, 580 + }) 581 + if err != nil { 582 + return "", err 583 + } 584 + 585 + claims := jwt.Claims{ 586 + Issuer: clientID, 587 + Subject: clientID, 588 + Audience: jwt.Audience{audience}, 589 + ID: fmt.Sprintf("%d", time.Now().UnixNano()), 590 + IssuedAt: jwt.NewNumericDate(time.Now()), 591 + Expiry: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), 592 + } 593 + 594 + return jwt.Signed(signer).Claims(claims).CompactSerialize() 595 + } 596 + 597 + func resolveHandle(handle string) (string, error) { 598 + fmt.Printf("Resolving handle: %s\n", handle) 599 + client := &http.Client{Timeout: 10 * time.Second} 600 + 601 + u := fmt.Sprintf("https://%s/.well-known/atproto-did", handle) 602 + fmt.Printf("GET %s\n", u) 603 + resp, err := client.Get(u) 604 + if err == nil && resp.StatusCode == 200 { 605 + defer resp.Body.Close() 606 + b, _ := io.ReadAll(resp.Body) 607 + did := strings.TrimSpace(string(b)) 608 + 609 + if strings.HasPrefix(did, "did:") { 610 + fmt.Printf("Resolved via well-known to: %s\n", did) 611 + return did, nil 612 + } 613 + fmt.Printf("Well-known returned invalid DID: %s\n", did[:min(50, len(did))]) 614 + } 615 + if err != nil { 616 + fmt.Printf("Well-known error: %v\n", err) 617 + } else if resp != nil { 618 + fmt.Printf("Well-known status: %d\n", resp.StatusCode) 619 + } 620 + 621 + u = fmt.Sprintf("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=%s", handle) 622 + fmt.Printf("GET %s\n", u) 623 + resp, err = client.Get(u) 624 + if err != nil { 625 + fmt.Printf("XRPC error: %v\n", err) 626 + return "", err 627 + } 628 + defer resp.Body.Close() 629 + if resp.StatusCode != 200 { 630 + fmt.Printf("XRPC status: %d\n", resp.StatusCode) 631 + return "", fmt.Errorf("resolution failed") 632 + } 633 + 634 + var res struct { 635 + DID string `json:"did"` 636 + } 637 + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { 638 + return "", err 639 + } 640 + fmt.Printf("Resolved via XRPC to: %s\n", res.DID) 641 + return res.DID, nil 642 + } 643 + 644 + func getPDSEndpoint(did string) (string, error) { 645 + fmt.Printf("Resolving PDS for DID: %s\n", did) 646 + 647 + resp, err := http.Get(fmt.Sprintf("https://plc.directory/%s", did)) 648 + if err != nil { 649 + fmt.Printf("PLC HTTP error: %v\n", err) 650 + return "", err 651 + } 652 + defer resp.Body.Close() 653 + if resp.StatusCode != 200 { 654 + fmt.Printf("PLC Status: %d\n", resp.StatusCode) 655 + return "", fmt.Errorf("plc resolution failed") 656 + } 657 + 658 + bodyBytes, _ := io.ReadAll(resp.Body) 659 + fmt.Printf("PLC Raw Response: %s\n", string(bodyBytes)) 660 + 661 + var plc struct { 662 + Services []struct { 663 + Type string `json:"type"` 664 + Endpoint string `json:"serviceEndpoint"` 665 + } `json:"service"` 666 + } 667 + if err := json.Unmarshal(bodyBytes, &plc); err != nil { 668 + fmt.Printf("PLC Decode error: %v\n", err) 669 + return "", err 670 + } 671 + fmt.Printf("PLC Services found: %d\n", len(plc.Services)) 672 + 673 + for _, s := range plc.Services { 674 + fmt.Printf("Service Type: %s, Endpoint: %s\n", s.Type, s.Endpoint) 675 + if s.Type == "atproto_pds" || s.Type == "AtprotoPersonalDataServer" { 676 + return s.Endpoint, nil 677 + } 678 + } 679 + return "", fmt.Errorf("no pds found") 680 + } 681 + 682 + func getAuthMetadata(pds string) (*AuthMetadata, error) { 683 + pds = strings.TrimRight(pds, "/") 684 + 685 + authServer := pds 686 + if strings.Contains(pds, ".bsky.network") { 687 + authServer = "https://bsky.social" 688 + } 689 + 690 + resp, err := http.Get(fmt.Sprintf("%s/.well-known/oauth-authorization-server", authServer)) 691 + if err != nil { 692 + return nil, err 693 + } 694 + defer resp.Body.Close() 695 + if resp.StatusCode != 200 { 696 + return nil, fmt.Errorf("auth metadata failed: status %d", resp.StatusCode) 697 + } 698 + 699 + var meta AuthMetadata 700 + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { 701 + return nil, err 702 + } 703 + return &meta, nil 704 + } 705 + 706 + func fetchAtprotoEmail(pds, accessToken, dpopNonce string) (string, error) { 707 + endpoint := strings.TrimRight(pds, "/") + "/xrpc/com.atproto.server.getSession" 708 + 709 + makeReq := func(nonce string) (*http.Response, error) { 710 + dpop, err := makeDPoPHeaderWithAth("GET", endpoint, nonce, accessToken) 711 + if err != nil { 712 + return nil, err 713 + } 714 + req, _ := http.NewRequest("GET", endpoint, nil) 715 + req.Header.Set("Authorization", "DPoP "+accessToken) 716 + req.Header.Set("DPoP", dpop) 717 + req.Header.Set("Accept", "application/json") 718 + 719 + client := &http.Client{Timeout: 10 * time.Second} 720 + return client.Do(req) 721 + } 722 + 723 + resp, err := makeReq(dpopNonce) 724 + if err != nil { 725 + return "", err 726 + } 727 + 728 + if resp.StatusCode == 400 { 729 + nonce := resp.Header.Get("DPoP-Nonce") 730 + if nonce != "" { 731 + resp.Body.Close() 732 + resp, err = makeReq(nonce) 733 + if err != nil { 734 + return "", err 735 + } 736 + } 737 + } 738 + defer resp.Body.Close() 739 + 740 + if resp.StatusCode != 200 { 741 + return "", nil 742 + } 743 + 744 + var sess struct { 745 + Email string `json:"email"` 746 + } 747 + if err := json.NewDecoder(resp.Body).Decode(&sess); err != nil { 748 + return "", err 749 + } 750 + return sess.Email, nil 751 + }
+204
backend-go/handlers/auth.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "net/http" 7 + "os" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "github.com/nrednav/cuid2" 11 + 12 + "boop-cat/db" 13 + "boop-cat/deploy" 14 + "boop-cat/lib" 15 + "boop-cat/middleware" 16 + ) 17 + 18 + type AuthHandler struct { 19 + DB *sql.DB 20 + Engine *deploy.Engine 21 + } 22 + 23 + func NewAuthHandler(database *sql.DB, engine *deploy.Engine) *AuthHandler { 24 + return &AuthHandler{DB: database, Engine: engine} 25 + } 26 + 27 + func jsonError(w http.ResponseWriter, error string, code int) { 28 + w.Header().Set("Content-Type", "application/json") 29 + w.WriteHeader(code) 30 + json.NewEncoder(w).Encode(map[string]string{"error": error}) 31 + } 32 + 33 + func (h *AuthHandler) Routes() chi.Router { 34 + r := chi.NewRouter() 35 + 36 + r.Post("/login", h.Login) 37 + r.Post("/register", h.Register) 38 + r.Post("/logout", h.Logout) 39 + r.Get("/me", h.Me) 40 + r.Get("/providers", h.GetProviders) 41 + 42 + r.Post("/verify-email", h.SendVerificationEmail) 43 + r.Get("/verify-email", h.VerifyEmail) 44 + r.Post("/verify-email/resend", h.ResendVerificationEmail) 45 + r.Post("/forgot-password", h.RequestPasswordReset) 46 + r.Post("/reset-password", h.ResetPasswordConfirm) 47 + 48 + return r 49 + } 50 + 51 + type loginRequest struct { 52 + Email string `json:"email"` 53 + Password string `json:"password"` 54 + TurnstileToken string `json:"turnstileToken"` 55 + } 56 + 57 + type registerRequest struct { 58 + Email string `json:"email"` 59 + Password string `json:"password"` 60 + TurnstileToken string `json:"turnstileToken"` 61 + } 62 + 63 + func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { 64 + var req loginRequest 65 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 66 + jsonError(w, "invalid-json", http.StatusBadRequest) 67 + return 68 + } 69 + 70 + ts := lib.VerifyTurnstile(req.TurnstileToken, r.RemoteAddr) 71 + if !ts.OK { 72 + jsonError(w, "captcha-failed", http.StatusBadRequest) 73 + return 74 + } 75 + 76 + user, err := db.GetUserByEmail(h.DB, req.Email) 77 + if err != nil { 78 + 79 + jsonError(w, "invalid-credentials", http.StatusUnauthorized) 80 + return 81 + } 82 + 83 + if !user.VerifyPassword(req.Password) { 84 + jsonError(w, "invalid-credentials", http.StatusUnauthorized) 85 + return 86 + } 87 + 88 + if user.Banned { 89 + jsonError(w, "user-banned", http.StatusForbidden) 90 + return 91 + } 92 + 93 + _ = db.UpdateLastLogin(h.DB, user.ID) 94 + 95 + if err := middleware.LoginUser(w, r, user.ID); err != nil { 96 + jsonError(w, "session-error", http.StatusInternalServerError) 97 + return 98 + } 99 + 100 + w.Header().Set("Content-Type", "application/json") 101 + json.NewEncoder(w).Encode(map[string]interface{}{ 102 + "ok": true, 103 + "user": map[string]interface{}{ 104 + "id": user.ID, 105 + "email": user.Email, 106 + "username": getNullString(user.Username), 107 + "emailVerified": user.EmailVerified, 108 + }, 109 + }) 110 + } 111 + 112 + func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { 113 + var req registerRequest 114 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 115 + jsonError(w, "invalid-json", http.StatusBadRequest) 116 + return 117 + } 118 + 119 + ts := lib.VerifyTurnstile(req.TurnstileToken, r.RemoteAddr) 120 + if !ts.OK { 121 + jsonError(w, "captcha-failed", http.StatusBadRequest) 122 + return 123 + } 124 + 125 + if req.Email == "" || req.Password == "" { 126 + jsonError(w, "missing-fields", http.StatusBadRequest) 127 + return 128 + } 129 + 130 + existing, _ := db.GetUserByEmail(h.DB, req.Email) 131 + if existing != nil { 132 + jsonError(w, "email-already-registered", http.StatusConflict) 133 + return 134 + } 135 + 136 + id := cuid2.Generate() 137 + user, err := db.CreateUser(h.DB, id, req.Email, req.Password) 138 + if err != nil { 139 + jsonError(w, "create-user-failed", http.StatusInternalServerError) 140 + return 141 + } 142 + 143 + if err := middleware.LoginUser(w, r, user.ID); err != nil { 144 + jsonError(w, "session-error", http.StatusInternalServerError) 145 + return 146 + } 147 + 148 + w.Header().Set("Content-Type", "application/json") 149 + w.WriteHeader(http.StatusCreated) 150 + json.NewEncoder(w).Encode(map[string]interface{}{ 151 + "ok": true, 152 + "user": map[string]interface{}{ 153 + "id": user.ID, 154 + "email": user.Email, 155 + "username": nil, 156 + "emailVerified": false, 157 + }, 158 + }) 159 + } 160 + 161 + func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { 162 + middleware.LogoutUser(w, r) 163 + w.Header().Set("Content-Type", "application/json") 164 + w.Write([]byte(`{"ok":true}`)) 165 + } 166 + 167 + func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { 168 + user := middleware.GetUser(r.Context()) 169 + if user == nil { 170 + w.Header().Set("Content-Type", "application/json") 171 + w.Write([]byte(`{"authenticated":false,"user":null}`)) 172 + return 173 + } 174 + 175 + w.Header().Set("Content-Type", "application/json") 176 + json.NewEncoder(w).Encode(map[string]interface{}{ 177 + "authenticated": true, 178 + "user": map[string]interface{}{ 179 + "id": user.ID, 180 + "email": user.Email, 181 + "username": getNullString(user.Username), 182 + "emailVerified": user.EmailVerified, 183 + }, 184 + }) 185 + } 186 + 187 + func (h *AuthHandler) GetProviders(w http.ResponseWriter, r *http.Request) { 188 + config := map[string]interface{}{ 189 + "github": os.Getenv("GITHUB_CLIENT_ID") != "" && os.Getenv("GITHUB_CLIENT_SECRET") != "", 190 + "google": os.Getenv("GOOGLE_CLIENT_ID") != "" && os.Getenv("GOOGLE_CLIENT_SECRET") != "", 191 + "atproto": os.Getenv("ATPROTO_PRIVATE_KEY_1") != "", 192 + "turnstileSiteKey": os.Getenv("TURNSTILE_SITE_KEY"), 193 + } 194 + 195 + w.Header().Set("Content-Type", "application/json") 196 + json.NewEncoder(w).Encode(config) 197 + } 198 + 199 + func getNullString(ns sql.NullString) *string { 200 + if ns.Valid { 201 + return &ns.String 202 + } 203 + return nil 204 + }
+194
backend-go/handlers/auth_email.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/hex" 5 + "encoding/json" 6 + "math/rand" 7 + "net/http" 8 + "time" 9 + 10 + "golang.org/x/crypto/bcrypt" 11 + 12 + "boop-cat/db" 13 + "boop-cat/lib" 14 + "boop-cat/middleware" 15 + ) 16 + 17 + func (h *AuthHandler) SendVerificationEmail(w http.ResponseWriter, r *http.Request) { 18 + user := middleware.GetUser(r.Context()) 19 + if user == nil { 20 + jsonError(w, "unauthorized", http.StatusUnauthorized) 21 + return 22 + } 23 + if user.EmailVerified { 24 + jsonError(w, "already-verified", http.StatusBadRequest) 25 + return 26 + } 27 + 28 + token := generateToken() 29 + expiresAt := time.Now().Add(24 * time.Hour).Unix() 30 + id := generateToken() 31 + 32 + err := db.CreateVerificationToken(h.DB, id, user.ID, token, expiresAt) 33 + if err != nil { 34 + jsonError(w, "db-error", http.StatusInternalServerError) 35 + return 36 + } 37 + 38 + username := "" 39 + if user.Username.Valid { 40 + username = user.Username.String 41 + } 42 + go lib.SendVerificationEmail(user.Email, token, username) 43 + 44 + w.Write([]byte(`{"ok":true}`)) 45 + } 46 + 47 + func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { 48 + token := r.URL.Query().Get("token") 49 + if token == "" { 50 + http.Error(w, "missing-token", http.StatusBadRequest) 51 + return 52 + } 53 + 54 + ev, err := db.GetVerificationToken(h.DB, token) 55 + if err != nil || ev == nil { 56 + http.Error(w, "invalid-token", http.StatusBadRequest) 57 + return 58 + } 59 + 60 + if time.Now().Unix() > ev.ExpiresAt { 61 + http.Error(w, "expired-token", http.StatusBadRequest) 62 + return 63 + } 64 + 65 + err = db.UpdateUserEmailVerified(h.DB, ev.UserID) 66 + if err != nil { 67 + http.Error(w, "db-error", http.StatusInternalServerError) 68 + return 69 + } 70 + 71 + db.MarkTokenUsed(h.DB, ev.ID) 72 + 73 + http.Redirect(w, r, "/dashboard?verified=true", http.StatusFound) 74 + } 75 + 76 + func (h *AuthHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) { 77 + var req struct { 78 + Email string `json:"email"` 79 + } 80 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 81 + jsonError(w, "invalid-json", http.StatusBadRequest) 82 + return 83 + } 84 + 85 + user, err := db.GetUserByEmail(h.DB, req.Email) 86 + if err != nil || user == nil { 87 + 88 + w.Write([]byte(`{"ok":true}`)) 89 + return 90 + } 91 + 92 + token := generateToken() 93 + expiresAt := time.Now().Add(1 * time.Hour).Unix() 94 + id := generateToken() 95 + 96 + err = db.CreateVerificationToken(h.DB, id, user.ID, token, expiresAt) 97 + if err != nil { 98 + jsonError(w, "db-error", http.StatusInternalServerError) 99 + return 100 + } 101 + 102 + username := "" 103 + if user.Username.Valid { 104 + username = user.Username.String 105 + } 106 + go lib.SendPasswordResetEmail(user.Email, token, username) 107 + 108 + w.Write([]byte(`{"ok":true}`)) 109 + } 110 + 111 + func (h *AuthHandler) ResetPasswordConfirm(w http.ResponseWriter, r *http.Request) { 112 + var req struct { 113 + Token string `json:"token"` 114 + Password string `json:"password"` 115 + } 116 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 117 + jsonError(w, "invalid-json", http.StatusBadRequest) 118 + return 119 + } 120 + 121 + ev, err := db.GetVerificationToken(h.DB, req.Token) 122 + if err != nil || ev == nil { 123 + jsonError(w, "invalid-token", http.StatusBadRequest) 124 + return 125 + } 126 + 127 + if time.Now().Unix() > ev.ExpiresAt { 128 + jsonError(w, "expired-token", http.StatusBadRequest) 129 + return 130 + } 131 + 132 + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) 133 + if err != nil { 134 + jsonError(w, "hash-failed", http.StatusInternalServerError) 135 + return 136 + } 137 + 138 + err = db.UpdateUserPassword(h.DB, ev.UserID, string(hash)) 139 + if err != nil { 140 + jsonError(w, "db-error", http.StatusInternalServerError) 141 + return 142 + } 143 + 144 + db.MarkTokenUsed(h.DB, ev.ID) 145 + 146 + w.Write([]byte(`{"ok":true}`)) 147 + } 148 + 149 + func (h *AuthHandler) ResendVerificationEmail(w http.ResponseWriter, r *http.Request) { 150 + var req struct { 151 + Email string `json:"email"` 152 + } 153 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 154 + jsonError(w, "invalid-json", http.StatusBadRequest) 155 + return 156 + } 157 + 158 + user, err := db.GetUserByEmail(h.DB, req.Email) 159 + if err != nil || user == nil { 160 + 161 + w.Write([]byte(`{"ok":true}`)) 162 + return 163 + } 164 + 165 + if user.EmailVerified { 166 + w.Header().Set("Content-Type", "application/json") 167 + w.Write([]byte(`{"ok":true,"alreadyVerified":true}`)) 168 + return 169 + } 170 + 171 + token := generateToken() 172 + expiresAt := time.Now().Add(24 * time.Hour).Unix() 173 + id := generateToken() 174 + 175 + err = db.CreateVerificationToken(h.DB, id, user.ID, token, expiresAt) 176 + if err != nil { 177 + jsonError(w, "db-error", http.StatusInternalServerError) 178 + return 179 + } 180 + 181 + username := "" 182 + if user.Username.Valid { 183 + username = user.Username.String 184 + } 185 + go lib.SendVerificationEmail(user.Email, token, username) 186 + 187 + w.Write([]byte(`{"ok":true}`)) 188 + } 189 + 190 + func generateToken() string { 191 + b := make([]byte, 32) 192 + rand.Read(b) 193 + return hex.EncodeToString(b) 194 + }
+376
backend-go/handlers/custom_domains.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "os" 9 + "strings" 10 + 11 + "github.com/go-chi/chi/v5" 12 + "github.com/nrednav/cuid2" 13 + 14 + "boop-cat/db" 15 + "boop-cat/deploy" 16 + "boop-cat/middleware" 17 + ) 18 + 19 + type CustomDomainHandler struct { 20 + DB *sql.DB 21 + Engine *deploy.Engine 22 + } 23 + 24 + func NewCustomDomainHandler(database *sql.DB, engine *deploy.Engine) *CustomDomainHandler { 25 + return &CustomDomainHandler{DB: database, Engine: engine} 26 + } 27 + 28 + func (h *CustomDomainHandler) Routes() chi.Router { 29 + r := chi.NewRouter() 30 + r.Use(middleware.RequireLogin) 31 + 32 + r.Get("/sites/{siteId}/custom-domains", h.ListCustomDomains) 33 + r.Post("/sites/{siteId}/custom-domains", h.CreateCustomDomain) 34 + r.Delete("/sites/{siteId}/custom-domains/{id}", h.DeleteCustomDomain) 35 + 36 + r.Post("/sites/{siteId}/custom-domains/{id}/poll", h.PollCustomDomain) 37 + 38 + return r 39 + } 40 + 41 + func (h *CustomDomainHandler) ListCustomDomains(w http.ResponseWriter, r *http.Request) { 42 + userID := middleware.GetUserID(r.Context()) 43 + siteID := chi.URLParam(r, "siteId") 44 + 45 + site, err := db.GetSiteByID(h.DB, userID, siteID) 46 + if err != nil || site == nil { 47 + jsonError(w, "site-not-found", http.StatusNotFound) 48 + return 49 + } 50 + 51 + domains, err := db.ListCustomDomains(h.DB, siteID) 52 + if err != nil { 53 + jsonError(w, "list-failed", http.StatusInternalServerError) 54 + return 55 + } 56 + 57 + resp := []db.CustomDomainResponse{} 58 + for _, d := range domains { 59 + resp = append(resp, d.ToResponse()) 60 + } 61 + 62 + w.Header().Set("Content-Type", "application/json") 63 + json.NewEncoder(w).Encode(resp) 64 + } 65 + 66 + func (h *CustomDomainHandler) CreateCustomDomain(w http.ResponseWriter, r *http.Request) { 67 + userID := middleware.GetUserID(r.Context()) 68 + siteID := chi.URLParam(r, "siteId") 69 + 70 + var req struct { 71 + Hostname string `json:"hostname"` 72 + } 73 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 74 + jsonError(w, "invalid-json", http.StatusBadRequest) 75 + return 76 + } 77 + 78 + site, err := db.GetSiteByID(h.DB, userID, siteID) 79 + if err != nil || site == nil { 80 + jsonError(w, "site-not-found", http.StatusNotFound) 81 + return 82 + } 83 + 84 + if req.Hostname == "" { 85 + jsonError(w, "hostname-required", http.StatusBadRequest) 86 + return 87 + } 88 + hostname := strings.ToLower(strings.TrimSpace(req.Hostname)) 89 + rootDomain := strings.ToLower(os.Getenv("FSD_EDGE_ROOT_DOMAIN")) 90 + if rootDomain != "" && (hostname == rootDomain || strings.HasSuffix(hostname, "."+rootDomain)) { 91 + jsonError(w, "root-domains-not-supported", http.StatusBadRequest) 92 + return 93 + } 94 + 95 + count, err := db.CountCustomDomainsForUser(h.DB, userID) 96 + if err != nil { 97 + jsonError(w, "db-error", http.StatusInternalServerError) 98 + return 99 + } 100 + if count >= 3 { 101 + jsonError(w, "custom-domain-limit-reached", http.StatusBadRequest) 102 + return 103 + } 104 + 105 + cf := deploy.NewCloudflareClient(h.Engine.CFAccountID, h.Engine.CFNamespaceID, h.Engine.CFToken) 106 + 107 + zoneID, err := cf.GetZoneID(os.Getenv("FSD_EDGE_ROOT_DOMAIN")) 108 + if err != nil { 109 + 110 + jsonError(w, "zone-not-found", http.StatusInternalServerError) 111 + return 112 + } 113 + 114 + if err := h.ensureFallbackOrigin(cf, zoneID); err != nil { 115 + fmt.Printf("Fallback origin error: %v\n", err) 116 + 117 + } 118 + 119 + res, err := cf.CreateCustomHostname(zoneID, hostname) 120 + if err != nil { 121 + jsonError(w, "custom-domain-create-failed: "+err.Error(), http.StatusBadRequest) 122 + return 123 + } 124 + 125 + var cfRes CloudflareHostname 126 + if err := json.Unmarshal(res, &cfRes); err != nil { 127 + fmt.Printf("Error unmarshalling CF response: %v\n", err) 128 + } 129 + 130 + sslStatus := cfRes.SSL.Status 131 + status := cfRes.Status 132 + combined := "pending" 133 + if status == "active" && sslStatus == "active" { 134 + combined = "active" 135 + } else if status == "active" || sslStatus == "active" { 136 + combined = "pending_ssl" 137 + } 138 + 139 + records := extractVerificationRecords(cfRes) 140 + recordsJSON, _ := json.Marshal(records) 141 + 142 + id := cuid2.Generate() 143 + err = db.CreateCustomDomain(h.DB, id, siteID, hostname, cfRes.ID, combined, sslStatus, string(recordsJSON)) 144 + if err != nil { 145 + jsonError(w, "db-create-failed", http.StatusInternalServerError) 146 + return 147 + } 148 + 149 + if combined == "active" { 150 + cf.EnsureRouting(hostname, siteID, "") 151 + } 152 + 153 + d, _ := db.GetCustomDomainByID(h.DB, id) 154 + w.Header().Set("Content-Type", "application/json") 155 + json.NewEncoder(w).Encode(d.ToResponse()) 156 + } 157 + 158 + func (h *CustomDomainHandler) PollCustomDomain(w http.ResponseWriter, r *http.Request) { 159 + userID := middleware.GetUserID(r.Context()) 160 + siteID := chi.URLParam(r, "siteId") 161 + id := chi.URLParam(r, "id") 162 + 163 + site, err := db.GetSiteByID(h.DB, userID, siteID) 164 + if err != nil || site == nil { 165 + jsonError(w, "site-not-found", http.StatusNotFound) 166 + return 167 + } 168 + 169 + domain, err := db.GetCustomDomainByID(h.DB, id) 170 + if err != nil { 171 + jsonError(w, "custom-domain-not-found", http.StatusNotFound) 172 + return 173 + } 174 + 175 + cf := deploy.NewCloudflareClient(h.Engine.CFAccountID, h.Engine.CFNamespaceID, h.Engine.CFToken) 176 + zoneID, _ := cf.GetZoneID(os.Getenv("FSD_EDGE_ROOT_DOMAIN")) 177 + 178 + res, err := cf.GetCustomHostname(zoneID, domain.CFCustomHostnameID.String) 179 + if err != nil { 180 + jsonError(w, "poll-failed", http.StatusInternalServerError) 181 + return 182 + } 183 + 184 + var cfRes CloudflareHostname 185 + if err := json.Unmarshal(res, &cfRes); err != nil { 186 + fmt.Printf("Error unmarshalling CF response: %v\n", err) 187 + } 188 + 189 + sslStatus := cfRes.SSL.Status 190 + status := cfRes.Status 191 + combined := "pending" 192 + if status == "active" && sslStatus == "active" { 193 + combined = "active" 194 + } else if status == "active" || sslStatus == "active" { 195 + combined = "pending_ssl" 196 + } 197 + 198 + records := extractVerificationRecords(cfRes) 199 + recordsJSON, _ := json.Marshal(records) 200 + 201 + db.UpdateCustomDomainStatus(h.DB, id, combined, sslStatus, string(recordsJSON), cfRes.ID) 202 + 203 + if combined == "active" && domain.Status != "active" { 204 + cf.EnsureRouting(domain.Hostname, siteID, "") 205 + } 206 + 207 + updated, _ := db.GetCustomDomainByID(h.DB, id) 208 + w.Header().Set("Content-Type", "application/json") 209 + json.NewEncoder(w).Encode(updated.ToResponse()) 210 + } 211 + 212 + type CloudflareHostname struct { 213 + ID string `json:"id"` 214 + Status string `json:"status"` 215 + SSL struct { 216 + Status string `json:"status"` 217 + ValidationRecords []struct { 218 + TxtName string `json:"txt_name"` 219 + TxtValue string `json:"txt_value"` 220 + HTTPUrl string `json:"http_url"` 221 + HTTPBody string `json:"http_body"` 222 + } `json:"validation_records"` 223 + } `json:"ssl"` 224 + OwnershipVerification struct { 225 + Type string `json:"type"` 226 + Name string `json:"name"` 227 + Value string `json:"value"` 228 + } `json:"ownership_verification"` 229 + OwnershipVerificationHTTP struct { 230 + HTTPUrl string `json:"http_url"` 231 + HTTPBody string `json:"http_body"` 232 + } `json:"ownership_verification_http"` 233 + } 234 + 235 + type VerificationRecord struct { 236 + Type string `json:"type"` 237 + Name string `json:"name,omitempty"` 238 + Value string `json:"value,omitempty"` 239 + HTTPUrl string `json:"http_url,omitempty"` 240 + HTTPBody string `json:"http_body,omitempty"` 241 + } 242 + 243 + func extractVerificationRecords(r CloudflareHostname) []VerificationRecord { 244 + var records []VerificationRecord 245 + 246 + if r.OwnershipVerification.Type != "" && r.OwnershipVerification.Name != "" && r.OwnershipVerification.Value != "" { 247 + records = append(records, VerificationRecord{ 248 + Type: r.OwnershipVerification.Type, 249 + Name: r.OwnershipVerification.Name, 250 + Value: r.OwnershipVerification.Value, 251 + }) 252 + } 253 + 254 + if r.OwnershipVerificationHTTP.HTTPUrl != "" && r.OwnershipVerificationHTTP.HTTPBody != "" { 255 + records = append(records, VerificationRecord{ 256 + Type: "http", 257 + HTTPUrl: r.OwnershipVerificationHTTP.HTTPUrl, 258 + HTTPBody: r.OwnershipVerificationHTTP.HTTPBody, 259 + }) 260 + } 261 + 262 + for _, rec := range r.SSL.ValidationRecords { 263 + if rec.TxtName != "" && rec.TxtValue != "" { 264 + records = append(records, VerificationRecord{ 265 + Type: "ssl_txt", 266 + Name: rec.TxtName, 267 + Value: rec.TxtValue, 268 + }) 269 + } 270 + if rec.HTTPUrl != "" && rec.HTTPBody != "" { 271 + records = append(records, VerificationRecord{ 272 + Type: "ssl_http", 273 + HTTPUrl: rec.HTTPUrl, 274 + HTTPBody: rec.HTTPBody, 275 + }) 276 + } 277 + } 278 + 279 + return records 280 + } 281 + 282 + func (h *CustomDomainHandler) DeleteCustomDomain(w http.ResponseWriter, r *http.Request) { 283 + userID := middleware.GetUserID(r.Context()) 284 + siteID := chi.URLParam(r, "siteId") 285 + id := chi.URLParam(r, "id") 286 + 287 + site, err := db.GetSiteByID(h.DB, userID, siteID) 288 + if err != nil || site == nil { 289 + jsonError(w, "site-not-found", http.StatusNotFound) 290 + return 291 + } 292 + 293 + domain, err := db.GetCustomDomainByID(h.DB, id) 294 + if err != nil { 295 + jsonError(w, "custom-domain-not-found", http.StatusNotFound) 296 + return 297 + } 298 + 299 + cf := deploy.NewCloudflareClient(h.Engine.CFAccountID, h.Engine.CFNamespaceID, h.Engine.CFToken) 300 + zoneID, _ := cf.GetZoneID(os.Getenv("FSD_EDGE_ROOT_DOMAIN")) 301 + 302 + cfID := domain.CFCustomHostnameID.String 303 + if cfID == "" { 304 + 305 + foundID, err := cf.GetCustomHostnameIDByName(zoneID, domain.Hostname) 306 + if err == nil { 307 + cfID = foundID 308 + } else { 309 + fmt.Printf("Warning: Could not find CF ID for %s: %v\n", domain.Hostname, err) 310 + } 311 + } 312 + 313 + if cfID != "" { 314 + err := cf.DeleteCustomHostname(zoneID, cfID) 315 + if err != nil { 316 + fmt.Printf("Warning: Failed to delete custom hostname %s: %v\n", cfID, err) 317 + } 318 + } 319 + 320 + cf.RemoveRouting("", "", domain.Hostname) 321 + 322 + db.DeleteCustomDomain(h.DB, id) 323 + 324 + w.Header().Set("Content-Type", "application/json") 325 + w.Write([]byte(`{"ok":true}`)) 326 + } 327 + 328 + func (h *CustomDomainHandler) ensureFallbackOrigin(cf *deploy.CloudflareClient, zoneID string) error { 329 + rootDomain := os.Getenv("FSD_EDGE_ROOT_DOMAIN") 330 + if rootDomain == "" { 331 + return nil 332 + } 333 + 334 + fallbackOrigin := fmt.Sprintf("sites.%s", rootDomain) 335 + 336 + records, err := cf.GetDNSRecords(zoneID, fallbackOrigin) 337 + if err != nil { 338 + fmt.Printf("Error getting DNS records: %v\n", err) 339 + 340 + } else { 341 + var existing *deploy.DNSRecord 342 + for _, r := range records { 343 + if r.Name == fallbackOrigin { 344 + existing = &r 345 + break 346 + } 347 + } 348 + 349 + targetRecord := deploy.DNSRecord{ 350 + Type: "AAAA", 351 + Name: "sites", 352 + 353 + Content: "100::", 354 + Proxied: true, 355 + } 356 + 357 + targetRecord.Name = fallbackOrigin 358 + 359 + if existing == nil { 360 + err := cf.CreateDNSRecord(zoneID, targetRecord) 361 + if err != nil { 362 + fmt.Printf("Error creating DNS record: %v\n", err) 363 + } 364 + } else { 365 + if existing.Type != "AAAA" || existing.Content != "100::" || !existing.Proxied { 366 + err := cf.UpdateDNSRecord(zoneID, existing.ID, targetRecord) 367 + if err != nil { 368 + fmt.Printf("Error updating DNS record: %v\n", err) 369 + } 370 + } 371 + } 372 + } 373 + 374 + fmt.Printf("Setting fallback origin for zone %s to %s\n", zoneID, fallbackOrigin) 375 + return cf.UpdateFallbackOrigin(zoneID, fallbackOrigin) 376 + }
+129
backend-go/handlers/deploy.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "net/http" 7 + "os" 8 + 9 + "github.com/go-chi/chi/v5" 10 + 11 + "boop-cat/db" 12 + "boop-cat/deploy" 13 + "boop-cat/middleware" 14 + ) 15 + 16 + type DeployHandler struct { 17 + DB *sql.DB 18 + Engine *deploy.Engine 19 + } 20 + 21 + func NewDeployHandler(database *sql.DB) *DeployHandler { 22 + 23 + engine := deploy.NewEngine( 24 + database, 25 + os.Getenv("B2_KEY_ID"), 26 + os.Getenv("B2_APP_KEY"), 27 + os.Getenv("B2_BUCKET_ID"), 28 + os.Getenv("CF_API_TOKEN"), 29 + os.Getenv("CF_ACCOUNT_ID"), 30 + os.Getenv("CF_KV_NAMESPACE_ID"), 31 + ) 32 + return &DeployHandler{DB: database, Engine: engine} 33 + } 34 + 35 + func (h *DeployHandler) Routes() chi.Router { 36 + r := chi.NewRouter() 37 + r.Use(middleware.RequireLogin) 38 + 39 + r.Post("/sites/{siteId}/deploy", h.TriggerDeploy) 40 + r.Get("/sites/{siteId}/deployments", h.ListDeployments) 41 + r.Get("/deployments/{id}", h.GetDeployment) 42 + 43 + return r 44 + } 45 + 46 + func (h *DeployHandler) TriggerDeploy(w http.ResponseWriter, r *http.Request) { 47 + userID := middleware.GetUserID(r.Context()) 48 + siteID := chi.URLParam(r, "siteId") 49 + 50 + site, err := db.GetSiteByID(h.DB, userID, siteID) 51 + if err != nil || site == nil { 52 + jsonError(w, "site-not-found", http.StatusNotFound) 53 + return 54 + } 55 + 56 + d, err := h.Engine.DeploySite(siteID, userID, nil) 57 + if err != nil { 58 + jsonError(w, "deploy-failed: "+err.Error(), http.StatusInternalServerError) 59 + return 60 + } 61 + 62 + w.Header().Set("Content-Type", "application/json") 63 + json.NewEncoder(w).Encode(d.ToResponse()) 64 + } 65 + 66 + func (h *DeployHandler) ListDeployments(w http.ResponseWriter, r *http.Request) { 67 + userID := middleware.GetUserID(r.Context()) 68 + siteID := chi.URLParam(r, "siteId") 69 + 70 + site, err := db.GetSiteByID(h.DB, userID, siteID) 71 + if err != nil || site == nil { 72 + jsonError(w, "site-not-found", http.StatusNotFound) 73 + return 74 + } 75 + 76 + deployments, err := db.ListDeployments(h.DB, userID, siteID) 77 + if err != nil { 78 + jsonError(w, "list-failed", http.StatusInternalServerError) 79 + return 80 + } 81 + 82 + var resp []db.DeploymentResponse 83 + for _, d := range deployments { 84 + resp = append(resp, d.ToResponse()) 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/json") 88 + json.NewEncoder(w).Encode(resp) 89 + } 90 + 91 + func (h *DeployHandler) GetDeployment(w http.ResponseWriter, r *http.Request) { 92 + userID := middleware.GetUserID(r.Context()) 93 + deployID := chi.URLParam(r, "id") 94 + 95 + d, err := db.GetDeploymentByID(h.DB, deployID) 96 + if err != nil { 97 + jsonError(w, "not-found", http.StatusNotFound) 98 + return 99 + } 100 + 101 + if d.UserID != userID { 102 + jsonError(w, "forbidden", http.StatusForbidden) 103 + return 104 + } 105 + 106 + w.Header().Set("Content-Type", "application/json") 107 + json.NewEncoder(w).Encode(d.ToResponse()) 108 + } 109 + 110 + func (h *DeployHandler) toResponse(d *db.Deployment) map[string]interface{} { 111 + resp := map[string]interface{}{ 112 + "id": d.ID, 113 + "status": d.Status, 114 + "createdAt": d.CreatedAt, 115 + } 116 + if d.URL.Valid { 117 + resp["url"] = d.URL.String 118 + } 119 + if d.CommitSha.Valid { 120 + resp["commitSha"] = d.CommitSha.String 121 + } 122 + if d.CommitMessage.Valid { 123 + resp["commitMessage"] = d.CommitMessage.String 124 + } 125 + if d.CommitAuthor.Valid { 126 + resp["commitAuthor"] = d.CommitAuthor.String 127 + } 128 + return resp 129 + }
+163
backend-go/handlers/deploy_extras.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "os" 7 + "strings" 8 + 9 + "github.com/go-chi/chi/v5" 10 + 11 + "boop-cat/db" 12 + "boop-cat/deploy" 13 + "boop-cat/middleware" 14 + ) 15 + 16 + func (h *DeployHandler) GetDeploymentLogs(w http.ResponseWriter, r *http.Request) { 17 + userID := middleware.GetUserID(r.Context()) 18 + deployID := chi.URLParam(r, "id") 19 + 20 + d, err := db.GetDeploymentByID(h.DB, deployID) 21 + if err != nil { 22 + http.Error(w, "not-found", http.StatusNotFound) 23 + return 24 + } 25 + 26 + if d.UserID != userID { 27 + http.Error(w, "forbidden", http.StatusForbidden) 28 + return 29 + } 30 + 31 + if !d.LogsPath.Valid || d.LogsPath.String == "" { 32 + w.Header().Set("Content-Type", "text/plain") 33 + w.Write([]byte("")) 34 + return 35 + } 36 + 37 + content, err := os.ReadFile(d.LogsPath.String) 38 + if err != nil { 39 + 40 + w.Header().Set("Content-Type", "text/plain") 41 + w.Write([]byte("")) 42 + return 43 + } 44 + 45 + w.Header().Set("Content-Type", "text/plain") 46 + w.Write(content) 47 + } 48 + 49 + func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { 50 + userID := middleware.GetUserID(r.Context()) 51 + 52 + if h.Engine != nil { 53 + sites, _ := db.GetSitesByUserID(h.DB, userID) 54 + for _, site := range sites { 55 + err := h.Engine.CleanupSite(site.ID, userID) 56 + if err != nil { 57 + 58 + } 59 + } 60 + } 61 + 62 + _, err := h.DB.Exec("DELETE FROM users WHERE id = ?", userID) 63 + if err != nil { 64 + jsonError(w, "delete-failed", http.StatusInternalServerError) 65 + return 66 + } 67 + 68 + middleware.LogoutUser(w, r) 69 + w.Header().Set("Content-Type", "application/json") 70 + w.Write([]byte(`{"ok":true}`)) 71 + } 72 + 73 + func (h *DeployHandler) StopDeployment(w http.ResponseWriter, r *http.Request) { 74 + userID := middleware.GetUserID(r.Context()) 75 + deployID := chi.URLParam(r, "id") 76 + 77 + d, err := db.GetDeploymentByID(h.DB, deployID) 78 + if err != nil { 79 + http.Error(w, "not-found", http.StatusNotFound) 80 + return 81 + } 82 + if d.UserID != userID { 83 + http.Error(w, "forbidden", http.StatusForbidden) 84 + return 85 + } 86 + 87 + err = h.Engine.CancelDeployment(deployID) 88 + if err != nil { 89 + 90 + dCheck, errDb := db.GetDeploymentByID(h.DB, deployID) 91 + 92 + if errDb == nil && (dCheck.Status == "running" || dCheck.Status == "active") { 93 + 94 + cf := deploy.NewCloudflareClient( 95 + os.Getenv("CF_ACCOUNT_ID"), 96 + os.Getenv("CF_KV_NAMESPACE_ID"), 97 + os.Getenv("CF_API_TOKEN"), 98 + ) 99 + 100 + site, _ := db.GetSiteByID(h.DB, userID, dCheck.SiteID) 101 + if site != nil { 102 + 103 + rootDomain := os.Getenv("FSD_EDGE_ROOT_DOMAIN") 104 + routingKey := site.Domain 105 + if rootDomain != "" && strings.HasSuffix(site.Domain, "."+rootDomain) { 106 + routingKey = strings.TrimSuffix(site.Domain, "."+rootDomain) 107 + } 108 + 109 + cf.RemoveRouting(routingKey, site.ID, site.Domain) 110 + 111 + customDomains, _ := db.ListCustomDomains(h.DB, site.ID) 112 + for _, cd := range customDomains { 113 + 114 + cf.RemoveRouting("", site.ID, cd.Hostname) 115 + } 116 + } 117 + 118 + db.UpdateDeploymentStatus(h.DB, deployID, "stopped", "") 119 + w.Header().Set("Content-Type", "application/json") 120 + w.Write([]byte(`{"ok":true}`)) 121 + return 122 + } 123 + 124 + if errDb == nil && dCheck.Status == "building" { 125 + db.UpdateDeploymentStatus(h.DB, deployID, "canceled", "") 126 + w.Header().Set("Content-Type", "application/json") 127 + w.Write([]byte(`{"ok":true}`)) 128 + return 129 + } 130 + 131 + jsonError(w, "cancel-failed: "+err.Error(), http.StatusBadRequest) 132 + return 133 + } 134 + 135 + w.Header().Set("Content-Type", "application/json") 136 + w.Write([]byte(`{"ok":true}`)) 137 + } 138 + 139 + func (h *DeployHandler) PreviewSite(w http.ResponseWriter, r *http.Request) { 140 + var req struct { 141 + GitURL string `json:"gitUrl"` 142 + Branch string `json:"branch"` 143 + Subdir string `json:"subdir"` 144 + } 145 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 146 + http.Error(w, "invalid-json", http.StatusBadRequest) 147 + return 148 + } 149 + 150 + result, err := h.Engine.PreviewGitRepo(req.GitURL) 151 + if err != nil { 152 + 153 + w.WriteHeader(http.StatusBadRequest) 154 + json.NewEncoder(w).Encode(map[string]interface{}{ 155 + "error": "preview-failed", 156 + "message": err.Error(), 157 + }) 158 + return 159 + } 160 + 161 + w.Header().Set("Content-Type", "application/json") 162 + json.NewEncoder(w).Encode(result) 163 + }
+176
backend-go/handlers/github.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strconv" 8 + "strings" 9 + 10 + "boop-cat/db" 11 + "boop-cat/middleware" 12 + ) 13 + 14 + type SimplifiedRepo struct { 15 + ID int64 `json:"id"` 16 + Name string `json:"name"` 17 + FullName string `json:"fullName"` 18 + CloneURL string `json:"cloneUrl"` 19 + HTMLURL string `json:"htmlUrl"` 20 + DefaultBranch string `json:"defaultBranch"` 21 + Private bool `json:"private"` 22 + Description string `json:"description"` 23 + Language string `json:"language"` 24 + UpdatedAt string `json:"updatedAt"` 25 + PushedAt string `json:"pushedAt"` 26 + } 27 + 28 + func (h *AuthHandler) GetGitHubRepos(w http.ResponseWriter, r *http.Request) { 29 + user := middleware.GetUser(r.Context()) 30 + if user == nil { 31 + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) 32 + return 33 + } 34 + 35 + accounts, err := db.ListOAuthAccounts(h.DB, user.ID) 36 + if err != nil { 37 + http.Error(w, `{"error":"db-error"}`, http.StatusInternalServerError) 38 + return 39 + } 40 + 41 + var accessToken string 42 + for _, a := range accounts { 43 + if a.Provider == "github" && a.AccessToken.Valid { 44 + accessToken = a.AccessToken.String 45 + break 46 + } 47 + } 48 + 49 + if accessToken == "" { 50 + w.Header().Set("Content-Type", "application/json") 51 + w.Write([]byte(`{"repos":[],"githubConnected":false}`)) 52 + return 53 + } 54 + 55 + page := 1 56 + if p := r.URL.Query().Get("page"); p != "" { 57 + if val, err := strconv.Atoi(p); err == nil { 58 + page = val 59 + } 60 + } 61 + perPage := 30 62 + if p := r.URL.Query().Get("per_page"); p != "" { 63 + if val, err := strconv.Atoi(p); err == nil { 64 + perPage = val 65 + if perPage > 100 { 66 + perPage = 100 67 + } 68 + } 69 + } 70 + searchQuery := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q"))) 71 + 72 + var repos []SimplifiedRepo 73 + 74 + if searchQuery != "" { 75 + 76 + url := "https://api.github.com/user/repos?sort=updated&per_page=100&visibility=all" 77 + fetched, _, err := fetchGithubRepos(url, accessToken) 78 + if err != nil { 79 + http.Error(w, `{"error":"github-api-error"}`, http.StatusBadGateway) 80 + return 81 + } 82 + 83 + for _, repo := range fetched { 84 + if strings.Contains(strings.ToLower(repo.Name), searchQuery) || 85 + strings.Contains(strings.ToLower(repo.Description), searchQuery) { 86 + repos = append(repos, repo) 87 + } 88 + } 89 + 90 + w.Header().Set("Content-Type", "application/json") 91 + json.NewEncoder(w).Encode(map[string]interface{}{ 92 + "repos": repos, 93 + "githubConnected": true, 94 + "page": 1, 95 + "hasNextPage": false, 96 + "hasPrevPage": false, 97 + }) 98 + return 99 + } 100 + 101 + url := fmt.Sprintf("https://api.github.com/user/repos?sort=updated&per_page=%d&page=%d&visibility=all", perPage, page) 102 + fetched, linkHeader, err := fetchGithubRepos(url, accessToken) 103 + if err != nil { 104 + http.Error(w, `{"error":"github-api-error"}`, http.StatusBadGateway) 105 + return 106 + } 107 + repos = fetched 108 + 109 + hasNextPage := strings.Contains(linkHeader, `rel="next"`) 110 + hasPrevPage := strings.Contains(linkHeader, `rel="prev"`) 111 + 112 + w.Header().Set("Content-Type", "application/json") 113 + json.NewEncoder(w).Encode(map[string]interface{}{ 114 + "repos": repos, 115 + "githubConnected": true, 116 + "page": page, 117 + "perPage": perPage, 118 + "hasNextPage": hasNextPage, 119 + "hasPrevPage": hasPrevPage, 120 + }) 121 + } 122 + 123 + type githubRepoInternal struct { 124 + ID int64 `json:"id"` 125 + Name string `json:"name"` 126 + FullName string `json:"full_name"` 127 + CloneURL string `json:"clone_url"` 128 + HTMLURL string `json:"html_url"` 129 + DefaultBranch string `json:"default_branch"` 130 + Private bool `json:"private"` 131 + Description string `json:"description"` 132 + Language string `json:"language"` 133 + UpdatedAt string `json:"updated_at"` 134 + PushedAt string `json:"pushed_at"` 135 + } 136 + 137 + func fetchGithubRepos(url, token string) ([]SimplifiedRepo, string, error) { 138 + req, _ := http.NewRequest("GET", url, nil) 139 + req.Header.Set("Authorization", "token "+token) 140 + req.Header.Set("Accept", "application/vnd.github+json") 141 + req.Header.Set("User-Agent", "free-static-host") 142 + 143 + resp, err := http.DefaultClient.Do(req) 144 + if err != nil { 145 + return nil, "", err 146 + } 147 + defer resp.Body.Close() 148 + 149 + if resp.StatusCode != 200 { 150 + return nil, "", fmt.Errorf("github api status: %d", resp.StatusCode) 151 + } 152 + 153 + var raw []githubRepoInternal 154 + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { 155 + return nil, "", err 156 + } 157 + 158 + var simplified []SimplifiedRepo 159 + for _, r := range raw { 160 + simplified = append(simplified, SimplifiedRepo{ 161 + ID: r.ID, 162 + Name: r.Name, 163 + FullName: r.FullName, 164 + CloneURL: r.CloneURL, 165 + HTMLURL: r.HTMLURL, 166 + DefaultBranch: r.DefaultBranch, 167 + Private: r.Private, 168 + Description: r.Description, 169 + Language: r.Language, 170 + UpdatedAt: r.UpdatedAt, 171 + PushedAt: r.PushedAt, 172 + }) 173 + } 174 + 175 + return simplified, resp.Header.Get("Link"), nil 176 + }
+28
backend-go/handlers/github_installed.go
··· 1 + package handlers 2 + 3 + import ( 4 + "net/http" 5 + 6 + "boop-cat/middleware" 7 + ) 8 + 9 + func (h *AuthHandler) GitHubInstalled(w http.ResponseWriter, r *http.Request) { 10 + user := middleware.GetUser(r.Context()) 11 + if user == nil { 12 + http.Redirect(w, r, "/login", http.StatusFound) 13 + return 14 + } 15 + 16 + installationID := r.URL.Query().Get("installation_id") 17 + setupAction := r.URL.Query().Get("setup_action") 18 + 19 + if installationID != "" && setupAction == "install" { 20 + 21 + _, _ = h.DB.Exec(` 22 + INSERT OR IGNORE INTO githubAppInstallations (id, userId, installationId, createdAt) 23 + VALUES (?, ?, ?, datetime('now')) 24 + `, generateToken()[:16], user.ID, installationID) 25 + } 26 + 27 + http.Redirect(w, r, "/dashboard", http.StatusFound) 28 + }
+152
backend-go/handlers/github_webhooks.go
··· 1 + package handlers 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "database/sql" 7 + "encoding/hex" 8 + "encoding/json" 9 + "fmt" 10 + "io/ioutil" 11 + "net/http" 12 + "os" 13 + "strings" 14 + 15 + "github.com/go-chi/chi/v5" 16 + "github.com/nrednav/cuid2" 17 + 18 + "boop-cat/db" 19 + "boop-cat/deploy" 20 + ) 21 + 22 + type GitHubWebhookHandler struct { 23 + DB *sql.DB 24 + Engine *deploy.Engine 25 + } 26 + 27 + func NewGitHubWebhookHandler(database *sql.DB, engine *deploy.Engine) *GitHubWebhookHandler { 28 + return &GitHubWebhookHandler{DB: database, Engine: engine} 29 + } 30 + 31 + func (h *GitHubWebhookHandler) Routes() chi.Router { 32 + r := chi.NewRouter() 33 + r.Post("/", h.HandleWebhook) 34 + return r 35 + } 36 + 37 + func verifySignature(payload []byte, signature, secret string) bool { 38 + if signature == "" || secret == "" { 39 + return false 40 + } 41 + 42 + mac := hmac.New(sha256.New, []byte(secret)) 43 + mac.Write(payload) 44 + expectedMAC := mac.Sum(nil) 45 + expectedSignature := "sha256=" + hex.EncodeToString(expectedMAC) 46 + 47 + return hmac.Equal([]byte(signature), []byte(expectedSignature)) 48 + } 49 + 50 + func (h *GitHubWebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { 51 + secret := os.Getenv("GITHUB_APP_WEBHOOK_SECRET") 52 + signature := r.Header.Get("X-Hub-Signature-256") 53 + eventType := r.Header.Get("X-GitHub-Event") 54 + 55 + body, err := ioutil.ReadAll(r.Body) 56 + if err != nil { 57 + http.Error(w, "read-failed", http.StatusInternalServerError) 58 + return 59 + } 60 + 61 + if secret != "" { 62 + if !verifySignature(body, signature, secret) { 63 + http.Error(w, "invalid-signature", http.StatusUnauthorized) 64 + return 65 + } 66 + } 67 + 68 + var event map[string]interface{} 69 + if err := json.Unmarshal(body, &event); err != nil { 70 + http.Error(w, "invalid-json", http.StatusBadRequest) 71 + return 72 + } 73 + 74 + if eventType == "push" { 75 + h.handlePush(w, event) 76 + return 77 + } 78 + 79 + if eventType == "installation" { 80 + h.handleInstallation(w, event) 81 + return 82 + } 83 + 84 + w.Write([]byte(`{"ok":true,"ignored":true}`)) 85 + } 86 + 87 + func (h *GitHubWebhookHandler) handlePush(w http.ResponseWriter, event map[string]interface{}) { 88 + 89 + repoMap, _ := event["repository"].(map[string]interface{}) 90 + if repoMap == nil { 91 + w.Write([]byte(`{"ok":true,"ignored":"no-repo"}`)) 92 + return 93 + } 94 + 95 + repoURL, _ := repoMap["clone_url"].(string) 96 + 97 + ref, _ := event["ref"].(string) 98 + branch := strings.TrimPrefix(ref, "refs/heads/") 99 + 100 + if repoURL == "" || branch == "" { 101 + w.Write([]byte(`{"ok":true,"ignored":"no-url-or-branch"}`)) 102 + return 103 + } 104 + 105 + sites, err := db.GetSitesByRepo(h.DB, repoURL, branch) 106 + if err != nil { 107 + fmt.Printf("[Webhook] Failed to find sites: %v\n", err) 108 + http.Error(w, "db-error", http.StatusInternalServerError) 109 + return 110 + } 111 + 112 + processed := 0 113 + for _, site := range sites { 114 + fmt.Printf("[Webhook] Triggering deploy for site %s\n", site.ID) 115 + _, err := h.Engine.DeploySite(site.ID, site.UserID, nil) 116 + if err != nil { 117 + fmt.Printf("[Webhook] Deploy failed for %s: %v\n", site.ID, err) 118 + } else { 119 + processed++ 120 + } 121 + } 122 + 123 + json.NewEncoder(w).Encode(map[string]interface{}{ 124 + "ok": true, 125 + "deployed": processed, 126 + "matched": len(sites), 127 + }) 128 + } 129 + 130 + func (h *GitHubWebhookHandler) handleInstallation(w http.ResponseWriter, event map[string]interface{}) { 131 + action, _ := event["action"].(string) 132 + installMap, _ := event["installation"].(map[string]interface{}) 133 + if installMap == nil { 134 + w.Write([]byte(`{"ok":true}`)) 135 + return 136 + } 137 + 138 + instID := fmt.Sprintf("%.0f", installMap["id"].(float64)) 139 + 140 + if action == "deleted" { 141 + db.RemoveGitHubInstallation(h.DB, instID) 142 + } else if action == "created" { 143 + account, _ := installMap["account"].(map[string]interface{}) 144 + login, _ := account["login"].(string) 145 + accType, _ := account["type"].(string) 146 + 147 + id := cuid2.Generate() 148 + db.AddGitHubInstallation(h.DB, id, instID, login, accType, "") 149 + } 150 + 151 + w.Write([]byte(`{"ok":true}`)) 152 + }
+180
backend-go/handlers/oauth.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "github.com/markbates/goth/gothic" 11 + "github.com/nrednav/cuid2" 12 + 13 + "boop-cat/db" 14 + "boop-cat/middleware" 15 + ) 16 + 17 + func (h *AuthHandler) MountOAuthRoutes(r chi.Router) { 18 + r.Get("/{provider}", h.BeginOAuth) 19 + r.Get("/{provider}/callback", h.OAuthCallback) 20 + } 21 + 22 + func (h *AuthHandler) BeginOAuth(w http.ResponseWriter, r *http.Request) { 23 + 24 + gothic.BeginAuthHandler(w, r) 25 + } 26 + 27 + func (h *AuthHandler) OAuthCallback(w http.ResponseWriter, r *http.Request) { 28 + gothUser, err := gothic.CompleteUserAuth(w, r) 29 + if err != nil { 30 + http.Redirect(w, r, "/?error=auth-failed", http.StatusTemporaryRedirect) 31 + return 32 + } 33 + 34 + provider := chi.URLParam(r, "provider") 35 + 36 + loggedInUser := middleware.GetUser(r.Context()) 37 + 38 + existingAcc, err := db.FindOAuthAccount(h.DB, provider, gothUser.UserID) 39 + if err == nil && existingAcc != nil { 40 + 41 + if loggedInUser != nil && existingAcc.UserID != loggedInUser.ID { 42 + 43 + http.Redirect(w, r, "/dashboard/account?error=already-linked", http.StatusTemporaryRedirect) 44 + return 45 + } 46 + 47 + _, _ = h.DB.Exec(`UPDATE oauthAccounts SET accessToken = ? WHERE id = ?`, gothUser.AccessToken, existingAcc.ID) 48 + 49 + if err := middleware.LoginUser(w, r, existingAcc.UserID); err != nil { 50 + http.Redirect(w, r, "/?error=session-error", http.StatusTemporaryRedirect) 51 + return 52 + } 53 + h.finalizeGitHubLogin(w, r, existingAcc.UserID, provider, gothUser.AccessToken) 54 + return 55 + } 56 + 57 + if loggedInUser != nil { 58 + err = db.CreateOAuthAccount(h.DB, cuid2.Generate(), provider, gothUser.UserID, loggedInUser.ID, gothUser.AccessToken, gothUser.Name) 59 + if err != nil { 60 + http.Redirect(w, r, "/dashboard/account?error=link-failed", http.StatusTemporaryRedirect) 61 + return 62 + } 63 + 64 + http.Redirect(w, r, "/dashboard/account", http.StatusTemporaryRedirect) 65 + return 66 + } 67 + 68 + existingUser, err := db.GetUserByEmail(h.DB, gothUser.Email) 69 + if err == nil && existingUser != nil { 70 + 71 + err = db.CreateOAuthAccount(h.DB, cuid2.Generate(), provider, gothUser.UserID, existingUser.ID, gothUser.AccessToken, gothUser.Name) 72 + if err != nil { 73 + http.Redirect(w, r, "/?error=link-failed", http.StatusTemporaryRedirect) 74 + return 75 + } 76 + 77 + if err := middleware.LoginUser(w, r, existingUser.ID); err != nil { 78 + http.Redirect(w, r, "/?error=session-error", http.StatusTemporaryRedirect) 79 + return 80 + } 81 + h.finalizeGitHubLogin(w, r, existingUser.ID, provider, gothUser.AccessToken) 82 + return 83 + } 84 + 85 + userID := cuid2.Generate() 86 + 87 + randomPwd := cuid2.Generate() + cuid2.Generate() 88 + _, err = db.CreateUser(h.DB, userID, gothUser.Email, randomPwd) 89 + if err != nil { 90 + http.Redirect(w, r, "/?error=create-user-failed", http.StatusTemporaryRedirect) 91 + return 92 + } 93 + 94 + verified := false 95 + if v, ok := gothUser.RawData["verified"].(bool); ok && v { 96 + verified = true 97 + } else if v, ok := gothUser.RawData["email_verified"].(bool); ok && v { 98 + verified = true 99 + } 100 + 101 + if verified { 102 + _, _ = h.DB.Exec(`UPDATE users SET emailVerified = 1 WHERE id = ?`, userID) 103 + } 104 + 105 + err = db.CreateOAuthAccount(h.DB, cuid2.Generate(), provider, gothUser.UserID, userID, gothUser.AccessToken, gothUser.Name) 106 + if err != nil { 107 + http.Redirect(w, r, "/?error=link-failed", http.StatusTemporaryRedirect) 108 + return 109 + } 110 + 111 + if err := middleware.LoginUser(w, r, userID); err != nil { 112 + http.Redirect(w, r, "/?error=session-error", http.StatusTemporaryRedirect) 113 + return 114 + } 115 + 116 + if err := middleware.LoginUser(w, r, userID); err != nil { 117 + http.Redirect(w, r, "/?error=session-error", http.StatusTemporaryRedirect) 118 + return 119 + } 120 + 121 + h.finalizeGitHubLogin(w, r, userID, provider, gothUser.AccessToken) 122 + } 123 + 124 + func (h *AuthHandler) finalizeGitHubLogin(w http.ResponseWriter, r *http.Request, userID, provider, accessToken string) { 125 + 126 + if provider != "github" { 127 + http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect) 128 + return 129 + } 130 + 131 + installURL := os.Getenv("GITHUB_APP_INSTALL_URL") 132 + appID := os.Getenv("GITHUB_APP_ID") 133 + if installURL == "" || appID == "" { 134 + http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect) 135 + return 136 + } 137 + 138 + var exists bool 139 + err := h.DB.QueryRow(`SELECT 1 FROM githubAppInstallations WHERE userId = ? LIMIT 1`, userID).Scan(&exists) 140 + if err == nil && exists { 141 + http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect) 142 + return 143 + } 144 + 145 + hasInstall := false 146 + if accessToken != "" { 147 + req, _ := http.NewRequest("GET", "https://api.github.com/user/installations", nil) 148 + req.Header.Set("Authorization", "token "+accessToken) 149 + req.Header.Set("Accept", "application/vnd.github+json") 150 + 151 + resp, err := http.DefaultClient.Do(req) 152 + if err == nil && resp.StatusCode == 200 { 153 + var data struct { 154 + Installations []struct { 155 + ID int64 `json:"id"` 156 + AppID int64 `json:"app_id"` 157 + } `json:"installations"` 158 + } 159 + if json.NewDecoder(resp.Body).Decode(&data) == nil { 160 + for _, inst := range data.Installations { 161 + if fmt.Sprintf("%d", inst.AppID) == appID { 162 + 163 + h.DB.Exec(`INSERT OR IGNORE INTO githubAppInstallations (id, userId, installationId, createdAt) VALUES (?, ?, ?, datetime('now'))`, 164 + cuid2.Generate(), userID, fmt.Sprintf("%d", inst.ID)) 165 + hasInstall = true 166 + break 167 + } 168 + } 169 + } 170 + resp.Body.Close() 171 + } 172 + } 173 + 174 + if hasInstall { 175 + http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect) 176 + } else { 177 + 178 + http.Redirect(w, r, installURL, http.StatusTemporaryRedirect) 179 + } 180 + }
+294
backend-go/handlers/sites.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "os" 9 + "regexp" 10 + "strings" 11 + 12 + "github.com/go-chi/chi/v5" 13 + "github.com/nrednav/cuid2" 14 + 15 + "boop-cat/db" 16 + "boop-cat/deploy" 17 + "boop-cat/lib" 18 + "boop-cat/middleware" 19 + ) 20 + 21 + type SitesHandler struct { 22 + DB *sql.DB 23 + Engine *deploy.Engine 24 + } 25 + 26 + func NewSitesHandler(database *sql.DB, engine *deploy.Engine) *SitesHandler { 27 + return &SitesHandler{DB: database, Engine: engine} 28 + } 29 + 30 + func (h *SitesHandler) Routes() chi.Router { 31 + r := chi.NewRouter() 32 + r.Use(middleware.RequireLogin) 33 + 34 + r.Get("/", h.ListSites) 35 + r.Post("/", h.CreateSite) 36 + r.Patch("/{id}", h.UpdateSiteEnv) 37 + r.Patch("/{id}/settings", h.UpdateSiteSettings) 38 + r.Put("/{id}/settings", h.UpdateSiteSettings) 39 + r.Post("/{id}/settings", h.UpdateSiteSettings) 40 + r.Delete("/{id}", h.DeleteSite) 41 + 42 + return r 43 + } 44 + 45 + func (h *SitesHandler) ListSites(w http.ResponseWriter, r *http.Request) { 46 + userID := middleware.GetUserID(r.Context()) 47 + sites, err := db.GetSitesByUserID(h.DB, userID) 48 + if err != nil { 49 + jsonError(w, "list-sites-failed", http.StatusInternalServerError) 50 + return 51 + } 52 + w.Header().Set("Content-Type", "application/json") 53 + 54 + var resp []db.SiteResponse 55 + for _, s := range sites { 56 + r := s.ToResponse() 57 + if r.EnvText != "" { 58 + r.EnvText = lib.Decrypt(r.EnvText) 59 + } 60 + resp = append(resp, r) 61 + } 62 + 63 + json.NewEncoder(w).Encode(resp) 64 + } 65 + 66 + func (h *SitesHandler) CreateSite(w http.ResponseWriter, r *http.Request) { 67 + userID := middleware.GetUserID(r.Context()) 68 + 69 + var req struct { 70 + Name string `json:"name"` 71 + GitURL string `json:"gitUrl"` 72 + Branch string `json:"branch"` 73 + Subdir string `json:"subdir"` 74 + Domain string `json:"domain"` 75 + BuildCommand string `json:"buildCommand"` 76 + OutputDir string `json:"outputDir"` 77 + } 78 + 79 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 80 + jsonError(w, "invalid-json", http.StatusBadRequest) 81 + return 82 + } 83 + 84 + if req.Name == "" { 85 + jsonError(w, "name-required", http.StatusBadRequest) 86 + return 87 + } 88 + 89 + siteID := cuid2.Generate() 90 + 91 + if req.Branch == "" { 92 + req.Branch = "main" 93 + } 94 + 95 + edgeRoot := os.Getenv("FSD_EDGE_ROOT_DOMAIN") 96 + if edgeRoot != "" { 97 + 98 + edgeRoot = strings.TrimLeft(edgeRoot, ".") 99 + 100 + normalized := strings.ToLower(strings.TrimSpace(req.Domain)) 101 + if normalized != "" { 102 + if !strings.HasSuffix(normalized, "."+edgeRoot) && !strings.Contains(normalized, ".") { 103 + 104 + normalized = normalized + "." + edgeRoot 105 + } else if !strings.HasSuffix(normalized, "."+edgeRoot) { 106 + 107 + } 108 + } else { 109 + 110 + label := strings.ToLower(req.Name) 111 + reg := regexp.MustCompile("[^a-z0-9-]") 112 + label = reg.ReplaceAllString(label, "-") 113 + label = strings.Trim(label, "-") 114 + if label == "" { 115 + label = "site" 116 + } 117 + normalized = label + "." + edgeRoot 118 + } 119 + 120 + req.Domain = normalized 121 + 122 + baseLabel := strings.TrimSuffix(req.Domain, "."+edgeRoot) 123 + for i := 0; i < 5; i++ { 124 + existing, _ := db.GetSiteByDomain(h.DB, req.Domain) 125 + if existing == nil { 126 + break 127 + } 128 + 129 + suffix := cuid2.Generate()[:4] 130 + req.Domain = fmt.Sprintf("%s-%s.%s", baseLabel, suffix, edgeRoot) 131 + } 132 + } 133 + 134 + err := db.CreateSite(h.DB, siteID, userID, req.Name, req.Domain, req.GitURL, req.Branch, req.Subdir, req.BuildCommand, req.OutputDir) 135 + if err != nil { 136 + jsonError(w, "create-site-failed: "+err.Error(), http.StatusBadRequest) 137 + return 138 + } 139 + 140 + site, _ := db.GetSiteByID(h.DB, userID, siteID) 141 + resp := site.ToResponse() 142 + if resp.EnvText != "" { 143 + resp.EnvText = lib.Decrypt(resp.EnvText) 144 + } 145 + w.Header().Set("Content-Type", "application/json") 146 + json.NewEncoder(w).Encode(resp) 147 + } 148 + 149 + func (h *SitesHandler) UpdateSiteEnv(w http.ResponseWriter, r *http.Request) { 150 + userID := middleware.GetUserID(r.Context()) 151 + siteID := chi.URLParam(r, "siteId") 152 + if siteID == "" { 153 + siteID = chi.URLParam(r, "id") 154 + } 155 + 156 + var req struct { 157 + EnvText string `json:"envText"` 158 + } 159 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 160 + jsonError(w, "invalid-json", http.StatusBadRequest) 161 + return 162 + } 163 + 164 + site, err := db.GetSiteByID(h.DB, userID, siteID) 165 + if err != nil || site == nil { 166 + jsonError(w, "site-not-found", http.StatusNotFound) 167 + return 168 + } 169 + 170 + if site.UserID != userID { 171 + jsonError(w, "forbidden", http.StatusForbidden) 172 + return 173 + } 174 + 175 + encryptedEnv := lib.Encrypt(req.EnvText) 176 + err = db.UpdateSiteEnv(h.DB, siteID, encryptedEnv) 177 + if err != nil { 178 + jsonError(w, "update-failed", http.StatusInternalServerError) 179 + return 180 + } 181 + 182 + updated, _ := db.GetSiteByID(h.DB, userID, siteID) 183 + resp := updated.ToResponse() 184 + if resp.EnvText != "" { 185 + resp.EnvText = lib.Decrypt(resp.EnvText) 186 + } 187 + w.Header().Set("Content-Type", "application/json") 188 + json.NewEncoder(w).Encode(resp) 189 + } 190 + 191 + func (h *SitesHandler) UpdateSiteSettings(w http.ResponseWriter, r *http.Request) { 192 + userID := middleware.GetUserID(r.Context()) 193 + siteID := chi.URLParam(r, "siteId") 194 + if siteID == "" { 195 + siteID = chi.URLParam(r, "id") 196 + } 197 + 198 + var req struct { 199 + Name string `json:"name"` 200 + GitURL string `json:"gitUrl"` 201 + Branch string `json:"branch"` 202 + Subdir string `json:"subdir"` 203 + Domain string `json:"domain"` 204 + BuildCommand string `json:"buildCommand"` 205 + OutputDir string `json:"outputDir"` 206 + } 207 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 208 + jsonError(w, "invalid-json", http.StatusBadRequest) 209 + return 210 + } 211 + 212 + site, err := db.GetSiteByID(h.DB, userID, siteID) 213 + if err != nil || site == nil { 214 + jsonError(w, "site-not-found", http.StatusNotFound) 215 + return 216 + } 217 + 218 + if req.Name == "" { 219 + req.Name = site.Name 220 + } 221 + if req.GitURL == "" && site.GitURL.Valid { 222 + req.GitURL = site.GitURL.String 223 + } 224 + if req.Branch == "" && site.GitBranch.Valid { 225 + req.Branch = site.GitBranch.String 226 + } 227 + if req.Subdir == "" && site.GitSubdir.Valid { 228 + req.Subdir = site.GitSubdir.String 229 + } 230 + if req.Domain == "" { 231 + req.Domain = site.Domain 232 + } 233 + if req.BuildCommand == "" && site.BuildCommand.Valid { 234 + req.BuildCommand = site.BuildCommand.String 235 + } 236 + if req.OutputDir == "" && site.OutputDir.Valid { 237 + req.OutputDir = site.OutputDir.String 238 + } 239 + 240 + if req.Domain != "" && req.Domain != site.Domain { 241 + edgeRoot := os.Getenv("FSD_EDGE_ROOT_DOMAIN") 242 + if edgeRoot != "" { 243 + normalized := strings.ToLower(strings.TrimSpace(req.Domain)) 244 + if !strings.HasSuffix(normalized, "."+edgeRoot) && !strings.Contains(normalized, ".") { 245 + req.Domain = normalized + "." + edgeRoot 246 + } 247 + } 248 + } 249 + 250 + err = db.UpdateSiteSettings(h.DB, siteID, req.Name, req.Domain, req.GitURL, req.Branch, req.Subdir, req.BuildCommand, req.OutputDir) 251 + if err != nil { 252 + jsonError(w, "update-failed", http.StatusInternalServerError) 253 + return 254 + } 255 + 256 + updated, _ := db.GetSiteByID(h.DB, userID, siteID) 257 + resp := updated.ToResponse() 258 + if resp.EnvText != "" { 259 + resp.EnvText = lib.Decrypt(resp.EnvText) 260 + } 261 + w.Header().Set("Content-Type", "application/json") 262 + json.NewEncoder(w).Encode(resp) 263 + } 264 + 265 + func (h *SitesHandler) DeleteSite(w http.ResponseWriter, r *http.Request) { 266 + userID := middleware.GetUserID(r.Context()) 267 + siteID := chi.URLParam(r, "siteId") 268 + if siteID == "" { 269 + siteID = chi.URLParam(r, "id") 270 + } 271 + 272 + site, err := db.GetSiteByID(h.DB, userID, siteID) 273 + if err != nil || site == nil { 274 + jsonError(w, "site-not-found", http.StatusNotFound) 275 + return 276 + } 277 + 278 + if h.Engine != nil { 279 + 280 + cleanupErr := h.Engine.CleanupSite(siteID, userID) 281 + if cleanupErr != nil { 282 + fmt.Printf("Warning: Failed to cleanup external resources for site %s: %v\n", siteID, cleanupErr) 283 + } 284 + } 285 + 286 + err = db.DeleteSite(h.DB, userID, siteID) 287 + if err != nil { 288 + jsonError(w, "delete-failed", http.StatusInternalServerError) 289 + return 290 + } 291 + 292 + w.Header().Set("Content-Type", "application/json") 293 + w.Write([]byte(`{"ok":true}`)) 294 + }
+268
backend-go/lib/dmca.go
··· 1 + package lib 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + "os" 10 + "time" 11 + 12 + "github.com/emersion/go-imap" 13 + "github.com/emersion/go-imap/client" 14 + "github.com/emersion/go-message/mail" 15 + ) 16 + 17 + type DMCAMonitor struct { 18 + IMAPHost string 19 + IMAPPort string 20 + IMAPUser string 21 + IMAPPass string 22 + IMAPTLS bool 23 + WebhookURL string 24 + pollTicker *time.Ticker 25 + stopChan chan struct{} 26 + } 27 + 28 + func NewDMCAMonitor() *DMCAMonitor { 29 + return &DMCAMonitor{ 30 + IMAPHost: os.Getenv("IMAP_HOST"), 31 + IMAPPort: os.Getenv("IMAP_PORT"), 32 + IMAPUser: os.Getenv("IMAP_USER"), 33 + IMAPPass: os.Getenv("IMAP_PASSWORD"), 34 + IMAPTLS: os.Getenv("IMAP_TLS") == "true", 35 + WebhookURL: os.Getenv("DISCORD_DMCA_WEBHOOK_URL"), 36 + stopChan: make(chan struct{}), 37 + } 38 + } 39 + 40 + func (m *DMCAMonitor) Start() { 41 + if m.IMAPHost == "" || m.IMAPUser == "" || m.IMAPPass == "" { 42 + log.Println("DMCA Monitor: Missing IMAP credentials, skipping.") 43 + return 44 + } 45 + 46 + m.CheckEmails() 47 + 48 + m.pollTicker = time.NewTicker(5 * time.Minute) 49 + go func() { 50 + for { 51 + select { 52 + case <-m.pollTicker.C: 53 + m.CheckEmails() 54 + case <-m.stopChan: 55 + m.pollTicker.Stop() 56 + return 57 + } 58 + } 59 + }() 60 + log.Println("DMCA Monitor started.") 61 + } 62 + 63 + func (m *DMCAMonitor) Stop() { 64 + close(m.stopChan) 65 + } 66 + 67 + func (m *DMCAMonitor) CheckEmails() { 68 + if m.IMAPHost == "" || m.IMAPUser == "" || m.IMAPPass == "" { 69 + log.Println("DMCA Monitor: Missing IMAP credentials, skipping.") 70 + return 71 + } 72 + 73 + port := m.IMAPPort 74 + if port == "" { 75 + port = "993" 76 + } 77 + 78 + addr := fmt.Sprintf("%s:%s", m.IMAPHost, port) 79 + 80 + var c *client.Client 81 + var err error 82 + 83 + if m.IMAPTLS { 84 + c, err = client.DialTLS(addr, nil) 85 + } else { 86 + c, err = client.Dial(addr) 87 + if err == nil { 88 + 89 + if ok, _ := c.SupportStartTLS(); ok { 90 + if err := c.StartTLS(nil); err != nil { 91 + log.Printf("DMCA Monitor: STARTTLS failed: %v", err) 92 + return 93 + } 94 + } 95 + } 96 + } 97 + if err != nil { 98 + log.Printf("DMCA Monitor: Failed to connect: %v", err) 99 + return 100 + } 101 + defer c.Logout() 102 + 103 + if err := c.Login(m.IMAPUser, m.IMAPPass); err != nil { 104 + log.Printf("DMCA Monitor: Login failed: %v", err) 105 + return 106 + } 107 + 108 + mbox, err := c.Select("INBOX", false) 109 + if err != nil { 110 + log.Printf("DMCA Monitor: Failed to select INBOX: %v", err) 111 + return 112 + } 113 + 114 + if mbox.Messages == 0 { 115 + return 116 + } 117 + 118 + criteria := imap.NewSearchCriteria() 119 + criteria.WithoutFlags = []string{imap.SeenFlag} 120 + 121 + uids, err := c.Search(criteria) 122 + if err != nil { 123 + log.Printf("DMCA Monitor: Search failed: %v", err) 124 + return 125 + } 126 + 127 + if len(uids) == 0 { 128 + return 129 + } 130 + 131 + seqSet := new(imap.SeqSet) 132 + seqSet.AddNum(uids...) 133 + 134 + section := &imap.BodySectionName{} 135 + items := []imap.FetchItem{section.FetchItem(), imap.FetchEnvelope} 136 + 137 + messages := make(chan *imap.Message, len(uids)) 138 + done := make(chan error, 1) 139 + go func() { 140 + done <- c.Fetch(seqSet, items, messages) 141 + }() 142 + 143 + for msg := range messages { 144 + if msg == nil { 145 + continue 146 + } 147 + 148 + r := msg.GetBody(section) 149 + if r == nil { 150 + continue 151 + } 152 + 153 + mr, err := mail.CreateReader(r) 154 + if err != nil { 155 + log.Printf("DMCA Monitor: Failed to create mail reader: %v", err) 156 + continue 157 + } 158 + 159 + var from, subject, body string 160 + 161 + header := mr.Header 162 + if addrs, err := header.AddressList("From"); err == nil && len(addrs) > 0 { 163 + from = addrs[0].String() 164 + } 165 + if s, err := header.Subject(); err == nil { 166 + subject = s 167 + } 168 + 169 + for { 170 + p, err := mr.NextPart() 171 + if err != nil { 172 + break 173 + } 174 + 175 + switch h := p.Header.(type) { 176 + case *mail.InlineHeader: 177 + 178 + contentType, _, _ := h.ContentType() 179 + 180 + buf := new(bytes.Buffer) 181 + buf.ReadFrom(p.Body) 182 + partBody := buf.String() 183 + 184 + if body == "" { 185 + body = partBody 186 + } else if contentType == "text/plain" { 187 + 188 + body += "\n\n" + partBody 189 + } 190 + } 191 + } 192 + 193 + log.Printf("DMCA Monitor: Forwarding email from %s: %s", from, subject) 194 + 195 + if m.WebhookURL != "" { 196 + m.sendToDiscord(from, subject, body) 197 + } else { 198 + log.Println("DMCA Monitor: No Webhook URL configured.") 199 + } 200 + } 201 + 202 + if err := <-done; err != nil { 203 + log.Printf("DMCA Monitor: Fetch error: %v", err) 204 + } 205 + 206 + item := imap.FormatFlagsOp(imap.AddFlags, true) 207 + flags := []interface{}{imap.SeenFlag} 208 + if err := c.Store(seqSet, item, flags, nil); err != nil { 209 + log.Printf("DMCA Monitor: Store failed: %v", err) 210 + } 211 + } 212 + 213 + func (m *DMCAMonitor) sendToDiscord(from, subject, body string) { 214 + if m.WebhookURL == "" { 215 + return 216 + } 217 + 218 + header := fmt.Sprintf("**New DMCA/Legal-related Email Received**\n**From:** %s\n**Subject:** %s\n\n", from, subject) 219 + 220 + if body == "" { 221 + body = "(No content)" 222 + } 223 + 224 + const chunkSize = 1900 225 + chunks := splitString(body, chunkSize) 226 + 227 + firstChunk := header + ">>> " + chunks[0] 228 + m.postWebhook(firstChunk) 229 + 230 + for i := 1; i < len(chunks); i++ { 231 + time.Sleep(500 * time.Millisecond) 232 + m.postWebhook(">>> " + chunks[i]) 233 + } 234 + } 235 + 236 + func (m *DMCAMonitor) postWebhook(content string) { 237 + payload := map[string]string{"content": content} 238 + data, _ := json.Marshal(payload) 239 + 240 + resp, err := http.Post(m.WebhookURL, "application/json", bytes.NewReader(data)) 241 + if err != nil { 242 + log.Printf("DMCA Monitor: Discord webhook failed: %v", err) 243 + return 244 + } 245 + resp.Body.Close() 246 + } 247 + 248 + func splitString(s string, chunkSize int) []string { 249 + if len(s) <= chunkSize { 250 + return []string{s} 251 + } 252 + 253 + var chunks []string 254 + for i := 0; i < len(s); i += chunkSize { 255 + end := i + chunkSize 256 + if end > len(s) { 257 + end = len(s) 258 + } 259 + chunks = append(chunks, s[i:end]) 260 + } 261 + return chunks 262 + } 263 + 264 + func StartDMCAMonitor() *DMCAMonitor { 265 + monitor := NewDMCAMonitor() 266 + monitor.Start() 267 + return monitor 268 + }
+211
backend-go/lib/email.go
··· 1 + package lib 2 + 3 + import ( 4 + "crypto/tls" 5 + "fmt" 6 + "net/smtp" 7 + "os" 8 + "time" 9 + ) 10 + 11 + func SendEmail(to, subject, body string) error { 12 + host := os.Getenv("SMTP_HOST") 13 + port := os.Getenv("SMTP_PORT") 14 + user := os.Getenv("SMTP_USER") 15 + pass := os.Getenv("SMTP_PASS") 16 + from := os.Getenv("SMTP_FROM") 17 + if from == "" { 18 + from = os.Getenv("MAIL_FROM") 19 + } 20 + 21 + if host == "" || from == "" { 22 + fmt.Printf("[Email] Skipped sending to %s (no config)\nSubject: %s\nBody: %s\n", to, subject, body) 23 + return nil 24 + } 25 + 26 + fromName := os.Getenv("SMTP_FROM_NAME") 27 + if fromName == "" { 28 + fromName = "boop.cat" 29 + } 30 + 31 + msg := []byte(fmt.Sprintf("From: %s <%s>\r\n"+ 32 + "To: %s\r\n"+ 33 + "Subject: %s\r\n"+ 34 + "MIME-Version: 1.0\r\n"+ 35 + "Content-Type: text/html; charset=\"UTF-8\"\r\n"+ 36 + "\r\n"+ 37 + "%s\r\n", fromName, from, to, subject, body)) 38 + 39 + addr := fmt.Sprintf("%s:%s", host, port) 40 + 41 + if port == "465" || os.Getenv("SMTP_SECURE") == "true" { 42 + tlsConfig := &tls.Config{ 43 + InsecureSkipVerify: false, 44 + ServerName: host, 45 + } 46 + 47 + conn, err := tls.Dial("tcp", addr, tlsConfig) 48 + if err != nil { 49 + return fmt.Errorf("failed to dial tls: %w", err) 50 + } 51 + defer conn.Close() 52 + 53 + c, err := smtp.NewClient(conn, host) 54 + if err != nil { 55 + return fmt.Errorf("failed to create smtp client: %w", err) 56 + } 57 + defer c.Quit() 58 + 59 + if user != "" && pass != "" { 60 + auth := smtp.PlainAuth("", user, pass, host) 61 + if err = c.Auth(auth); err != nil { 62 + return fmt.Errorf("auth failed: %w", err) 63 + } 64 + } 65 + 66 + if err = c.Mail(from); err != nil { 67 + return err 68 + } 69 + if err = c.Rcpt(to); err != nil { 70 + return err 71 + } 72 + w, err := c.Data() 73 + if err != nil { 74 + return err 75 + } 76 + _, err = w.Write(msg) 77 + if err != nil { 78 + return err 79 + } 80 + err = w.Close() 81 + if err != nil { 82 + return err 83 + } 84 + return nil 85 + } 86 + 87 + auth := smtp.PlainAuth("", user, pass, host) 88 + err := smtp.SendMail(addr, auth, from, []string{to}, msg) 89 + if err != nil { 90 + fmt.Printf("[Email] Failed to send: %v\n", err) 91 + return err 92 + } 93 + return nil 94 + } 95 + 96 + func SendVerificationEmail(to, token, username string) error { 97 + displayName := username 98 + if displayName == "" { 99 + displayName = "there" 100 + } 101 + url := fmt.Sprintf("%s/auth/verify-email?token=%s", os.Getenv("PUBLIC_URL"), token) 102 + subject := "Verify your email - boop.cat" 103 + body := buildEmailTemplate("Verify your email", 104 + fmt.Sprintf("Hey %s! Click the button below to verify your email address and activate your account.", displayName), 105 + "Verify Email", url, 106 + "This link expires in 24 hours.") 107 + return SendEmail(to, subject, body) 108 + } 109 + 110 + func SendPasswordResetEmail(to, token, username string) error { 111 + displayName := username 112 + if displayName == "" { 113 + displayName = "there" 114 + } 115 + url := fmt.Sprintf("%s/reset-password?token=%s", os.Getenv("PUBLIC_URL"), token) 116 + subject := "Reset your password - boop.cat" 117 + body := buildEmailTemplate("Reset your password", 118 + fmt.Sprintf("Hey %s! Someone requested a password reset for your account. Click the button below to set a new password.", displayName), 119 + "Reset Password", url, 120 + "This link expires in 1 hour. If you didn't request this, ignore this email.") 121 + return SendEmail(to, subject, body) 122 + } 123 + 124 + func buildEmailTemplate(heading, message, buttonText, buttonURL, footer string) string { 125 + brandName := "boop.cat" 126 + return fmt.Sprintf(`<!DOCTYPE html> 127 + <html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> 128 + <head> 129 + <meta charset="UTF-8"> 130 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 131 + <meta http-equiv="X-UA-Compatible" content="IE=edge"> 132 + <meta name="color-scheme" content="light dark"> 133 + <meta name="supported-color-schemes" content="light dark"> 134 + <title>%s</title> 135 + <!--[if mso]> 136 + <style type="text/css"> 137 + body, table, td, p, a, h1 {font-family: Arial, sans-serif !important;} 138 + </style> 139 + <![endif]--> 140 + <style> 141 + :root { color-scheme: light dark; } 142 + body { margin: 0; padding: 0; } 143 + @media (prefers-color-scheme: dark) { 144 + .email-bg { background-color: #1a1a2e !important; } 145 + .email-card { background-color: #16213e !important; } 146 + .email-title { color: #f1f5f9 !important; } 147 + .email-text { color: #cbd5e1 !important; } 148 + .email-muted { color: #94a3b8 !important; } 149 + .email-link { color: #60a5fa !important; } 150 + .email-divider { border-color: #334155 !important; } 151 + } 152 + </style> 153 + </head> 154 + <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased;"> 155 + <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" class="email-bg" style="background-color: #f1f5f9;"> 156 + <tr> 157 + <td align="center" style="padding: 40px 16px;"> 158 + <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width: 480px;"> 159 + <!-- Logo --> 160 + <tr> 161 + <td align="center" style="padding-bottom: 24px;"> 162 + <span class="email-title" style="font-size: 20px; font-weight: 700; color: #0f172a;">%s</span> 163 + </td> 164 + </tr> 165 + <!-- Card --> 166 + <tr> 167 + <td> 168 + <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" class="email-card" style="background-color: #ffffff; border-radius: 12px;"> 169 + <tr> 170 + <td style="padding: 32px 28px;"> 171 + <h1 class="email-title" style="margin: 0 0 8px; font-size: 22px; font-weight: 700; color: #0f172a; text-align: center;">%s</h1> 172 + <p class="email-text" style="margin: 0 0 24px; font-size: 15px; color: #475569; text-align: center; line-height: 1.6;">%s</p> 173 + <!-- Button --> 174 + <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0"> 175 + <tr> 176 + <td align="center" style="padding: 4px 0;"> 177 + <a href="%s" target="_blank" style="display: inline-block; padding: 12px 28px; background-color: #2563eb; color: #ffffff; font-size: 15px; font-weight: 600; text-decoration: none; border-radius: 8px;">%s</a> 178 + </td> 179 + </tr> 180 + </table> 181 + <p class="email-text" style="margin: 24px 0 0; font-size: 13px; color: #64748b; text-align: center; line-height: 1.6;"> 182 + Or copy this link into your browser:<br> 183 + <a href="%s" class="email-link" style="color: #2563eb; word-break: break-all; text-decoration: underline;">%s</a> 184 + </p> 185 + <table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="margin-top: 24px;"> 186 + <tr> 187 + <td class="email-divider" style="border-top: 1px solid #e2e8f0; padding-top: 16px;"> 188 + <p class="email-muted" style="margin: 0; font-size: 12px; color: #94a3b8; text-align: center;">%s</p> 189 + </td> 190 + </tr> 191 + </table> 192 + </td> 193 + </tr> 194 + </table> 195 + </td> 196 + </tr> 197 + <!-- Footer --> 198 + <tr> 199 + <td align="center" style="padding-top: 24px;"> 200 + <p class="email-muted" style="margin: 0; font-size: 13px; color: #64748b; line-height: 1.6;"> 201 + &copy; %d %s 202 + </p> 203 + </td> 204 + </tr> 205 + </table> 206 + </td> 207 + </tr> 208 + </table> 209 + </body> 210 + </html>`, heading, brandName, heading, message, buttonURL, buttonText, buttonURL, buttonURL, footer, time.Now().Year(), brandName) 211 + }
+129
backend-go/lib/encryption.go
··· 1 + package lib 2 + 3 + import ( 4 + "crypto/aes" 5 + "crypto/cipher" 6 + "crypto/rand" 7 + "crypto/sha256" 8 + "encoding/base64" 9 + "os" 10 + "strings" 11 + 12 + "golang.org/x/crypto/pbkdf2" 13 + ) 14 + 15 + const ( 16 + ivLength = 16 17 + authTagLength = 16 18 + saltLength = 16 19 + keyLength = 32 20 + ) 21 + 22 + func getEncryptionKey() []byte { 23 + secret := os.Getenv("ENV_ENCRYPTION_SECRET") 24 + if secret == "" { 25 + return nil 26 + } 27 + 28 + saltHash := sha256.Sum256([]byte(secret + ":salt")) 29 + salt := saltHash[:saltLength] 30 + 31 + return pbkdf2.Key([]byte(secret), salt, 100000, keyLength, sha256.New) 32 + } 33 + 34 + func IsEncryptionEnabled() bool { 35 + return os.Getenv("ENV_ENCRYPTION_SECRET") != "" 36 + } 37 + 38 + func Encrypt(plaintext string) string { 39 + if plaintext == "" { 40 + return plaintext 41 + } 42 + 43 + key := getEncryptionKey() 44 + if key == nil { 45 + 46 + return plaintext 47 + } 48 + 49 + block, err := aes.NewCipher(key) 50 + if err != nil { 51 + return plaintext 52 + } 53 + 54 + gcm, err := cipher.NewGCM(block) 55 + if err != nil { 56 + return plaintext 57 + } 58 + 59 + iv := make([]byte, gcm.NonceSize()) 60 + if _, err := rand.Read(iv); err != nil { 61 + return plaintext 62 + } 63 + 64 + ciphertext := gcm.Seal(nil, iv, []byte(plaintext), nil) 65 + 66 + authTag := ciphertext[len(ciphertext)-authTagLength:] 67 + encrypted := ciphertext[:len(ciphertext)-authTagLength] 68 + 69 + return "enc:v1:" + 70 + base64.StdEncoding.EncodeToString(iv) + ":" + 71 + base64.StdEncoding.EncodeToString(authTag) + ":" + 72 + base64.StdEncoding.EncodeToString(encrypted) 73 + } 74 + 75 + func Decrypt(ciphertext string) string { 76 + if ciphertext == "" { 77 + return ciphertext 78 + } 79 + 80 + if !strings.HasPrefix(ciphertext, "enc:v1:") { 81 + 82 + return ciphertext 83 + } 84 + 85 + key := getEncryptionKey() 86 + if key == nil { 87 + 88 + return ciphertext 89 + } 90 + 91 + parts := strings.Split(ciphertext, ":") 92 + if len(parts) != 5 { 93 + return ciphertext 94 + } 95 + 96 + iv, err := base64.StdEncoding.DecodeString(parts[2]) 97 + if err != nil { 98 + return ciphertext 99 + } 100 + 101 + authTag, err := base64.StdEncoding.DecodeString(parts[3]) 102 + if err != nil { 103 + return ciphertext 104 + } 105 + 106 + encrypted, err := base64.StdEncoding.DecodeString(parts[4]) 107 + if err != nil { 108 + return ciphertext 109 + } 110 + 111 + block, err := aes.NewCipher(key) 112 + if err != nil { 113 + return ciphertext 114 + } 115 + 116 + gcm, err := cipher.NewGCM(block) 117 + if err != nil { 118 + return ciphertext 119 + } 120 + 121 + fullCiphertext := append(encrypted, authTag...) 122 + 123 + plaintext, err := gcm.Open(nil, iv, fullCiphertext, nil) 124 + if err != nil { 125 + return ciphertext 126 + } 127 + 128 + return string(plaintext) 129 + }
+40
backend-go/lib/pkce.go
··· 1 + package lib 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + ) 8 + 9 + type PKCEChallenge struct { 10 + Verifier string 11 + Challenge string 12 + } 13 + 14 + func GeneratePKCE() PKCEChallenge { 15 + 16 + verifierBytes := make([]byte, 32) 17 + rand.Read(verifierBytes) 18 + 19 + verifier := base64.RawURLEncoding.EncodeToString(verifierBytes) 20 + 21 + hash := sha256.Sum256([]byte(verifier)) 22 + challenge := base64.RawURLEncoding.EncodeToString(hash[:]) 23 + 24 + return PKCEChallenge{ 25 + Verifier: verifier, 26 + Challenge: challenge, 27 + } 28 + } 29 + 30 + func GenerateSecureState() string { 31 + b := make([]byte, 32) 32 + rand.Read(b) 33 + return base64.RawURLEncoding.EncodeToString(b) 34 + } 35 + 36 + func GenerateSecureNonce() string { 37 + b := make([]byte, 16) 38 + rand.Read(b) 39 + return base64.RawURLEncoding.EncodeToString(b) 40 + }
+61
backend-go/lib/turnstile.go
··· 1 + package lib 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + "os" 8 + "strings" 9 + ) 10 + 11 + type TurnstileResult struct { 12 + OK bool 13 + Error string 14 + } 15 + 16 + func VerifyTurnstile(token, remoteIP string) TurnstileResult { 17 + secret := os.Getenv("TURNSTILE_SECRET_KEY") 18 + if secret == "" { 19 + 20 + return TurnstileResult{OK: true} 21 + } 22 + 23 + if token == "" { 24 + return TurnstileResult{OK: false, Error: "missing-token"} 25 + } 26 + 27 + data := url.Values{} 28 + data.Set("secret", secret) 29 + data.Set("response", token) 30 + if remoteIP != "" { 31 + data.Set("remoteip", remoteIP) 32 + } 33 + 34 + resp, err := http.Post( 35 + "https://challenges.cloudflare.com/turnstile/v0/siteverify", 36 + "application/x-www-form-urlencoded", 37 + strings.NewReader(data.Encode()), 38 + ) 39 + if err != nil { 40 + return TurnstileResult{OK: false, Error: "verification-failed"} 41 + } 42 + defer resp.Body.Close() 43 + 44 + var result struct { 45 + Success bool `json:"success"` 46 + ErrorCodes []string `json:"error-codes"` 47 + } 48 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 49 + return TurnstileResult{OK: false, Error: "parse-failed"} 50 + } 51 + 52 + if !result.Success { 53 + errCode := "captcha-failed" 54 + if len(result.ErrorCodes) > 0 { 55 + errCode = result.ErrorCodes[0] 56 + } 57 + return TurnstileResult{OK: false, Error: errCode} 58 + } 59 + 60 + return TurnstileResult{OK: true} 61 + }
+167
backend-go/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "os" 7 + "path/filepath" 8 + "time" 9 + 10 + "github.com/go-chi/chi/v5" 11 + chimiddleware "github.com/go-chi/chi/v5/middleware" 12 + "github.com/go-chi/cors" 13 + "github.com/joho/godotenv" 14 + 15 + "boop-cat/config" 16 + "boop-cat/db" 17 + "boop-cat/handlers" 18 + "boop-cat/lib" 19 + "boop-cat/middleware" 20 + "boop-cat/oauth" 21 + ) 22 + 23 + func main() { 24 + 25 + _ = godotenv.Load() 26 + _ = godotenv.Load("../.env") 27 + 28 + lib.StartDMCAMonitor() 29 + 30 + cfg := config.Load() 31 + 32 + if cfg.SessionSecret == "" { 33 + fmt.Fprintln(os.Stderr, "Missing SESSION_SECRET. Generate one: openssl rand -base64 32") 34 + os.Exit(1) 35 + } 36 + 37 + database, err := db.GetDB(cfg.DBPath) 38 + if err != nil { 39 + fmt.Fprintf(os.Stderr, "Failed to initialize database: %v\n", err) 40 + os.Exit(1) 41 + } 42 + 43 + r := chi.NewRouter() 44 + 45 + middleware.InitSessionStore(cfg.SessionSecret, cfg.CookieSecure) 46 + 47 + oauth.InitProviders(cfg.SessionSecret) 48 + 49 + r.Use(chimiddleware.Logger) 50 + r.Use(chimiddleware.Recoverer) 51 + r.Use(chimiddleware.RealIP) 52 + r.Use(middleware.WithUser(database)) 53 + r.Use(middleware.RateLimit(100, 60*time.Second)) 54 + 55 + r.Use(cors.Handler(cors.Options{ 56 + AllowedOrigins: []string{"*"}, 57 + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, 58 + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, 59 + ExposedHeaders: []string{"Link"}, 60 + AllowCredentials: true, 61 + MaxAge: 300, 62 + })) 63 + 64 + r.Get("/api/health", func(w http.ResponseWriter, r *http.Request) { 65 + w.Header().Set("Content-Type", "application/json") 66 + w.Write([]byte(`{"ok":true,"backend":"go"}`)) 67 + }) 68 + 69 + r.Get("/api/config", func(w http.ResponseWriter, r *http.Request) { 70 + w.Header().Set("Content-Type", "application/json") 71 + fmt.Fprintf(w, `{"deliveryMode":"%s","edgeRootDomain":"%s"}`, 72 + cfg.DeliveryMode, cfg.EdgeRootDomain) 73 + }) 74 + 75 + deployHandler := handlers.NewDeployHandler(database) 76 + 77 + authHandler := handlers.NewAuthHandler(database, deployHandler.Engine) 78 + r.Mount("/api/auth", authHandler.Routes()) 79 + r.Route("/auth", func(r chi.Router) { 80 + authHandler.MountOAuthRoutes(r) 81 + }) 82 + r.Get("/api/github/repos", authHandler.GetGitHubRepos) 83 + r.Get("/github/installed", authHandler.GitHubInstalled) 84 + 85 + apiKeysHandler := handlers.NewAPIKeysHandler(database) 86 + r.Mount("/api/api-keys", apiKeysHandler.Routes()) 87 + 88 + sitesHandler := handlers.NewSitesHandler(database, deployHandler.Engine) 89 + 90 + r.Mount("/api/account", handlers.NewAccountHandler(database).Routes()) 91 + 92 + cdHandler := handlers.NewCustomDomainHandler(database, deployHandler.Engine) 93 + 94 + r.Route("/api/sites", func(r chi.Router) { 95 + r.Use(middleware.RequireLogin) 96 + 97 + r.Get("/", sitesHandler.ListSites) 98 + r.Post("/", sitesHandler.CreateSite) 99 + 100 + r.Route("/{siteId}", func(r chi.Router) { 101 + r.Patch("/", sitesHandler.UpdateSiteEnv) 102 + r.Patch("/settings", sitesHandler.UpdateSiteSettings) 103 + r.Put("/settings", sitesHandler.UpdateSiteSettings) 104 + r.Post("/settings", sitesHandler.UpdateSiteSettings) 105 + r.Delete("/", sitesHandler.DeleteSite) 106 + 107 + r.Post("/deploy", deployHandler.TriggerDeploy) 108 + r.Get("/deployments", deployHandler.ListDeployments) 109 + 110 + r.Get("/custom-domains", cdHandler.ListCustomDomains) 111 + r.Post("/custom-domains", cdHandler.CreateCustomDomain) 112 + r.Delete("/custom-domains/{id}", cdHandler.DeleteCustomDomain) 113 + r.Post("/custom-domains/{id}/poll", cdHandler.PollCustomDomain) 114 + }) 115 + 116 + r.Post("/preview", deployHandler.PreviewSite) 117 + }) 118 + 119 + r.Route("/api/deployments/{id}", func(r chi.Router) { 120 + r.Use(middleware.RequireLogin) 121 + r.Get("/", deployHandler.GetDeployment) 122 + r.Get("/logs", deployHandler.GetDeploymentLogs) 123 + r.Post("/stop", deployHandler.StopDeployment) 124 + }) 125 + 126 + r.Delete("/api/account", func(w http.ResponseWriter, r *http.Request) { 127 + middleware.RequireLogin(http.HandlerFunc(authHandler.DeleteAccount)).ServeHTTP(w, r) 128 + }) 129 + 130 + ghWebhookHandler := handlers.NewGitHubWebhookHandler(database, deployHandler.Engine) 131 + r.Mount("/api/github/webhook", ghWebhookHandler.Routes()) 132 + 133 + apiV1Handler := handlers.NewAPIV1Handler(database, deployHandler.Engine) 134 + r.Mount("/api/v1", apiV1Handler.Routes()) 135 + 136 + adminHandler := handlers.NewAdminHandler(database) 137 + r.Mount("/api/admin", adminHandler.Routes()) 138 + 139 + atprotoHandler := handlers.NewATProtoHandler(database) 140 + r.Get("/client-metadata.json", atprotoHandler.ServeClientMetadata) 141 + r.Get("/jwks.json", atprotoHandler.ServeJWKS) 142 + r.Get("/auth/atproto", atprotoHandler.BeginAuth) 143 + r.Get("/auth/atproto/callback", atprotoHandler.Callback) 144 + 145 + clientDist := filepath.Join("..", "client", "dist") 146 + if _, err := os.Stat(clientDist); err == nil { 147 + 148 + fs := http.FileServer(http.Dir(clientDist)) 149 + r.Handle("/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 150 + 151 + path := filepath.Join(clientDist, r.URL.Path) 152 + if _, err := os.Stat(path); os.IsNotExist(err) { 153 + 154 + http.ServeFile(w, r, filepath.Join(clientDist, "index.html")) 155 + return 156 + } 157 + fs.ServeHTTP(w, r) 158 + })) 159 + } 160 + 161 + addr := fmt.Sprintf(":%d", cfg.Port) 162 + fmt.Printf("boop.cat (Go) listening on http://127.0.0.1%s\n", addr) 163 + if err := http.ListenAndServe(addr, r); err != nil { 164 + fmt.Fprintf(os.Stderr, "Server error: %v\n", err) 165 + os.Exit(1) 166 + } 167 + }
+68
backend-go/middleware/auth.go
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "net/http" 7 + "strings" 8 + 9 + "boop-cat/db" 10 + ) 11 + 12 + type ContextKey string 13 + 14 + const ( 15 + UserContextKey ContextKey = "user" 16 + 17 + APIKeyIDContextKey ContextKey = "apiKeyId" 18 + ) 19 + 20 + func RequireAPIKey(database *sql.DB) func(http.Handler) http.Handler { 21 + return func(next http.Handler) http.Handler { 22 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 + authHeader := r.Header.Get("Authorization") 24 + if !strings.HasPrefix(authHeader, "Bearer ") { 25 + http.Error(w, `{"error":"missing-api-key","message":"Authorization header with Bearer token required"}`, http.StatusUnauthorized) 26 + return 27 + } 28 + 29 + key := strings.TrimPrefix(authHeader, "Bearer ") 30 + if !strings.HasPrefix(key, "sk_") { 31 + http.Error(w, `{"error":"invalid-api-key","message":"Invalid or expired API key"}`, http.StatusUnauthorized) 32 + return 33 + } 34 + 35 + user, keyID, err := db.ValidateAPIKey(database, key) 36 + if err != nil { 37 + http.Error(w, `{"error":"invalid-api-key","message":"Invalid or expired API key"}`, http.StatusUnauthorized) 38 + return 39 + } 40 + 41 + ctx := context.WithValue(r.Context(), UserContextKey, user) 42 + ctx = context.WithValue(ctx, APIKeyIDContextKey, keyID) 43 + next.ServeHTTP(w, r.WithContext(ctx)) 44 + }) 45 + } 46 + } 47 + 48 + func GetUser(ctx context.Context) *db.User { 49 + if user, ok := ctx.Value(UserContextKey).(*db.User); ok { 50 + return user 51 + } 52 + return nil 53 + } 54 + 55 + func GetAPIKeyID(ctx context.Context) string { 56 + if keyID, ok := ctx.Value(APIKeyIDContextKey).(string); ok { 57 + return keyID 58 + } 59 + return "" 60 + } 61 + 62 + func GetUserID(ctx context.Context) string { 63 + user := GetUser(ctx) 64 + if user != nil { 65 + return user.ID 66 + } 67 + return "" 68 + }
+76
backend-go/middleware/ratelimit.go
··· 1 + package middleware 2 + 3 + import ( 4 + "net/http" 5 + "sync" 6 + "time" 7 + ) 8 + 9 + type visitor struct { 10 + limiter *rateLimiter 11 + lastSeen time.Time 12 + } 13 + 14 + type rateLimiter struct { 15 + rate int 16 + per time.Duration 17 + tokens int 18 + last time.Time 19 + mu sync.Mutex 20 + } 21 + 22 + func newRateLimiter(r int, d time.Duration) *rateLimiter { 23 + return &rateLimiter{ 24 + rate: r, 25 + per: d, 26 + tokens: r, 27 + last: time.Now(), 28 + } 29 + } 30 + 31 + func (l *rateLimiter) Allow() bool { 32 + l.mu.Lock() 33 + defer l.mu.Unlock() 34 + 35 + now := time.Now() 36 + 37 + elapsed := now.Sub(l.last) 38 + if elapsed > l.per { 39 + l.tokens = l.rate 40 + l.last = now 41 + } 42 + 43 + if l.tokens > 0 { 44 + l.tokens-- 45 + return true 46 + } 47 + return false 48 + } 49 + 50 + var ( 51 + visitors = make(map[string]*rateLimiter) 52 + mu sync.Mutex 53 + ) 54 + 55 + func RateLimit(requests int, window time.Duration) func(http.Handler) http.Handler { 56 + return func(next http.Handler) http.Handler { 57 + 58 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 + ip := r.RemoteAddr 60 + 61 + mu.Lock() 62 + limiter, exists := visitors[ip] 63 + if !exists { 64 + limiter = newRateLimiter(requests, window) 65 + visitors[ip] = limiter 66 + } 67 + mu.Unlock() 68 + 69 + if !limiter.Allow() { 70 + http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) 71 + return 72 + } 73 + next.ServeHTTP(w, r) 74 + }) 75 + } 76 + }
+85
backend-go/middleware/session.go
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "net/http" 7 + 8 + "boop-cat/db" 9 + "github.com/gorilla/sessions" 10 + ) 11 + 12 + var store *sessions.CookieStore 13 + 14 + func InitSessionStore(secret string, secure bool) { 15 + store = sessions.NewCookieStore([]byte(secret)) 16 + store.Options = &sessions.Options{ 17 + Path: "/", 18 + MaxAge: 86400 * 30, 19 + HttpOnly: true, 20 + Secure: secure, 21 + SameSite: http.SameSiteLaxMode, 22 + } 23 + } 24 + 25 + func GetSession(r *http.Request) (*sessions.Session, error) { 26 + return store.Get(r, "fsd-session") 27 + } 28 + 29 + func WithUser(database *sql.DB) func(http.Handler) http.Handler { 30 + return func(next http.Handler) http.Handler { 31 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 + session, err := GetSession(r) 33 + if err != nil { 34 + next.ServeHTTP(w, r) 35 + return 36 + } 37 + 38 + userID, ok := session.Values["userId"].(string) 39 + if !ok || userID == "" { 40 + next.ServeHTTP(w, r) 41 + return 42 + } 43 + 44 + user, err := db.GetUserByID(database, userID) 45 + if err == nil && user != nil && !user.Banned { 46 + 47 + u := &db.User{ 48 + ID: user.ID, 49 + Email: user.Email, 50 + Username: user.Username, 51 + EmailVerified: user.EmailVerified, 52 + Banned: user.Banned, 53 + } 54 + ctx := context.WithValue(r.Context(), UserContextKey, u) 55 + next.ServeHTTP(w, r.WithContext(ctx)) 56 + } else { 57 + next.ServeHTTP(w, r) 58 + } 59 + }) 60 + } 61 + } 62 + 63 + func LoginUser(w http.ResponseWriter, r *http.Request, userID string) error { 64 + session, _ := GetSession(r) 65 + session.Values["userId"] = userID 66 + return session.Save(r, w) 67 + } 68 + 69 + func LogoutUser(w http.ResponseWriter, r *http.Request) error { 70 + session, _ := GetSession(r) 71 + session.Values["userId"] = "" 72 + session.Options.MaxAge = -1 73 + return session.Save(r, w) 74 + } 75 + 76 + func RequireLogin(next http.Handler) http.Handler { 77 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 78 + user := GetUser(r.Context()) 79 + if user == nil { 80 + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) 81 + return 82 + } 83 + next.ServeHTTP(w, r) 84 + }) 85 + }
+63
backend-go/oauth/providers.go
··· 1 + package oauth 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "os" 7 + "strings" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "github.com/gorilla/sessions" 11 + "github.com/markbates/goth" 12 + "github.com/markbates/goth/gothic" 13 + "github.com/markbates/goth/providers/github" 14 + "github.com/markbates/goth/providers/google" 15 + ) 16 + 17 + func InitProviders(sessionSecret string) { 18 + 19 + store := sessions.NewCookieStore([]byte(sessionSecret)) 20 + store.Options.HttpOnly = true 21 + store.Options.Secure = os.Getenv("NODE_ENV") == "production" 22 + gothic.Store = store 23 + 24 + publicURL := os.Getenv("PUBLIC_URL") 25 + if publicURL == "" { 26 + publicURL = "http://localhost:8080" 27 + } 28 + 29 + publicURL = strings.TrimRight(publicURL, "/") 30 + 31 + githubCallback := os.Getenv("GITHUB_CALLBACK_URL") 32 + if githubCallback == "" { 33 + githubCallback = fmt.Sprintf("%s/auth/github/callback", publicURL) 34 + } 35 + 36 + googleCallback := os.Getenv("GOOGLE_CALLBACK_URL") 37 + if googleCallback == "" { 38 + googleCallback = fmt.Sprintf("%s/auth/google/callback", publicURL) 39 + } 40 + 41 + goth.UseProviders( 42 + github.New( 43 + os.Getenv("GITHUB_CLIENT_ID"), 44 + os.Getenv("GITHUB_CLIENT_SECRET"), 45 + githubCallback, 46 + "read:user", "user:email", "repo", 47 + ), 48 + google.New( 49 + os.Getenv("GOOGLE_CLIENT_ID"), 50 + os.Getenv("GOOGLE_CLIENT_SECRET"), 51 + googleCallback, 52 + "email", "profile", 53 + ), 54 + ) 55 + 56 + gothic.GetProviderName = func(req *http.Request) (string, error) { 57 + provider := chi.URLParam(req, "provider") 58 + if provider == "" { 59 + return "", nil 60 + } 61 + return provider, nil 62 + } 63 + }
+42
client/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 + <link rel="icon" type="image/png" href="/milly.png" /> 7 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 + <link href="https://fonts.googleapis.com/css2?family=Kumbh+Sans:wght@100..900&display=swap" rel="stylesheet" /> 10 + <title>boop.cat - Free Static Hosting</title> 11 + <meta 12 + name="description" 13 + content="Free, fast static site hosting for developers. Deploy directly from GitHub with zero config. Custom domains, SSL, and unlimited bandwidth included." 14 + /> 15 + <meta name="theme-color" content="#e88978" /> 16 + 17 + <!-- Open Graph / Facebook --> 18 + <meta property="og:type" content="website" /> 19 + <meta property="og:url" content="https://boop.cat/" /> 20 + <meta property="og:title" content="boop.cat - Free Static Hosting" /> 21 + <meta 22 + property="og:description" 23 + content="Free, fast static site hosting for developers. Deploy directly from GitHub with zero config." 24 + /> 25 + <meta property="og:image" content="https://boop.cat/milly.png" /> 26 + 27 + <!-- Twitter --> 28 + <meta property="twitter:card" content="summary_large_image" /> 29 + <meta property="twitter:url" content="https://boop.cat/" /> 30 + <meta property="twitter:title" content="boop.cat - Free Static Hosting" /> 31 + <meta 32 + property="twitter:description" 33 + content="Free, fast static site hosting for developers. Deploy directly from GitHub with zero config." 34 + /> 35 + <meta property="twitter:image" content="https://boop.cat/milly.png" /> 36 + </head> 37 + 38 + <body> 39 + <div id="root"></div> 40 + <script type="module" src="/src/main.jsx"></script> 41 + </body> 42 + </html>
client/public/milly.png

This is a binary file and will not be displayed.

+376
client/src/App.jsx
··· 1 + import React from 'react'; 2 + import { Routes, Route, Link, useNavigate } from 'react-router-dom'; 3 + import Login from './pages/Login.jsx'; 4 + import Signup from './pages/Signup.jsx'; 5 + import ResetPassword from './pages/ResetPassword.jsx'; 6 + import DashboardLayout from './pages/DashboardLayout.jsx'; 7 + import DashboardHome from './pages/DashboardHome.jsx'; 8 + import DashboardSite from './pages/DashboardSite.jsx'; 9 + import NewSite from './pages/NewSite.jsx'; 10 + import Account from './pages/Account.jsx'; 11 + import Tos from './pages/Tos.jsx'; 12 + import Privacy from './pages/Privacy.jsx'; 13 + import Dmca from './pages/Dmca.jsx'; 14 + import ApiDocs from './pages/ApiDocs.jsx'; 15 + import ThemeToggle from './components/ThemeToggle.jsx'; 16 + 17 + class ErrorBoundary extends React.Component { 18 + constructor(props) { 19 + super(props); 20 + this.state = { error: null }; 21 + } 22 + 23 + static getDerivedStateFromError(error) { 24 + return { error }; 25 + } 26 + 27 + render() { 28 + if (this.state.error) { 29 + return ( 30 + <div className="center"> 31 + <div style={{ fontSize: 64, marginBottom: 16 }}>😵</div> 32 + <h1 className="title" style={{ fontSize: 36 }}> 33 + Something went wrong 34 + </h1> 35 + <div className="muted" style={{ maxWidth: 480, marginBottom: 24 }}> 36 + {String(this.state.error?.message || this.state.error)} 37 + </div> 38 + <div className="buttons"> 39 + <button className="btn primary" onClick={() => window.location.reload()}> 40 + Refresh page 41 + </button> 42 + <a className="btn ghost" href="/"> 43 + Go home 44 + </a> 45 + </div> 46 + </div> 47 + ); 48 + } 49 + return this.props.children; 50 + } 51 + } 52 + 53 + function Landing() { 54 + const [authChecked, setAuthChecked] = React.useState(false); 55 + const [isAuthed, setIsAuthed] = React.useState(false); 56 + const [navHidden, setNavHidden] = React.useState(false); 57 + const lastScrollY = React.useRef(0); 58 + 59 + React.useEffect(() => { 60 + fetch('/api/auth/me', { credentials: 'same-origin' }) 61 + .then((r) => r.json()) 62 + .then((d) => { 63 + setIsAuthed(Boolean(d?.authenticated)); 64 + setAuthChecked(true); 65 + }) 66 + .catch(() => { 67 + setIsAuthed(false); 68 + setAuthChecked(true); 69 + }); 70 + }, []); 71 + 72 + React.useEffect(() => { 73 + const handleScroll = () => { 74 + const currentScrollY = window.scrollY; 75 + if (currentScrollY > lastScrollY.current && currentScrollY > 80) { 76 + setNavHidden(true); 77 + } else { 78 + setNavHidden(false); 79 + } 80 + lastScrollY.current = currentScrollY; 81 + }; 82 + window.addEventListener('scroll', handleScroll, { passive: true }); 83 + return () => window.removeEventListener('scroll', handleScroll); 84 + }, []); 85 + 86 + return ( 87 + <div className="landing-page"> 88 + {} 89 + <nav className={`navbar-frame ${navHidden ? 'nav-hidden' : ''}`}> 90 + <div className="navbar-content"> 91 + <Link to="/" className="navbar-logo"> 92 + <img src="/milly.png" alt="" width="28" height="28" style={{ imageRendering: 'pixelated' }} /> 93 + <span>boop.cat</span> 94 + </Link> 95 + <div className="navbar-buttons"> 96 + <ThemeToggle /> 97 + {authChecked && isAuthed ? ( 98 + <Link to="/dashboard" className="glass-btn"> 99 + <svg 100 + width="18" 101 + height="18" 102 + viewBox="0 0 24 24" 103 + fill="none" 104 + stroke="currentColor" 105 + strokeWidth="2" 106 + strokeLinecap="round" 107 + strokeLinejoin="round" 108 + > 109 + <rect x="3" y="3" width="7" height="7" /> 110 + <rect x="14" y="3" width="7" height="7" /> 111 + <rect x="14" y="14" width="7" height="7" /> 112 + <rect x="3" y="14" width="7" height="7" /> 113 + </svg> 114 + <span>Dashboard</span> 115 + </Link> 116 + ) : ( 117 + <> 118 + <Link to="/login" className="glass-btn"> 119 + <svg 120 + width="18" 121 + height="18" 122 + viewBox="0 0 24 24" 123 + fill="none" 124 + stroke="currentColor" 125 + strokeWidth="2" 126 + strokeLinecap="round" 127 + strokeLinejoin="round" 128 + > 129 + <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /> 130 + <polyline points="10 17 15 12 10 7" /> 131 + <line x1="15" y1="12" x2="3" y2="12" /> 132 + </svg> 133 + <span>Log in</span> 134 + </Link> 135 + <Link to="/signup" className="glass-btn accent"> 136 + <svg 137 + width="18" 138 + height="18" 139 + viewBox="0 0 24 24" 140 + fill="none" 141 + stroke="currentColor" 142 + strokeWidth="2" 143 + strokeLinecap="round" 144 + strokeLinejoin="round" 145 + > 146 + <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /> 147 + <circle cx="8.5" cy="7" r="4" /> 148 + <line x1="20" y1="8" x2="20" y2="14" /> 149 + <line x1="23" y1="11" x2="17" y2="11" /> 150 + </svg> 151 + <span>Sign up</span> 152 + </Link> 153 + </> 154 + )} 155 + </div> 156 + </div> 157 + </nav> 158 + 159 + {} 160 + <main className="page-frame"> 161 + {} 162 + <section className="hero-section"> 163 + <div className="hero-icon"> 164 + <img src="/milly.png" alt="" width="80" height="80" style={{ imageRendering: 'pixelated' }} /> 165 + </div> 166 + <h1>Static hosting, simplified</h1> 167 + <p className="hero-desc"> 168 + Push your code. We handle the rest. Free static site hosting powered by open source. 169 + </p> 170 + <div className="hero-buttons"> 171 + {authChecked && isAuthed ? ( 172 + <Link to="/dashboard" className="glass-btn accent large"> 173 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> 174 + <rect x="3" y="3" width="7" height="7" /> 175 + <rect x="14" y="3" width="7" height="7" /> 176 + <rect x="14" y="14" width="7" height="7" /> 177 + <rect x="3" y="14" width="7" height="7" /> 178 + </svg> 179 + <span>Go to Dashboard</span> 180 + </Link> 181 + ) : ( 182 + <> 183 + <Link to="/signup" className="glass-btn accent large"> 184 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> 185 + <path d="M5 12h14" /> 186 + <path d="M12 5v14" /> 187 + </svg> 188 + <span>Start for free</span> 189 + </Link> 190 + <Link to="/login" className="glass-btn large"> 191 + <span>I have an account</span> 192 + </Link> 193 + </> 194 + )} 195 + </div> 196 + </section> 197 + 198 + {} 199 + <section className="content-section"> 200 + <div className="features-row"> 201 + <div className="block-card" style={{ '--card-color': '#B8D4E8' }}> 202 + <div className="card-icon-wrap"> 203 + <svg 204 + width="28" 205 + height="28" 206 + viewBox="0 0 24 24" 207 + fill="none" 208 + stroke="currentColor" 209 + strokeWidth="2" 210 + strokeLinecap="round" 211 + strokeLinejoin="round" 212 + > 213 + <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /> 214 + </svg> 215 + </div> 216 + <h3>Instant deploys</h3> 217 + <p>Connect your Git repo and every push goes live automatically.</p> 218 + </div> 219 + 220 + <div className="block-card" style={{ '--card-color': '#D4E8B8' }}> 221 + <div className="card-icon-wrap"> 222 + <svg 223 + width="28" 224 + height="28" 225 + viewBox="0 0 24 24" 226 + fill="none" 227 + stroke="currentColor" 228 + strokeWidth="2" 229 + strokeLinecap="round" 230 + strokeLinejoin="round" 231 + > 232 + <rect x="2" y="3" width="20" height="14" rx="2" ry="2" /> 233 + <line x1="8" y1="21" x2="16" y2="21" /> 234 + <line x1="12" y1="17" x2="12" y2="21" /> 235 + </svg> 236 + </div> 237 + <h3>Any framework</h3> 238 + <p>Vite, Next.js, Astro, Hugo, if it outputs HTML, it works.</p> 239 + </div> 240 + 241 + <div className="block-card" style={{ '--card-color': '#E8D4B8' }}> 242 + <div className="card-icon-wrap"> 243 + <svg 244 + width="28" 245 + height="28" 246 + viewBox="0 0 24 24" 247 + fill="none" 248 + stroke="currentColor" 249 + strokeWidth="2" 250 + strokeLinecap="round" 251 + strokeLinejoin="round" 252 + > 253 + <rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> 254 + <path d="M7 11V7a5 5 0 0 1 10 0v4" /> 255 + </svg> 256 + </div> 257 + <h3>SSL included</h3> 258 + <p>Every site gets HTTPS. No configuration needed.</p> 259 + </div> 260 + 261 + <div className="block-card" style={{ '--card-color': '#E8B8D4' }}> 262 + <div className="card-icon-wrap"> 263 + <svg 264 + width="28" 265 + height="28" 266 + viewBox="0 0 24 24" 267 + fill="none" 268 + stroke="currentColor" 269 + strokeWidth="2" 270 + strokeLinecap="round" 271 + strokeLinejoin="round" 272 + > 273 + <circle cx="12" cy="12" r="10" /> 274 + <line x1="2" y1="12" x2="22" y2="12" /> 275 + <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /> 276 + </svg> 277 + </div> 278 + <h3>Edge delivery</h3> 279 + <p>Your sites cached globally for fast loads everywhere.</p> 280 + </div> 281 + </div> 282 + </section> 283 + 284 + {} 285 + <section className="content-section"> 286 + <h2 className="section-title">How it works</h2> 287 + <div className="steps-row"> 288 + <div className="step-card" style={{ '--card-color': '#C8E0F0' }}> 289 + <span className="step-num">1</span> 290 + <h3>Add your repo</h3> 291 + <p>Paste a GitHub, GitLab, or any public Git URL</p> 292 + </div> 293 + <div className="step-arrow"> 294 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> 295 + <path d="M5 12h14M12 5l7 7-7 7" /> 296 + </svg> 297 + </div> 298 + <div className="step-card" style={{ '--card-color': '#D0F0C8' }}> 299 + <span className="step-num">2</span> 300 + <h3>Set build options</h3> 301 + <p>Choose your build command and output folder</p> 302 + </div> 303 + <div className="step-arrow"> 304 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> 305 + <path d="M5 12h14M12 5l7 7-7 7" /> 306 + </svg> 307 + </div> 308 + <div className="step-card" style={{ '--card-color': '#F0D0C8' }}> 309 + <span className="step-num">3</span> 310 + <h3>You're live</h3> 311 + <p>Get a URL instantly, add custom domains later</p> 312 + </div> 313 + </div> 314 + </section> 315 + 316 + {} 317 + <section className="content-section"> 318 + <h2 className="section-title">Works with your stack</h2> 319 + <div className="frameworks-row"> 320 + {[ 321 + { name: 'React', color: '#61DAFB20' }, 322 + { name: 'Vue', color: '#42b88320' }, 323 + { name: 'Svelte', color: '#FF3E0020' }, 324 + { name: 'Astro', color: '#FF5D0120' }, 325 + { name: 'Next.js', color: '#00000015' }, 326 + { name: 'Vite', color: '#646CFF20' } 327 + ].map((fw) => ( 328 + <div key={fw.name} className="framework-chip" style={{ background: fw.color }}> 329 + {fw.name} 330 + </div> 331 + ))} 332 + </div> 333 + </section> 334 + </main> 335 + 336 + {} 337 + <footer className="site-footer"> 338 + <div className="footer-inner"> 339 + <span>© 2025 boop.cat</span> 340 + <div className="footer-links"> 341 + <Link to="/tos">Terms</Link> 342 + <Link to="/privacy">Privacy</Link> 343 + <Link to="/dmca">DMCA</Link> 344 + <a href="https://ko-fi.com/P5P1VMR1D" target="_blank" rel="noopener noreferrer"> 345 + Donate 346 + </a> 347 + </div> 348 + </div> 349 + </footer> 350 + </div> 351 + ); 352 + } 353 + 354 + export default function App() { 355 + return ( 356 + <ErrorBoundary> 357 + <Routes> 358 + <Route path="/" element={<Landing />} /> 359 + <Route path="/login" element={<Login />} /> 360 + <Route path="/signup" element={<Signup />} /> 361 + <Route path="/reset-password" element={<ResetPassword />} /> 362 + <Route path="/tos" element={<Tos />} /> 363 + <Route path="/privacy" element={<Privacy />} /> 364 + <Route path="/dmca" element={<Dmca />} /> 365 + 366 + <Route path="/dashboard" element={<DashboardLayout />}> 367 + <Route index element={<DashboardHome />} /> 368 + <Route path="new" element={<NewSite />} /> 369 + <Route path="site/:id" element={<DashboardSite />} /> 370 + <Route path="account" element={<Account />} /> 371 + <Route path="api-docs" element={<ApiDocs />} /> 372 + </Route> 373 + </Routes> 374 + </ErrorBoundary> 375 + ); 376 + }
+16
client/src/components/MillyLogo.jsx
··· 1 + import React from 'react'; 2 + export default function MillyLogo({ size = 24, style = {}, ...props }) { 3 + return ( 4 + <img 5 + src="/milly.png" 6 + alt="Milly mascot" 7 + width={size} 8 + height={size} 9 + style={{ imageRendering: 'pixelated', display: 'inline-block', ...style }} 10 + onError={(e) => { 11 + e.target.style.display = 'none'; 12 + }} 13 + {...props} 14 + /> 15 + ); 16 + }
+76
client/src/components/ThemeToggle.jsx
··· 1 + import React, { useEffect, useState } from 'react'; 2 + 3 + export default function ThemeToggle({ className = '' }) { 4 + const [theme, setTheme] = useState(() => { 5 + if (typeof window !== 'undefined') { 6 + const stored = localStorage.getItem('theme'); 7 + if (stored) return stored; 8 + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 9 + } 10 + return 'light'; 11 + }); 12 + 13 + useEffect(() => { 14 + document.documentElement.setAttribute('data-theme', theme); 15 + localStorage.setItem('theme', theme); 16 + }, [theme]); 17 + 18 + useEffect(() => { 19 + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 20 + const handleChange = (e) => { 21 + const stored = localStorage.getItem('theme'); 22 + 23 + if (!stored) { 24 + setTheme(e.matches ? 'dark' : 'light'); 25 + } 26 + }; 27 + mediaQuery.addEventListener('change', handleChange); 28 + return () => mediaQuery.removeEventListener('change', handleChange); 29 + }, []); 30 + 31 + const toggleTheme = () => { 32 + setTheme((prev) => (prev === 'light' ? 'dark' : 'light')); 33 + }; 34 + 35 + return ( 36 + <button 37 + className={`themeToggle ${className}`} 38 + onClick={toggleTheme} 39 + aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} 40 + title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} 41 + > 42 + {} 43 + <svg 44 + className="sunIcon" 45 + viewBox="0 0 24 24" 46 + fill="none" 47 + stroke="currentColor" 48 + strokeWidth="2" 49 + strokeLinecap="round" 50 + strokeLinejoin="round" 51 + > 52 + <circle cx="12" cy="12" r="5" /> 53 + <line x1="12" y1="1" x2="12" y2="3" /> 54 + <line x1="12" y1="21" x2="12" y2="23" /> 55 + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /> 56 + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /> 57 + <line x1="1" y1="12" x2="3" y2="12" /> 58 + <line x1="21" y1="12" x2="23" y2="12" /> 59 + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /> 60 + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" /> 61 + </svg> 62 + {} 63 + <svg 64 + className="moonIcon" 65 + viewBox="0 0 24 24" 66 + fill="none" 67 + stroke="currentColor" 68 + strokeWidth="2" 69 + strokeLinecap="round" 70 + strokeLinejoin="round" 71 + > 72 + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> 73 + </svg> 74 + </button> 75 + ); 76 + }
+20
client/src/main.jsx
··· 1 + import React from 'react'; 2 + import ReactDOM from 'react-dom/client'; 3 + import { BrowserRouter } from 'react-router-dom'; 4 + import App from './App.jsx'; 5 + import './styles.css'; 6 + 7 + const savedTheme = localStorage.getItem('theme'); 8 + if (savedTheme) { 9 + document.documentElement.setAttribute('data-theme', savedTheme); 10 + } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { 11 + document.documentElement.setAttribute('data-theme', 'dark'); 12 + } 13 + 14 + ReactDOM.createRoot(document.getElementById('root')).render( 15 + <React.StrictMode> 16 + <BrowserRouter> 17 + <App /> 18 + </BrowserRouter> 19 + </React.StrictMode> 20 + );
+771
client/src/pages/Account.jsx
··· 1 + import React, { useEffect, useState } from 'react'; 2 + import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'; 3 + import { Mail, Lock, AlertTriangle, Link2, Unlink, Key, Copy, Check, Trash2 } from 'lucide-react'; 4 + 5 + const ERROR_MESSAGES = { 6 + 'email-required': 'Please enter your email address.', 7 + 'invalid-email-format': 'Please enter a valid email address.', 8 + 'email-too-long': 'Email address is too long.', 9 + 'email-already-registered': 'This email is already in use.', 10 + 'password-required': 'Please enter your password.', 11 + 'password-too-short': 'Password must be at least 8 characters.', 12 + 'password-too-long': 'Password is too long.', 13 + 'invalid-password': 'Current password is incorrect.', 14 + 'password-not-set': 'No password set. You signed up with OAuth.', 15 + 'user-not-found': 'User not found.', 16 + 'cannot-unlink-only-auth': 'Cannot unlink your only login method. Set a password first.', 17 + 'account-not-found': 'Linked account not found.' 18 + }; 19 + 20 + const PROVIDER_INFO = { 21 + github: { 22 + name: 'GitHub', 23 + icon: ( 24 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 25 + <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> 26 + </svg> 27 + ), 28 + color: '#333' 29 + }, 30 + google: { 31 + name: 'Google', 32 + icon: ( 33 + <svg width="20" height="20" viewBox="0 0 24 24"> 34 + <path 35 + fill="#4285F4" 36 + d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" 37 + /> 38 + <path 39 + fill="#34A853" 40 + d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" 41 + /> 42 + <path 43 + fill="#FBBC05" 44 + d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" 45 + /> 46 + <path 47 + fill="#EA4335" 48 + d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" 49 + /> 50 + </svg> 51 + ), 52 + color: '#4285F4' 53 + }, 54 + atproto: { 55 + name: 'ATProto (Bluesky)', 56 + icon: ( 57 + <svg width="20" height="20" viewBox="0 0 600 530" fill="currentColor"> 58 + <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" /> 59 + </svg> 60 + ), 61 + color: '#0085ff' 62 + } 63 + }; 64 + 65 + export default function Account() { 66 + const { api, me, setError } = useOutletContext(); 67 + const navigate = useNavigate(); 68 + const [searchParams, setSearchParams] = useSearchParams(); 69 + 70 + const [email, setEmail] = useState(''); 71 + const [currentPasswordEmail, setCurrentPasswordEmail] = useState(''); 72 + 73 + const [currentPassword, setCurrentPassword] = useState(''); 74 + const [newPassword, setNewPassword] = useState(''); 75 + 76 + const [success, setSuccess] = useState(''); 77 + const [emailError, setEmailError] = useState(''); 78 + const [passwordError, setPasswordError] = useState(''); 79 + const [loadingEmail, setLoadingEmail] = useState(false); 80 + const [loadingPassword, setLoadingPassword] = useState(false); 81 + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); 82 + const [deleteLoading, setDeleteLoading] = useState(false); 83 + 84 + const [linkedAccounts, setLinkedAccounts] = useState([]); 85 + const [linkedAccountsLoading, setLinkedAccountsLoading] = useState(true); 86 + const [linkedAccountsError, setLinkedAccountsError] = useState(''); 87 + const [unlinkingId, setUnlinkingId] = useState(null); 88 + const [providers, setProviders] = useState({ github: false, google: false, atproto: false }); 89 + const [showAtpModal, setShowAtpModal] = useState(false); 90 + const [atpHandle, setAtpHandle] = useState(''); 91 + 92 + const [apiKeys, setApiKeys] = useState([]); 93 + const [apiKeysLoading, setApiKeysLoading] = useState(true); 94 + const [apiKeysError, setApiKeysError] = useState(''); 95 + const [newKeyName, setNewKeyName] = useState(''); 96 + const [creatingKey, setCreatingKey] = useState(false); 97 + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); 98 + const [copiedKeyId, setCopiedKeyId] = useState(null); 99 + const [deletingKeyId, setDeletingKeyId] = useState(null); 100 + 101 + useEffect(() => { 102 + setEmail(me?.email || ''); 103 + }, [me?.email]); 104 + 105 + useEffect(() => { 106 + const error = searchParams.get('error'); 107 + if (error === 'already-linked') { 108 + setLinkedAccountsError('This account is already linked to another user.'); 109 + 110 + searchParams.delete('error'); 111 + setSearchParams(searchParams, { replace: true }); 112 + } 113 + }, [searchParams, setSearchParams]); 114 + 115 + useEffect(() => { 116 + fetch('/api/auth/providers') 117 + .then((r) => r.json()) 118 + .then((data) => setProviders(data)) 119 + .catch(() => {}); 120 + 121 + fetchLinkedAccounts(); 122 + 123 + fetchApiKeys(); 124 + }, []); 125 + 126 + async function fetchApiKeys() { 127 + setApiKeysLoading(true); 128 + try { 129 + const res = await fetch('/api/api-keys', { credentials: 'same-origin' }); 130 + const data = await res.json(); 131 + if (res.ok) { 132 + setApiKeys(data || []); 133 + } 134 + } catch (e) { 135 + setApiKeysError('Failed to load API keys'); 136 + } finally { 137 + setApiKeysLoading(false); 138 + } 139 + } 140 + 141 + async function createApiKey() { 142 + if (!newKeyName.trim()) return; 143 + setCreatingKey(true); 144 + setApiKeysError(''); 145 + try { 146 + const res = await fetch('/api/api-keys', { 147 + method: 'POST', 148 + headers: { 'content-type': 'application/json' }, 149 + credentials: 'same-origin', 150 + body: JSON.stringify({ name: newKeyName.trim() }) 151 + }); 152 + const data = await res.json(); 153 + if (!res.ok) { 154 + setApiKeysError(data?.message || 'Failed to create API key'); 155 + return; 156 + } 157 + setNewlyCreatedKey(data); 158 + setNewKeyName(''); 159 + setApiKeys((prev) => [...prev, { id: data.id, name: data.name, prefix: data.prefix, createdAt: data.createdAt }]); 160 + } catch (e) { 161 + setApiKeysError('Failed to create API key'); 162 + } finally { 163 + setCreatingKey(false); 164 + } 165 + } 166 + 167 + async function deleteApiKey(keyId) { 168 + setDeletingKeyId(keyId); 169 + setApiKeysError(''); 170 + try { 171 + const res = await fetch(`/api/api-keys/${keyId}`, { 172 + method: 'DELETE', 173 + credentials: 'same-origin' 174 + }); 175 + if (!res.ok) { 176 + const data = await res.json().catch(() => null); 177 + setApiKeysError(data?.message || 'Failed to delete API key'); 178 + return; 179 + } 180 + setApiKeys((prev) => prev.filter((k) => k.id !== keyId)); 181 + if (newlyCreatedKey?.id === keyId) { 182 + setNewlyCreatedKey(null); 183 + } 184 + } catch (e) { 185 + setApiKeysError('Failed to delete API key'); 186 + } finally { 187 + setDeletingKeyId(null); 188 + } 189 + } 190 + 191 + function copyToClipboard(text, keyId) { 192 + navigator.clipboard.writeText(text); 193 + setCopiedKeyId(keyId); 194 + setTimeout(() => setCopiedKeyId(null), 2000); 195 + } 196 + 197 + async function fetchLinkedAccounts() { 198 + setLinkedAccountsLoading(true); 199 + try { 200 + const res = await fetch('/api/account/linked-accounts', { credentials: 'same-origin' }); 201 + const data = await res.json(); 202 + if (res.ok) { 203 + setLinkedAccounts(data.accounts || []); 204 + } 205 + } catch (e) { 206 + setLinkedAccountsError('Failed to load linked accounts'); 207 + } finally { 208 + setLinkedAccountsLoading(false); 209 + } 210 + } 211 + 212 + async function unlinkAccount(accountId) { 213 + setUnlinkingId(accountId); 214 + setLinkedAccountsError(''); 215 + try { 216 + const res = await fetch(`/api/account/linked-accounts/${accountId}`, { 217 + method: 'DELETE', 218 + credentials: 'same-origin' 219 + }); 220 + const data = await res.json().catch(() => null); 221 + 222 + if (!res.ok) { 223 + const errCode = data?.error || 'unlink-failed'; 224 + setLinkedAccountsError(ERROR_MESSAGES[errCode] || errCode); 225 + return; 226 + } 227 + 228 + setLinkedAccounts((prev) => prev.filter((a) => a.id !== accountId)); 229 + setSuccess('🔗 Account unlinked successfully.'); 230 + } finally { 231 + setUnlinkingId(null); 232 + } 233 + } 234 + 235 + async function changeEmail() { 236 + setEmailError(''); 237 + setSuccess(''); 238 + setLoadingEmail(true); 239 + 240 + try { 241 + const res = await fetch('/api/account/email', { 242 + method: 'POST', 243 + headers: { 'content-type': 'application/json' }, 244 + credentials: 'same-origin', 245 + body: JSON.stringify({ newEmail: email, currentPassword: currentPasswordEmail }) 246 + }); 247 + 248 + const data = await res.json().catch(() => null); 249 + 250 + if (!res.ok) { 251 + const errCode = data?.error || 'email-change-failed'; 252 + setEmailError(ERROR_MESSAGES[errCode] || data?.message || 'Failed to change email.'); 253 + return; 254 + } 255 + 256 + setSuccess('📧 Email change requested. Check your inbox to verify your new email.'); 257 + setCurrentPasswordEmail(''); 258 + } finally { 259 + setLoadingEmail(false); 260 + } 261 + } 262 + 263 + async function changePassword() { 264 + setPasswordError(''); 265 + setSuccess(''); 266 + setLoadingPassword(true); 267 + 268 + try { 269 + const res = await fetch('/api/account/password', { 270 + method: 'POST', 271 + headers: { 'content-type': 'application/json' }, 272 + credentials: 'same-origin', 273 + body: JSON.stringify({ currentPassword, newPassword }) 274 + }); 275 + 276 + const data = await res.json().catch(() => null); 277 + 278 + if (!res.ok) { 279 + const errCode = data?.error || 'password-update-failed'; 280 + setPasswordError(ERROR_MESSAGES[errCode] || data?.message || 'Failed to update password.'); 281 + return; 282 + } 283 + 284 + setSuccess('🔒 Password updated successfully.'); 285 + setCurrentPassword(''); 286 + setNewPassword(''); 287 + } finally { 288 + setLoadingPassword(false); 289 + } 290 + } 291 + 292 + async function deleteAccount() { 293 + setDeleteLoading(true); 294 + try { 295 + await api('/api/account', { method: 'DELETE' }); 296 + navigate('/'); 297 + } catch (e) { 298 + setError(e.message); 299 + } finally { 300 + setDeleteLoading(false); 301 + } 302 + } 303 + 304 + return ( 305 + <div className="page"> 306 + <div className="pageHeader"> 307 + <div> 308 + <div className="h">Account Settings</div> 309 + <div className="muted">Manage your email, password, and account preferences.</div> 310 + </div> 311 + </div> 312 + 313 + {success && <div className="notice">{success}</div>} 314 + 315 + <div className="grid2"> 316 + <div className="panel"> 317 + <div className="panelTitle"> 318 + <Mail size={18} style={{ marginRight: 8, color: '#e88978' }} /> 319 + Email Address 320 + </div> 321 + <div className="muted" style={{ marginBottom: 16, fontSize: 13 }}> 322 + Changing your email requires verification. We'll send a link to your new address. 323 + </div> 324 + 325 + {emailError && ( 326 + <div className="errorBox" style={{ marginBottom: 12 }}> 327 + {emailError} 328 + </div> 329 + )} 330 + 331 + <div className="field"> 332 + <div className="label">New Email</div> 333 + <input 334 + className="input" 335 + type="email" 336 + value={email} 337 + onChange={(e) => setEmail(e.target.value)} 338 + disabled={loadingEmail} 339 + /> 340 + </div> 341 + <div className="field"> 342 + <div className="label">Current Password</div> 343 + <input 344 + className="input" 345 + type="password" 346 + placeholder="Enter your current password" 347 + value={currentPasswordEmail} 348 + onChange={(e) => setCurrentPasswordEmail(e.target.value)} 349 + disabled={loadingEmail} 350 + /> 351 + </div> 352 + <div className="panelActions"> 353 + <button 354 + className="btn primary" 355 + onClick={changeEmail} 356 + disabled={loadingEmail || !email || !currentPasswordEmail} 357 + > 358 + {loadingEmail ? 'Sending...' : 'Change Email'} 359 + </button> 360 + </div> 361 + </div> 362 + 363 + <div className="panel"> 364 + <div className="panelTitle"> 365 + <Lock size={18} style={{ marginRight: 8, color: '#e88978' }} /> 366 + Password 367 + </div> 368 + <div className="muted" style={{ marginBottom: 16, fontSize: 13 }}> 369 + Use a strong password with at least 8 characters. 370 + </div> 371 + 372 + {passwordError && ( 373 + <div className="errorBox" style={{ marginBottom: 12 }}> 374 + {passwordError} 375 + </div> 376 + )} 377 + 378 + <div className="field"> 379 + <div className="label">Current Password</div> 380 + <input 381 + className="input" 382 + type="password" 383 + placeholder="Enter your current password" 384 + value={currentPassword} 385 + onChange={(e) => setCurrentPassword(e.target.value)} 386 + disabled={loadingPassword} 387 + /> 388 + </div> 389 + <div className="field"> 390 + <div className="label">New Password</div> 391 + <input 392 + className="input" 393 + type="password" 394 + placeholder="Enter your new password" 395 + value={newPassword} 396 + onChange={(e) => setNewPassword(e.target.value)} 397 + minLength={8} 398 + disabled={loadingPassword} 399 + /> 400 + </div> 401 + <div className="panelActions"> 402 + <button 403 + className="btn primary" 404 + onClick={changePassword} 405 + disabled={loadingPassword || !currentPassword || !newPassword || newPassword.length < 8} 406 + > 407 + {loadingPassword ? 'Updating...' : 'Update Password'} 408 + </button> 409 + </div> 410 + </div> 411 + </div> 412 + 413 + {} 414 + <div className="panel" style={{ marginTop: 8 }}> 415 + <div className="panelTitle"> 416 + <Link2 size={18} style={{ marginRight: 8, color: '#e88978' }} /> 417 + Linked Accounts 418 + </div> 419 + <div className="muted" style={{ marginBottom: 16, fontSize: 13 }}> 420 + Connect additional login methods to your account. You can log in using any linked account. 421 + </div> 422 + 423 + {linkedAccountsError && ( 424 + <div className="errorBox" style={{ marginBottom: 12 }}> 425 + {linkedAccountsError} 426 + </div> 427 + )} 428 + 429 + {linkedAccountsLoading ? ( 430 + <div className="muted">Loading linked accounts...</div> 431 + ) : ( 432 + <> 433 + {} 434 + {linkedAccounts.length > 0 && ( 435 + <div style={{ marginBottom: 16 }}> 436 + {linkedAccounts.map((account) => { 437 + const info = PROVIDER_INFO[account.provider] || { name: account.provider, icon: null, color: '#666' }; 438 + return ( 439 + <div 440 + key={account.id} 441 + style={{ 442 + display: 'flex', 443 + alignItems: 'center', 444 + justifyContent: 'space-between', 445 + padding: '12px 16px', 446 + background: 'var(--bg-secondary)', 447 + borderRadius: 8, 448 + marginBottom: 8 449 + }} 450 + > 451 + <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> 452 + <span style={{ color: info.color }}>{info.icon}</span> 453 + <div> 454 + <div style={{ fontWeight: 500 }}>{info.name}</div> 455 + <div className="muted" style={{ fontSize: 12 }}> 456 + {account.displayName && <span>{account.displayName} · </span>} 457 + Connected {new Date(account.createdAt).toLocaleDateString()} 458 + </div> 459 + </div> 460 + </div> 461 + <button 462 + className="btn ghost" 463 + onClick={() => unlinkAccount(account.id)} 464 + disabled={unlinkingId === account.id} 465 + style={{ display: 'flex', alignItems: 'center', gap: 6 }} 466 + > 467 + <Unlink size={14} /> 468 + {unlinkingId === account.id ? 'Unlinking...' : 'Unlink'} 469 + </button> 470 + </div> 471 + ); 472 + })} 473 + </div> 474 + )} 475 + 476 + {} 477 + {(() => { 478 + const linkedProviders = new Set(linkedAccounts.map((a) => a.provider)); 479 + const availableProviders = Object.entries(providers) 480 + .filter(([key, enabled]) => enabled && !linkedProviders.has(key) && key !== 'turnstileSiteKey') 481 + .map(([key]) => key); 482 + 483 + if (availableProviders.length === 0 && linkedAccounts.length === 0) { 484 + return <div className="muted">No OAuth providers are configured.</div>; 485 + } 486 + 487 + if (availableProviders.length === 0) { 488 + return null; 489 + } 490 + 491 + return ( 492 + <div> 493 + <div className="muted" style={{ marginBottom: 12, fontSize: 13 }}> 494 + Link additional accounts: 495 + </div> 496 + <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> 497 + {availableProviders.map((provider) => { 498 + const info = PROVIDER_INFO[provider] || { name: provider, icon: null, color: '#666' }; 499 + const authUrl = provider === 'atproto' ? null : `/auth/${provider}`; 500 + 501 + if (provider === 'atproto') { 502 + return ( 503 + <button 504 + key={provider} 505 + type="button" 506 + onClick={() => setShowAtpModal(true)} 507 + className="btn ghost" 508 + style={{ display: 'flex', alignItems: 'center', gap: 8 }} 509 + > 510 + <span style={{ color: info.color }}>{info.icon}</span> 511 + Link {info.name} 512 + </button> 513 + ); 514 + } 515 + 516 + return ( 517 + <a 518 + key={provider} 519 + href={authUrl} 520 + className="btn ghost" 521 + style={{ display: 'flex', alignItems: 'center', gap: 8 }} 522 + > 523 + <span style={{ color: info.color }}>{info.icon}</span> 524 + Link {info.name} 525 + </a> 526 + ); 527 + })} 528 + </div> 529 + </div> 530 + ); 531 + })()} 532 + </> 533 + )} 534 + </div> 535 + 536 + {} 537 + <div className="panel" style={{ marginTop: 8 }}> 538 + <div className="panelTitle"> 539 + <Key size={18} style={{ marginRight: 8, color: '#e88978' }} /> 540 + API Keys 541 + </div> 542 + <div className="muted" style={{ marginBottom: 16, fontSize: 13 }}> 543 + Use API keys to deploy sites from CI/CD pipelines and scripts. Keys are shown only once when created. 544 + </div> 545 + 546 + {apiKeysError && ( 547 + <div className="errorBox" style={{ marginBottom: 12 }}> 548 + {apiKeysError} 549 + </div> 550 + )} 551 + 552 + {} 553 + {newlyCreatedKey && ( 554 + <div 555 + className="panel" 556 + style={{ 557 + display: 'flex', 558 + flexDirection: 'column', 559 + gap: 12, 560 + borderColor: 'rgba(34, 197, 94, 0.3)', 561 + background: 'rgba(34, 197, 94, 0.08)' 562 + }} 563 + > 564 + <div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8 }}> 565 + <Check size={16} style={{ color: '#22c55e' }} /> 566 + API Key Created 567 + </div> 568 + <div className="muted" style={{ fontSize: 13 }}> 569 + Copy this key now — it won't be shown again. 570 + </div> 571 + <div 572 + style={{ 573 + display: 'flex', 574 + alignItems: 'center', 575 + gap: 8, 576 + background: 'var(--code-bg)', 577 + padding: '8px 12px', 578 + borderRadius: 6, 579 + fontFamily: 'monospace', 580 + fontSize: 14, 581 + wordBreak: 'break-all', 582 + border: '1px solid var(--card-border)' 583 + }} 584 + > 585 + <code style={{ flex: 1, color: 'var(--card-text)' }}>{newlyCreatedKey.key}</code> 586 + <button 587 + className="iconBtn" 588 + onClick={() => copyToClipboard(newlyCreatedKey.key, newlyCreatedKey.id)} 589 + style={{ width: 28, height: 28, borderRadius: '50%', flexShrink: 0 }} 590 + > 591 + {copiedKeyId === newlyCreatedKey.id ? <Check size={14} /> : <Copy size={14} />} 592 + </button> 593 + </div> 594 + <button 595 + className="btn ghost" 596 + onClick={() => setNewlyCreatedKey(null)} 597 + style={{ fontSize: 13, alignSelf: 'flex-start' }} 598 + > 599 + Dismiss 600 + </button> 601 + </div> 602 + )} 603 + 604 + {apiKeysLoading ? ( 605 + <div className="muted">Loading API keys...</div> 606 + ) : ( 607 + <> 608 + {} 609 + {apiKeys.length > 0 && ( 610 + <div style={{ marginBottom: 16 }}> 611 + {apiKeys.map((key) => ( 612 + <div 613 + key={key.id} 614 + style={{ 615 + display: 'flex', 616 + alignItems: 'center', 617 + justifyContent: 'space-between', 618 + padding: '12px 16px', 619 + background: 'var(--bg-secondary)', 620 + borderRadius: 8, 621 + marginBottom: 8 622 + }} 623 + > 624 + <div> 625 + <div style={{ fontWeight: 500 }}>{key.name}</div> 626 + <div className="muted" style={{ fontSize: 12, fontFamily: 'monospace' }}> 627 + {key.prefix}•••••••• 628 + {key.lastUsedAt?.Valid && key.lastUsedAt?.String && ( 629 + <span> · Last used {new Date(key.lastUsedAt.String).toLocaleDateString()}</span> 630 + )} 631 + {!key.lastUsedAt?.Valid && <span> · Never used</span>} 632 + </div> 633 + </div> 634 + <button 635 + className="btn ghost" 636 + onClick={() => deleteApiKey(key.id)} 637 + disabled={deletingKeyId === key.id} 638 + style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--danger)' }} 639 + > 640 + <Trash2 size={14} /> 641 + {deletingKeyId === key.id ? 'Deleting...' : 'Delete'} 642 + </button> 643 + </div> 644 + ))} 645 + </div> 646 + )} 647 + 648 + {apiKeys.length === 0 && !newlyCreatedKey && ( 649 + <div className="muted" style={{ marginBottom: 16 }}> 650 + No API keys yet. Create one to get started with CLI deployments. 651 + </div> 652 + )} 653 + 654 + {} 655 + <div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}> 656 + <div className="field" style={{ flex: 1, marginBottom: 0 }}> 657 + <div className="label">Key Name</div> 658 + <input 659 + className="input" 660 + placeholder="e.g., GitHub Actions" 661 + value={newKeyName} 662 + onChange={(e) => setNewKeyName(e.target.value)} 663 + onKeyDown={(e) => { 664 + if (e.key === 'Enter' && newKeyName.trim()) { 665 + createApiKey(); 666 + } 667 + }} 668 + disabled={creatingKey} 669 + maxLength={64} 670 + /> 671 + </div> 672 + <button 673 + className="btn primary" 674 + onClick={createApiKey} 675 + disabled={creatingKey || !newKeyName.trim()} 676 + style={{ marginBottom: 0 }} 677 + > 678 + {creatingKey ? 'Creating...' : 'Create Key'} 679 + </button> 680 + </div> 681 + </> 682 + )} 683 + </div> 684 + 685 + <div className="panel" style={{ marginTop: 8 }}> 686 + <div className="panelTitle"> 687 + <AlertTriangle size={18} style={{ marginRight: 8, color: '#dc2626' }} /> 688 + Danger Zone 689 + </div> 690 + <div className="muted" style={{ marginBottom: 16, fontSize: 13 }}> 691 + Once you delete your account, all your projects and deployments will be permanently removed. 692 + </div> 693 + 694 + {!showDeleteConfirm ? ( 695 + <button className="btn danger" onClick={() => setShowDeleteConfirm(true)}> 696 + Delete Account 697 + </button> 698 + ) : ( 699 + <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> 700 + <span style={{ fontWeight: 600, color: '#b91c1c' }}>Are you sure?</span> 701 + <button className="btn danger" onClick={deleteAccount} disabled={deleteLoading}> 702 + {deleteLoading ? 'Deleting...' : 'Yes, delete my account'} 703 + </button> 704 + <button className="btn ghost" onClick={() => setShowDeleteConfirm(false)} disabled={deleteLoading}> 705 + Cancel 706 + </button> 707 + </div> 708 + )} 709 + </div> 710 + 711 + {} 712 + {showAtpModal && ( 713 + <div 714 + className="modalOverlay" 715 + onClick={(e) => { 716 + if (e.target === e.currentTarget) { 717 + setShowAtpModal(false); 718 + } 719 + }} 720 + > 721 + <div className="modal" onClick={(e) => e.stopPropagation()}> 722 + <div className="modalHeader"> 723 + <div className="modalTitle">Link ATProto Account</div> 724 + <button className="iconBtn" onClick={() => setShowAtpModal(false)} aria-label="Close"> 725 + 726 + </button> 727 + </div> 728 + <div className="modalBody"> 729 + <div className="field"> 730 + <div className="label">Handle</div> 731 + <input 732 + className="input" 733 + placeholder="your-handle.your.pds" 734 + value={atpHandle} 735 + onChange={(e) => setAtpHandle(e.target.value)} 736 + onKeyDown={(e) => { 737 + if (e.key === 'Enter' && atpHandle.trim()) { 738 + e.preventDefault(); 739 + window.location.href = `/auth/atproto?handle=${encodeURIComponent(atpHandle.trim())}`; 740 + } 741 + }} 742 + autoFocus 743 + /> 744 + <div className="muted" style={{ marginTop: 6 }}> 745 + Enter your Bluesky handle to link your account. 746 + </div> 747 + </div> 748 + <div className="modalActions"> 749 + <button className="btn ghost" type="button" onClick={() => setShowAtpModal(false)}> 750 + Cancel 751 + </button> 752 + <button 753 + className="btn primary" 754 + type="button" 755 + onClick={() => { 756 + if (atpHandle.trim()) { 757 + window.location.href = `/auth/atproto?handle=${encodeURIComponent(atpHandle.trim())}`; 758 + } 759 + }} 760 + disabled={!atpHandle.trim()} 761 + > 762 + Link Account 763 + </button> 764 + </div> 765 + </div> 766 + </div> 767 + </div> 768 + )} 769 + </div> 770 + ); 771 + }
+630
client/src/pages/ApiDocs.jsx
··· 1 + import React from 'react'; 2 + import { useNavigate, useOutletContext } from 'react-router-dom'; 3 + import { Book, Key, Terminal, Copy, Check, ExternalLink } from 'lucide-react'; 4 + 5 + export default function ApiDocs() { 6 + const { me } = useOutletContext(); 7 + const navigate = useNavigate(); 8 + const [copiedIndex, setCopiedIndex] = React.useState(null); 9 + 10 + const baseUrl = window.location.origin; 11 + 12 + const copyToClipboard = (text, index) => { 13 + navigator.clipboard.writeText(text); 14 + setCopiedIndex(index); 15 + setTimeout(() => setCopiedIndex(null), 2000); 16 + }; 17 + 18 + const endpoints = [ 19 + { 20 + method: 'GET', 21 + path: '/api/v1/sites', 22 + description: 'List all your sites', 23 + example: `curl -H "Authorization: Bearer YOUR_API_KEY" \\ 24 + ${baseUrl}/api/v1/sites`, 25 + response: `{ 26 + "sites": [ 27 + { 28 + "id": "abc123", 29 + "name": "my-site", 30 + "domain": "my-site.boop.cat", 31 + "git": { "url": "https://github.com/user/repo", "branch": "main" }, 32 + "createdAt": "2024-01-01T00:00:00Z" 33 + } 34 + ] 35 + }` 36 + }, 37 + { 38 + method: 'GET', 39 + path: '/api/v1/sites/{id}', 40 + description: 'Get details for a specific site', 41 + example: `curl -H "Authorization: Bearer YOUR_API_KEY" \\ 42 + ${baseUrl}/api/v1/sites/YOUR_SITE_ID`, 43 + response: `{ 44 + "id": "abc123", 45 + "name": "my-site", 46 + "domain": "my-site.boop.cat", 47 + "git": { "url": "https://github.com/user/repo", "branch": "main" }, 48 + "buildCommand": "npm run build", 49 + "outputDir": "dist", 50 + "createdAt": "2024-01-01T00:00:00Z" 51 + }` 52 + }, 53 + { 54 + method: 'POST', 55 + path: '/api/v1/sites/{id}/deploy', 56 + description: 'Trigger a new deployment for a site. Returns immediately with deployment details.', 57 + example: `curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \\ 58 + ${baseUrl}/api/v1/sites/YOUR_SITE_ID/deploy`, 59 + response: `{ 60 + "id": "dep_xyz789", 61 + "siteId": "abc123", 62 + "status": "building", 63 + "createdAt": "2024-01-01T12:00:00Z", 64 + "commitSha": "abc1234", 65 + "commitMessage": "Initial commit", 66 + "commitAuthor": "User" 67 + }` 68 + }, 69 + { 70 + method: 'POST', 71 + path: '/api/v1/sites/{id}/deploy?wait=true', 72 + description: 'Trigger a deployment and stream build logs in real-time.', 73 + example: `curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \\ 74 + ${baseUrl}/api/v1/sites/YOUR_SITE_ID/deploy?wait=true`, 75 + response: `[2024-01-01T12:00:00Z] Cloning repository... 76 + [2024-01-01T12:00:02Z] Installing dependencies... 77 + [2024-01-01T12:00:10Z] Building project... 78 + [2024-01-01T12:00:20Z] Uploading to edge storage... 79 + [2024-01-01T12:00:22Z] Deployment successful!` 80 + }, 81 + { 82 + method: 'GET', 83 + path: '/api/v1/sites/{id}/deployments', 84 + description: 'List all deployments for a site', 85 + example: `curl -H "Authorization: Bearer YOUR_API_KEY" \\ 86 + ${baseUrl}/api/v1/sites/YOUR_SITE_ID/deployments`, 87 + response: `{ 88 + "deployments": [ 89 + { 90 + "id": "dep_xyz789", 91 + "siteId": "abc123", 92 + "status": "active", 93 + "createdAt": "2024-01-01T12:00:00Z", 94 + "url": "https://my-site.boop.cat" 95 + } 96 + ] 97 + }` 98 + } 99 + ]; 100 + 101 + return ( 102 + <div className="page"> 103 + <div className="pageHeader"> 104 + <div> 105 + <div className="h">API Documentation</div> 106 + <div className="muted">Use the API to deploy sites programmatically from CI/CD pipelines.</div> 107 + </div> 108 + </div> 109 + 110 + {} 111 + <div className="panel"> 112 + <div className="panelTitle"> 113 + <Book size={18} style={{ marginRight: 8, color: '#e88978' }} /> 114 + Overview 115 + </div> 116 + <div className="muted" style={{ marginBottom: 16, lineHeight: 1.6 }}> 117 + The boop.cat API allows you to manage sites and trigger deployments programmatically. This is useful for CI/CD 118 + pipelines, GitHub Actions, and custom deployment workflows. 119 + </div> 120 + <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}> 121 + <div 122 + style={{ 123 + padding: '8px 16px', 124 + background: 'var(--bg-secondary)', 125 + borderRadius: 8, 126 + fontSize: 13 127 + }} 128 + > 129 + <strong>Base URL:</strong> <code>{baseUrl}/api/v1</code> 130 + </div> 131 + <div 132 + style={{ 133 + padding: '8px 16px', 134 + background: 'var(--bg-secondary)', 135 + borderRadius: 8, 136 + fontSize: 13 137 + }} 138 + > 139 + <strong>Rate Limit:</strong> 100 requests / 15 minutes 140 + </div> 141 + </div> 142 + </div> 143 + 144 + {} 145 + <div className="panel" style={{ marginTop: 8 }}> 146 + <div className="panelTitle"> 147 + <Key size={18} style={{ marginRight: 8, color: '#e88978' }} /> 148 + Authentication 149 + </div> 150 + <div className="muted" style={{ marginBottom: 16, lineHeight: 1.6 }}> 151 + All API requests require authentication using an API key. Create API keys in your{' '} 152 + <a 153 + href="#" 154 + onClick={(e) => { 155 + e.preventDefault(); 156 + navigate('/dashboard/account'); 157 + }} 158 + style={{ color: 'var(--primary)' }} 159 + > 160 + Account Settings 161 + </a> 162 + . 163 + </div> 164 + 165 + <div style={{ marginBottom: 16 }}> 166 + <div style={{ fontWeight: 600, marginBottom: 8, fontSize: 14 }}>Request Header</div> 167 + <div 168 + style={{ 169 + background: 'var(--bg-tertiary, #1e1e1e)', 170 + padding: 16, 171 + borderRadius: 8, 172 + fontFamily: 'monospace', 173 + fontSize: 13, 174 + color: 'var(--code-text, #e0e0e0)', 175 + overflowX: 'auto' 176 + }} 177 + > 178 + Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 179 + </div> 180 + </div> 181 + 182 + <div className="notice" style={{ marginBottom: 0 }}> 183 + <strong>Important:</strong> API keys have full access to your account. Keep them secret and never expose them 184 + in client-side code. 185 + </div> 186 + </div> 187 + 188 + {} 189 + <div className="panel" style={{ marginTop: 8 }}> 190 + <div className="panelTitle"> 191 + <Terminal size={18} style={{ marginRight: 8, color: '#e88978' }} /> 192 + Endpoints 193 + </div> 194 + 195 + {endpoints.map((endpoint, i) => ( 196 + <div 197 + key={i} 198 + style={{ 199 + borderBottom: i < endpoints.length - 1 ? '1px solid var(--border)' : 'none', 200 + paddingBottom: i < endpoints.length - 1 ? 24 : 0, 201 + marginBottom: i < endpoints.length - 1 ? 24 : 0 202 + }} 203 + > 204 + <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}> 205 + <span 206 + style={{ 207 + padding: '4px 10px', 208 + borderRadius: 6, 209 + fontSize: 12, 210 + fontWeight: 700, 211 + fontFamily: 'monospace', 212 + background: endpoint.method === 'POST' ? 'rgba(34, 197, 94, 0.15)' : 'rgba(59, 130, 246, 0.15)', 213 + color: endpoint.method === 'POST' ? '#22c55e' : '#3b82f6' 214 + }} 215 + > 216 + {endpoint.method} 217 + </span> 218 + <code style={{ fontSize: 14, fontWeight: 500 }}>{endpoint.path}</code> 219 + </div> 220 + <div className="muted" style={{ marginBottom: 12 }}> 221 + {endpoint.description} 222 + </div> 223 + 224 + <div style={{ marginBottom: 12 }}> 225 + <div 226 + style={{ 227 + display: 'flex', 228 + justifyContent: 'space-between', 229 + alignItems: 'center', 230 + marginBottom: 6 231 + }} 232 + > 233 + <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>Example Request</span> 234 + <button 235 + className="iconBtn" 236 + style={{ 237 + width: 28, 238 + height: 28, 239 + borderRadius: '50%', 240 + background: 'var(--bg-secondary)', 241 + border: 'none' 242 + }} 243 + onClick={() => copyToClipboard(endpoint.example, `example-${i}`)} 244 + > 245 + {copiedIndex === `example-${i}` ? <Check size={14} /> : <Copy size={14} />} 246 + </button> 247 + </div> 248 + <pre 249 + style={{ 250 + background: 'var(--bg-tertiary, #1e1e1e)', 251 + padding: 16, 252 + borderRadius: 8, 253 + fontFamily: 'monospace', 254 + fontSize: 12, 255 + color: 'var(--code-text, #e0e0e0)', 256 + overflowX: 'auto', 257 + margin: 0, 258 + whiteSpace: 'pre-wrap', 259 + wordBreak: 'break-word' 260 + }} 261 + > 262 + {endpoint.example} 263 + </pre> 264 + </div> 265 + 266 + <div> 267 + <div 268 + style={{ 269 + display: 'flex', 270 + justifyContent: 'space-between', 271 + alignItems: 'center', 272 + marginBottom: 6 273 + }} 274 + > 275 + <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>Example Response</span> 276 + <button 277 + className="iconBtn" 278 + style={{ 279 + width: 28, 280 + height: 28, 281 + borderRadius: '50%', 282 + background: 'var(--bg-secondary)', 283 + border: 'none' 284 + }} 285 + onClick={() => copyToClipboard(endpoint.response, `response-${i}`)} 286 + > 287 + {copiedIndex === `response-${i}` ? <Check size={14} /> : <Copy size={14} />} 288 + </button> 289 + </div> 290 + <pre 291 + style={{ 292 + background: 'var(--bg-tertiary, #1e1e1e)', 293 + padding: 16, 294 + borderRadius: 8, 295 + fontFamily: 'monospace', 296 + fontSize: 12, 297 + color: 'var(--code-text, #e0e0e0)', 298 + overflowX: 'auto', 299 + margin: 0 300 + }} 301 + > 302 + {endpoint.response} 303 + </pre> 304 + </div> 305 + </div> 306 + ))} 307 + </div> 308 + 309 + {} 310 + <div className="panel" style={{ marginTop: 8 }}> 311 + <div className="panelTitle"> 312 + <ExternalLink size={18} style={{ marginRight: 8, color: '#e88978' }} /> 313 + GitHub Actions Example 314 + </div> 315 + <div className="muted" style={{ marginBottom: 16, lineHeight: 1.6 }}> 316 + Here's an example workflow to trigger a deployment on every push to main: 317 + </div> 318 + 319 + <div style={{ position: 'relative' }}> 320 + <button 321 + className="iconBtn" 322 + style={{ 323 + position: 'absolute', 324 + top: 8, 325 + right: 8, 326 + width: 28, 327 + height: 28, 328 + borderRadius: '50%', 329 + background: 'var(--bg-secondary)', 330 + border: 'none' 331 + }} 332 + onClick={() => 333 + copyToClipboard( 334 + `name: Deploy to boop.cat 335 + 336 + on: 337 + push: 338 + branches: [main] 339 + 340 + jobs: 341 + deploy: 342 + runs-on: ubuntu-latest 343 + steps: 344 + - name: Trigger Deployment 345 + run: | 346 + curl -X POST \\ 347 + -H "Authorization: Bearer \${{ secrets.BOOP_CAT_API_KEY }}" \\ 348 + ${baseUrl}/api/v1/sites/YOUR_SITE_ID/deploy?wait=true`, 349 + 'github-actions' 350 + ) 351 + } 352 + > 353 + {copiedIndex === 'github-actions' ? <Check size={14} /> : <Copy size={14} />} 354 + </button> 355 + <pre 356 + style={{ 357 + background: 'var(--bg-tertiary, #1e1e1e)', 358 + padding: 16, 359 + borderRadius: 8, 360 + fontFamily: 'monospace', 361 + fontSize: 12, 362 + color: 'var(--code-text, #e0e0e0)', 363 + overflowX: 'auto', 364 + margin: 0 365 + }} 366 + > 367 + {`name: Deploy to boop.cat 368 + 369 + on: 370 + push: 371 + branches: [main] 372 + 373 + jobs: 374 + deploy: 375 + runs-on: ubuntu-latest 376 + steps: 377 + - name: Trigger Deployment 378 + run: | 379 + curl -X POST \\ 380 + -H "Authorization: Bearer \${{ secrets.BOOP_CAT_API_KEY }}" \\ 381 + ${baseUrl}/api/v1/sites/YOUR_SITE_ID/deploy?wait=true`} 382 + </pre> 383 + </div> 384 + 385 + <div className="notice" style={{ marginTop: 16, marginBottom: 0 }}> 386 + <strong>Tip:</strong> Store your API key as a repository secret named <code>BOOP_CAT_API_KEY</code> in your 387 + GitHub repository settings. 388 + </div> 389 + </div> 390 + 391 + {} 392 + <div className="panel" style={{ marginTop: 8 }}> 393 + <div className="panelTitle"> 394 + <ExternalLink size={18} style={{ marginRight: 8, color: '#fc6d26' }} /> 395 + GitLab CI Example 396 + </div> 397 + <div className="muted" style={{ marginBottom: 16, lineHeight: 1.6 }}> 398 + For GitLab CI/CD, add this to your <code>.gitlab-ci.yml</code>: 399 + </div> 400 + 401 + <div style={{ position: 'relative' }}> 402 + <button 403 + className="iconBtn" 404 + style={{ 405 + position: 'absolute', 406 + top: 8, 407 + right: 8, 408 + width: 28, 409 + height: 28, 410 + borderRadius: '50%', 411 + background: 'var(--bg-secondary)', 412 + border: 'none' 413 + }} 414 + onClick={() => 415 + copyToClipboard( 416 + `deploy: 417 + stage: deploy 418 + image: curlimages/curl 419 + script: 420 + - curl -X POST -H "Authorization: Bearer $BOOP_CAT_API_KEY" ${baseUrl}/api/v1/sites/YOUR_SITE_ID/deploy?wait=true 421 + only: 422 + - main`, 423 + 'gitlab-ci' 424 + ) 425 + } 426 + > 427 + {copiedIndex === 'gitlab-ci' ? <Check size={14} /> : <Copy size={14} />} 428 + </button> 429 + <pre 430 + style={{ 431 + background: 'var(--bg-tertiary, #1e1e1e)', 432 + padding: 16, 433 + borderRadius: 8, 434 + fontFamily: 'monospace', 435 + fontSize: 12, 436 + color: 'var(--code-text, #e0e0e0)', 437 + overflowX: 'auto', 438 + margin: 0 439 + }} 440 + > 441 + {`deploy: 442 + stage: deploy 443 + image: curlimages/curl 444 + script: 445 + - curl -X POST -H "Authorization: Bearer $BOOP_CAT_API_KEY" ${baseUrl}/api/v1/sites/YOUR_SITE_ID/deploy?wait=true 446 + only: 447 + - main`} 448 + </pre> 449 + </div> 450 + 451 + <div className="notice" style={{ marginTop: 16, marginBottom: 0 }}> 452 + <strong>Tip:</strong> Add <code>BOOP_CAT_API_KEY</code> as a variable in{' '} 453 + <strong> 454 + Settings {'>'} CI/CD {'>'} Variables. 455 + </strong> 456 + </div> 457 + </div> 458 + 459 + {} 460 + <div className="panel" style={{ marginTop: 8 }}> 461 + <div className="panelTitle"> 462 + <Terminal size={18} style={{ marginRight: 8, color: '#68a063' }} /> 463 + Node.js Script 464 + </div> 465 + <div className="muted" style={{ marginBottom: 16, lineHeight: 1.6 }}> 466 + A simple Node.js script to trigger deployments: 467 + </div> 468 + 469 + <div style={{ position: 'relative' }}> 470 + <button 471 + className="iconBtn" 472 + style={{ 473 + position: 'absolute', 474 + top: 8, 475 + right: 8, 476 + width: 28, 477 + height: 28, 478 + borderRadius: '50%', 479 + background: 'var(--bg-secondary)', 480 + border: 'none' 481 + }} 482 + onClick={() => 483 + copyToClipboard( 484 + `const siteId = 'YOUR_SITE_ID'; 485 + const apiKey = process.env.BOOP_CAT_API_KEY; 486 + 487 + async function deploy() { 488 + const res = await fetch(\`${baseUrl}/api/v1/sites/\${siteId}/deploy\`, { 489 + method: 'POST', 490 + headers: { 491 + 'Authorization': \`Bearer \${apiKey}\` 492 + } 493 + }); 494 + 495 + const data = await res.json(); 496 + console.log('Deploy triggered:', data); 497 + } 498 + 499 + deploy().catch(console.error);`, 500 + 'node-script' 501 + ) 502 + } 503 + > 504 + {copiedIndex === 'node-script' ? <Check size={14} /> : <Copy size={14} />} 505 + </button> 506 + <pre 507 + style={{ 508 + background: 'var(--bg-tertiary, #1e1e1e)', 509 + padding: 16, 510 + borderRadius: 8, 511 + fontFamily: 'monospace', 512 + fontSize: 12, 513 + color: 'var(--code-text, #e0e0e0)', 514 + overflowX: 'auto', 515 + margin: 0 516 + }} 517 + > 518 + {`const siteId = 'YOUR_SITE_ID'; 519 + const apiKey = process.env.BOOP_CAT_API_KEY; 520 + 521 + async function deploy() { 522 + const res = await fetch(\`${baseUrl}/api/v1/sites/\${siteId}/deploy\`, { 523 + method: 'POST', 524 + headers: { 525 + 'Authorization': \`Bearer \${apiKey}\` 526 + } 527 + }); 528 + 529 + const data = await res.json(); 530 + console.log('Deploy triggered:', data); 531 + } 532 + 533 + deploy().catch(console.error);`} 534 + </pre> 535 + </div> 536 + </div> 537 + 538 + {} 539 + <div className="panel" style={{ marginTop: 8 }}> 540 + <div className="panelTitle"> 541 + <ExternalLink size={18} style={{ marginRight: 8, color: '#8b5cf6' }} /> 542 + Tangled CI Example 543 + </div> 544 + <div className="muted" style={{ marginBottom: 16, lineHeight: 1.6 }}> 545 + For Tangled.org (Spindle), create only <code>.tangled/workflows/deploy.yml</code>: 546 + </div> 547 + 548 + <div style={{ position: 'relative' }}> 549 + <button 550 + className="iconBtn" 551 + style={{ 552 + position: 'absolute', 553 + top: 8, 554 + right: 8, 555 + width: 28, 556 + height: 28, 557 + borderRadius: '50%', 558 + background: 'var(--bg-secondary)', 559 + border: 'none' 560 + }} 561 + onClick={() => 562 + copyToClipboard( 563 + `when: 564 + - event: ["push"] 565 + branch: ["main"] 566 + 567 + engine: "nixery" 568 + 569 + clone: 570 + skip: true 571 + 572 + dependencies: 573 + nixpkgs: 574 + - curl 575 + 576 + steps: 577 + - name: "Trigger Deploy" 578 + command: | 579 + curl -X POST \\ 580 + -H "Authorization: Bearer $BOOP_CAT_API_KEY" \\ 581 + ${baseUrl}/api/v1/sites/YOUR_SITE_ID/deploy?wait=true`, 582 + 'tangled-ci' 583 + ) 584 + } 585 + > 586 + {copiedIndex === 'tangled-ci' ? <Check size={14} /> : <Copy size={14} />} 587 + </button> 588 + <pre 589 + style={{ 590 + background: 'var(--bg-tertiary, #1e1e1e)', 591 + padding: 16, 592 + borderRadius: 8, 593 + fontFamily: 'monospace', 594 + fontSize: 12, 595 + color: 'var(--code-text, #e0e0e0)', 596 + overflowX: 'auto', 597 + margin: 0 598 + }} 599 + > 600 + {`when: 601 + - event: ["push"] 602 + branch: ["main"] 603 + 604 + engine: "nixery" 605 + 606 + clone: 607 + skip: true 608 + 609 + dependencies: 610 + nixpkgs: 611 + - curl 612 + 613 + steps: 614 + - name: "Trigger Deploy" 615 + command: | 616 + curl -X POST \\ 617 + -H "Authorization: Bearer $BOOP_CAT_API_KEY" \\ 618 + ${baseUrl}/api/v1/sites/YOUR_SITE_ID/deploy?wait=true`} 619 + </pre> 620 + <div className="notice" style={{ marginTop: 16, marginBottom: 0 }}> 621 + <strong>Tip:</strong> Add <code>BOOP_CAT_API_KEY</code> as a secret in{' '} 622 + <strong> 623 + settings {'>'} pipelines {'>'} secrets. 624 + </strong> 625 + </div> 626 + </div> 627 + </div> 628 + </div> 629 + ); 630 + }
+75
client/src/pages/DashboardHome.jsx
··· 1 + import React from 'react'; 2 + import { Link, useOutletContext } from 'react-router-dom'; 3 + import MillyLogo from '../components/MillyLogo.jsx'; 4 + 5 + export default function DashboardHome() { 6 + const { me, sites } = useOutletContext(); 7 + const siteLimitReached = sites.length >= 10; 8 + 9 + return ( 10 + <div className="page"> 11 + <div className="pageHeader"> 12 + <div> 13 + <div className="h">Your Websites</div> 14 + <div className="muted">Manage deployments and environment variables.</div> 15 + </div> 16 + <div className="topActions"> 17 + {siteLimitReached ? ( 18 + <button className="btn primary" disabled> 19 + + New website 20 + </button> 21 + ) : ( 22 + <Link to="/dashboard/new" className="btn primary"> 23 + + New website 24 + </Link> 25 + )} 26 + </div> 27 + </div> 28 + 29 + {me && me.emailVerified === false ? ( 30 + <div className="notice"> 31 + <strong>Verify your email</strong> to deploy your websites. Check your inbox for the verification link. 32 + </div> 33 + ) : null} 34 + 35 + {siteLimitReached && ( 36 + <div className="notice"> 37 + <strong>Limit reached:</strong> You have 10 projects. Delete a project to create a new one. 38 + </div> 39 + )} 40 + 41 + <div className="gridCards"> 42 + {sites.map((s) => ( 43 + <Link key={s.id} className="projectCard" to={`/dashboard/site/${s.id}`}> 44 + <div className="projectTitle">{s.name}</div> 45 + <div className="muted" style={{ marginTop: 8, fontSize: 13 }}> 46 + {s.domain ? s.domain : s.git?.url?.replace('https://github.com/', '')} 47 + </div> 48 + <div className="projectMeta"> 49 + <span className="chip">{s.git?.branch || 'main'}</span> 50 + {s.git?.subdir && <span className="chip">{s.git.subdir}</span>} 51 + </div> 52 + </Link> 53 + ))} 54 + 55 + {sites.length === 0 && ( 56 + <div className="panel" style={{ gridColumn: '1 / -1' }}> 57 + <div style={{ textAlign: 'center', padding: '32px 24px' }}> 58 + <MillyLogo size={64} style={{ marginBottom: 20 }} /> 59 + <h3 style={{ fontSize: '1.25rem', fontWeight: 600, marginBottom: 8 }}>Create your first website</h3> 60 + <div 61 + className="muted" 62 + style={{ marginBottom: 24, maxWidth: 320, marginLeft: 'auto', marginRight: 'auto' }} 63 + > 64 + Deploy static sites from any Git repository in seconds. 65 + </div> 66 + <Link to="/dashboard/new" className="btn primary"> 67 + + New website 68 + </Link> 69 + </div> 70 + </div> 71 + )} 72 + </div> 73 + </div> 74 + ); 75 + }
+318
client/src/pages/DashboardLayout.jsx
··· 1 + import React, { useEffect, useMemo, useState } from 'react'; 2 + import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; 3 + import { 4 + LayoutDashboard, 5 + Settings, 6 + LogOut, 7 + Zap, 8 + Globe, 9 + AlertTriangle, 10 + User, 11 + ChevronDown, 12 + ChevronLeft, 13 + ChevronRight, 14 + Plus, 15 + Menu, 16 + X, 17 + Book 18 + } from 'lucide-react'; 19 + import md5 from 'blueimp-md5'; 20 + import ThemeToggle from '../components/ThemeToggle.jsx'; 21 + import MillyLogo from '../components/MillyLogo.jsx'; 22 + 23 + function useApi() { 24 + return async (path, init) => { 25 + const res = await fetch(path, { 26 + ...init, 27 + credentials: 'same-origin', 28 + headers: { 29 + 'content-type': 'application/json', 30 + ...(init?.headers || {}) 31 + } 32 + }); 33 + const ct = res.headers.get('content-type') || ''; 34 + const data = ct.includes('application/json') ? await res.json().catch(() => null) : await res.text(); 35 + if (!res.ok) { 36 + const msg = typeof data === 'string' ? data : data?.error || res.statusText; 37 + throw new Error(msg); 38 + } 39 + return data; 40 + }; 41 + } 42 + 43 + function getGravatarUrl(email, size = 40) { 44 + if (!email) return null; 45 + const hash = md5(email.trim().toLowerCase()); 46 + return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`; 47 + } 48 + 49 + function getAvatar(user, size = 40) { 50 + if (user?.avatarUrl) return user.avatarUrl; 51 + return getGravatarUrl(user?.email, size); 52 + } 53 + 54 + function UserDropdown({ user, onLogout }) { 55 + const [open, setOpen] = useState(false); 56 + const avatar = getAvatar(user); 57 + const displayName = user?.username || user?.email || 'User'; 58 + 59 + return ( 60 + <div className="userDropdown"> 61 + <button className="userDropdownTrigger" onClick={() => setOpen(!open)}> 62 + {avatar ? ( 63 + <img src={avatar} alt="" className="avatar" /> 64 + ) : ( 65 + <div className="avatarPlaceholder"> 66 + <User size={20} /> 67 + </div> 68 + )} 69 + <div className="userInfo"> 70 + <span className="userName">{displayName}</span> 71 + <span className="userEmail">{user?.email}</span> 72 + </div> 73 + <ChevronDown size={16} className={`chevron ${open ? 'open' : ''}`} /> 74 + </button> 75 + 76 + {open && ( 77 + <> 78 + <div className="dropdownOverlay" onClick={() => setOpen(false)} /> 79 + <div className="dropdownMenu"> 80 + <Link className="dropdownItem" to="/dashboard/account" onClick={() => setOpen(false)}> 81 + <Settings size={16} /> 82 + Account Settings 83 + </Link> 84 + <button className="dropdownItem" onClick={onLogout}> 85 + <LogOut size={16} /> 86 + Logout 87 + </button> 88 + </div> 89 + </> 90 + )} 91 + </div> 92 + ); 93 + } 94 + 95 + function Sidebar({ sites, selectedId, collapsed, onToggle, user, onLogout, mobileOpen, onMobileClose }) { 96 + const avatar = getAvatar(user, 64); 97 + const displayName = user?.username || user?.email || 'User'; 98 + const location = useLocation(); 99 + 100 + const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768; 101 + const isCollapsed = isMobile ? false : collapsed; 102 + 103 + useEffect(() => { 104 + if (mobileOpen) { 105 + onMobileClose(); 106 + } 107 + }, [location.pathname]); 108 + 109 + return ( 110 + <> 111 + {} 112 + <div className={`sidebarOverlay ${mobileOpen ? 'visible' : ''}`} onClick={onMobileClose} /> 113 + 114 + <aside className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${mobileOpen ? 'mobileOpen' : ''}`}> 115 + <div className="sidebarHeader"> 116 + <Link to="/" className="brand"> 117 + <MillyLogo size={24} /> 118 + {!isCollapsed && <span>boop.cat</span>} 119 + </Link> 120 + </div> 121 + 122 + <nav className="sidebarNav"> 123 + <Link className="sidebarLink" to="/dashboard" title="All websites"> 124 + <LayoutDashboard size={20} /> 125 + {!isCollapsed && <span>All websites</span>} 126 + </Link> 127 + <Link className="sidebarLink" to="/dashboard/account" title="Settings"> 128 + <Settings size={20} /> 129 + {!isCollapsed && <span>Settings</span>} 130 + </Link> 131 + <Link className="sidebarLink" to="/dashboard/api-docs" title="API Docs"> 132 + <Book size={20} /> 133 + {!isCollapsed && <span>API Docs</span>} 134 + </Link> 135 + </nav> 136 + 137 + {!isCollapsed && sites.length > 0 && ( 138 + <div className="sidebarSection"> 139 + <div className="sidebarLabel">Projects</div> 140 + <div className="sidebarProjects"> 141 + {sites.map((s) => ( 142 + <Link 143 + key={s.id} 144 + className={`sidebarProject ${s.id === selectedId ? 'active' : ''}`} 145 + to={`/dashboard/site/${s.id}`} 146 + > 147 + <Globe size={16} /> 148 + <div className="sidebarProjectInfo"> 149 + <span className="sidebarProjectName">{s.name}</span> 150 + <span className="sidebarProjectUrl"> 151 + {s.domain || s.git?.url?.replace('https://github.com/', '') || ''} 152 + </span> 153 + </div> 154 + </Link> 155 + ))} 156 + </div> 157 + </div> 158 + )} 159 + 160 + {isCollapsed && sites.length > 0 && ( 161 + <div className="sidebarSection"> 162 + {sites.map((s) => ( 163 + <Link 164 + key={s.id} 165 + className={`sidebarLink ${s.id === selectedId ? 'active' : ''}`} 166 + to={`/dashboard/site/${s.id}`} 167 + title={s.name} 168 + > 169 + <Globe size={20} /> 170 + </Link> 171 + ))} 172 + </div> 173 + )} 174 + 175 + <div className="sidebarUser"> 176 + <div className="sidebarUserInfo"> 177 + {avatar ? ( 178 + <img src={avatar} alt="" className="sidebarAvatar" /> 179 + ) : ( 180 + <div className="sidebarAvatarPlaceholder"> 181 + <User size={18} /> 182 + </div> 183 + )} 184 + {!isCollapsed && ( 185 + <div className="sidebarUserText"> 186 + <span className="sidebarUserName">{displayName}</span> 187 + <span className="sidebarUserEmail">{user?.email}</span> 188 + </div> 189 + )} 190 + </div> 191 + <button className="sidebarLogout" onClick={onLogout} title="Logout"> 192 + <LogOut size={18} /> 193 + </button> 194 + </div> 195 + 196 + <div className="sidebarFooter"> 197 + <button className="sidebarToggle" onClick={onToggle} title={isCollapsed ? 'Expand' : 'Collapse'}> 198 + {isCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />} 199 + {!isCollapsed && <span>Collapse</span>} 200 + </button> 201 + </div> 202 + </aside> 203 + </> 204 + ); 205 + } 206 + 207 + export default function DashboardLayout() { 208 + const api = useApi(); 209 + const nav = useNavigate(); 210 + const loc = useLocation(); 211 + 212 + const [authChecked, setAuthChecked] = useState(false); 213 + const [me, setMe] = useState(null); 214 + const [sites, setSites] = useState([]); 215 + const [error, setError] = useState(''); 216 + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { 217 + const saved = localStorage.getItem('sidebarCollapsed'); 218 + return saved === 'true'; 219 + }); 220 + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); 221 + 222 + const toggleSidebar = () => { 223 + setSidebarCollapsed((prev) => { 224 + localStorage.setItem('sidebarCollapsed', String(!prev)); 225 + return !prev; 226 + }); 227 + }; 228 + 229 + const toggleMobileMenu = () => { 230 + setMobileMenuOpen((prev) => !prev); 231 + }; 232 + 233 + const closeMobileMenu = () => { 234 + setMobileMenuOpen(false); 235 + }; 236 + 237 + const selectedId = useMemo(() => { 238 + const m = loc.pathname.match(/\/dashboard\/site\/([^/]+)/); 239 + return m ? m[1] : null; 240 + }, [loc.pathname]); 241 + 242 + async function refreshSites() { 243 + const data = await api('/api/sites'); 244 + const arr = Array.isArray(data) ? data : []; 245 + setSites(arr); 246 + return arr; 247 + } 248 + 249 + useEffect(() => { 250 + api('/api/auth/me') 251 + .then((d) => { 252 + if (!d?.authenticated) { 253 + nav('/login'); 254 + return; 255 + } 256 + setMe(d.user); 257 + setAuthChecked(true); 258 + }) 259 + .catch(() => { 260 + nav('/login'); 261 + }); 262 + }, []); 263 + 264 + useEffect(() => { 265 + if (!authChecked) return; 266 + refreshSites().catch((e) => setError(e.message)); 267 + }, [authChecked]); 268 + 269 + async function logout() { 270 + await api('/api/auth/logout', { method: 'POST' }).catch(() => {}); 271 + nav('/'); 272 + } 273 + 274 + if (!authChecked || !me) { 275 + return ( 276 + <div className="auth-page"> 277 + <div className="auth-card" style={{ textAlign: 'center' }}> 278 + <MillyLogo size={48} style={{ marginBottom: 16 }} /> 279 + <h1 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>Loading Dashboard</h1> 280 + <p className="muted">Just a moment...</p> 281 + </div> 282 + </div> 283 + ); 284 + } 285 + 286 + return ( 287 + <div className={`dashWrapper ${sidebarCollapsed ? 'sidebarCollapsed' : ''}`}> 288 + <Sidebar 289 + sites={sites} 290 + selectedId={selectedId} 291 + collapsed={sidebarCollapsed} 292 + onToggle={toggleSidebar} 293 + user={me} 294 + onLogout={logout} 295 + mobileOpen={mobileMenuOpen} 296 + onMobileClose={closeMobileMenu} 297 + /> 298 + <main className="dashMain"> 299 + <div className="dashHeader"> 300 + <button className="mobileMenuBtn" onClick={toggleMobileMenu} aria-label="Toggle menu"> 301 + {mobileMenuOpen ? <X size={20} /> : <Menu size={20} />} 302 + </button> 303 + <ThemeToggle /> 304 + </div> 305 + {error ? <div className="errorBox">{error}</div> : null} 306 + <Outlet 307 + context={{ 308 + api, 309 + me, 310 + sites, 311 + refreshSites, 312 + setError 313 + }} 314 + /> 315 + </main> 316 + </div> 317 + ); 318 + }
+1340
client/src/pages/DashboardSite.jsx
··· 1 + import React, { useEffect, useMemo, useState } from 'react'; 2 + import { useNavigate, useOutletContext, useParams } from 'react-router-dom'; 3 + import { 4 + AlertTriangle, 5 + FolderX, 6 + Zap, 7 + Server, 8 + Plug, 9 + AlertCircle, 10 + HelpCircle, 11 + FileQuestion, 12 + Mail, 13 + Ban, 14 + X, 15 + Lightbulb, 16 + FileText, 17 + ExternalLink, 18 + Check, 19 + Edit, 20 + Eye, 21 + EyeOff, 22 + Trash2, 23 + Plus 24 + } from 'lucide-react'; 25 + 26 + function Toast({ message, onClose }) { 27 + useEffect(() => { 28 + const timer = setTimeout(onClose, 3000); 29 + return () => clearTimeout(timer); 30 + }, [onClose]); 31 + 32 + if (!message) return null; 33 + 34 + return ( 35 + <div className="toast"> 36 + <Check size={16} /> 37 + {message} 38 + </div> 39 + ); 40 + } 41 + 42 + const ERROR_MESSAGES = { 43 + 'site-not-found': { 44 + title: 'Site Not Found', 45 + message: "The site you're looking for doesn't exist or you don't have access to it.", 46 + Icon: FileQuestion 47 + }, 48 + DIRECTORY_NOT_FOUND: { 49 + title: 'Directory Not Found', 50 + message: "The specified directory doesn't exist in the repository.", 51 + Icon: FolderX 52 + }, 53 + NEXT_JS_DETECTED: { 54 + title: 'Dynamic Framework Detected', 55 + message: 'This appears to be a Next.js project which requires a Node.js server.', 56 + Icon: Zap 57 + }, 58 + NUXT_DETECTED: { 59 + title: 'Dynamic Framework Detected', 60 + message: 'This appears to be a Nuxt.js project which requires a Node.js server.', 61 + Icon: Zap 62 + }, 63 + REMIX_DETECTED: { 64 + title: 'Dynamic Framework Detected', 65 + message: 'This appears to be a Remix project which requires a Node.js server.', 66 + Icon: Zap 67 + }, 68 + SERVER_FRAMEWORK_DETECTED: { 69 + title: 'Server Application Detected', 70 + message: 'This appears to be a server application, not a static site.', 71 + Icon: Server 72 + }, 73 + API_ROUTES_DETECTED: { 74 + title: 'API Routes Detected', 75 + message: 'This project contains API routes which require server-side execution.', 76 + Icon: Plug 77 + }, 78 + DYNAMIC_CONTENT_DETECTED: { 79 + title: 'Dynamic Content Detected', 80 + message: "This project requires server-side features that aren't supported.", 81 + Icon: AlertTriangle 82 + }, 83 + UNKNOWN_PROJECT_TYPE: { 84 + title: 'Unknown Project Type', 85 + message: 'Could not detect a supported static site framework.', 86 + Icon: HelpCircle 87 + }, 88 + 'not-a-static-site': { 89 + title: 'Not a Static Site', 90 + message: "This project doesn't appear to be a static website.", 91 + Icon: FileQuestion 92 + }, 93 + 'dynamic-site-detected': { 94 + title: 'Dynamic Site Detected', 95 + message: 'This project requires server-side features.', 96 + Icon: Zap 97 + }, 98 + 'email-not-verified': { 99 + title: 'Email Not Verified', 100 + message: 'Please verify your email address before deploying.', 101 + Icon: Mail 102 + }, 103 + 'too-many-requests': { 104 + title: 'Too Many Requests', 105 + message: "You're making too many requests. Please wait a moment.", 106 + Icon: Ban 107 + } 108 + }; 109 + 110 + function EnvVarModal({ open, onClose, onSave, initialKey, initialValue, isEdit }) { 111 + const [key, setKey] = React.useState(initialKey || ''); 112 + const [value, setValue] = React.useState(initialValue || ''); 113 + 114 + React.useEffect(() => { 115 + if (open) { 116 + setKey(initialKey || ''); 117 + setValue(initialValue || ''); 118 + } 119 + }, [open, initialKey, initialValue]); 120 + 121 + if (!open) return null; 122 + 123 + const handleSave = () => { 124 + if (!key.trim()) return; 125 + onSave(key.trim(), value); 126 + setKey(''); 127 + setValue(''); 128 + }; 129 + 130 + const handleKeyDown = (e) => { 131 + if (e.key === 'Enter' && e.metaKey) { 132 + handleSave(); 133 + } 134 + }; 135 + 136 + return ( 137 + <div className="modalOverlay" onClick={onClose}> 138 + <div className="modal envModal" onClick={(e) => e.stopPropagation()}> 139 + <div className="modalHeader"> 140 + <div className="modalTitle"> 141 + {isEdit ? <Edit size={18} /> : <Plus size={18} />} 142 + {isEdit ? 'Edit Variable' : 'Add Variable'} 143 + </div> 144 + <button className="iconBtn" onClick={onClose}> 145 + <X size={20} /> 146 + </button> 147 + </div> 148 + <div className="modalBody" onKeyDown={handleKeyDown}> 149 + <div className="field"> 150 + <div className="label">Name</div> 151 + <input 152 + className="input envKeyInput" 153 + placeholder="MY_API_KEY" 154 + value={key} 155 + onChange={(e) => setKey(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))} 156 + autoFocus={!isEdit} 157 + disabled={isEdit} 158 + style={isEdit ? { opacity: 0.7, cursor: 'not-allowed' } : {}} 159 + /> 160 + </div> 161 + <div className="field"> 162 + <div className="label">Value</div> 163 + <textarea 164 + className="textarea envValueInput" 165 + placeholder="Enter value..." 166 + value={value} 167 + onChange={(e) => setValue(e.target.value)} 168 + rows={4} 169 + autoFocus={isEdit} 170 + /> 171 + <div className="muted" style={{ fontSize: 11, marginTop: 6 }}> 172 + Press ⌘+Enter to save 173 + </div> 174 + </div> 175 + </div> 176 + <div className="modalActions"> 177 + <button className="btn ghost" onClick={onClose}> 178 + Cancel 179 + </button> 180 + <button className="btn primary" onClick={handleSave} disabled={!key.trim()}> 181 + {isEdit ? 'Update' : 'Add'} 182 + </button> 183 + </div> 184 + </div> 185 + </div> 186 + ); 187 + } 188 + 189 + function ErrorModal({ error, onClose }) { 190 + if (!error) return null; 191 + 192 + const errorInfo = ERROR_MESSAGES[error.code] || 193 + ERROR_MESSAGES[error.error] || { 194 + title: 'Deployment Failed', 195 + message: error.message || 'An unexpected error occurred.', 196 + Icon: AlertCircle 197 + }; 198 + 199 + const IconComponent = errorInfo.Icon || AlertCircle; 200 + 201 + return ( 202 + <div className="modalOverlay" onClick={onClose}> 203 + <div className="modal errorModal" onClick={(e) => e.stopPropagation()}> 204 + <div className="modalHeader"> 205 + <div className="modalTitle"> 206 + <IconComponent size={20} style={{ marginRight: 8 }} /> 207 + {errorInfo.title} 208 + </div> 209 + <button className="iconBtn" onClick={onClose} aria-label="Close"> 210 + <X size={18} /> 211 + </button> 212 + </div> 213 + <div className="modalBody"> 214 + <div className="errorContent"> 215 + <p className="errorMessage">{error.message || errorInfo.message}</p> 216 + {error.suggestion && ( 217 + <div className="errorSuggestion"> 218 + <Lightbulb size={16} style={{ marginRight: 6, flexShrink: 0 }} /> 219 + <div> 220 + <strong>Suggestion:</strong> 221 + <p style={{ margin: '4px 0 0 0' }}>{error.suggestion}</p> 222 + </div> 223 + </div> 224 + )} 225 + {error.details && ( 226 + <details className="errorDetails"> 227 + <summary>Technical Details</summary> 228 + <pre>{JSON.stringify(error.details, null, 2)}</pre> 229 + </details> 230 + )} 231 + </div> 232 + <div className="modalActions"> 233 + <button className="btn primary" onClick={onClose}> 234 + Got it 235 + </button> 236 + </div> 237 + </div> 238 + </div> 239 + </div> 240 + ); 241 + } 242 + 243 + function LogsModal({ deployment, onClose }) { 244 + const [logs, setLogs] = useState(''); 245 + const [loading, setLoading] = useState(true); 246 + const [error, setError] = useState(''); 247 + 248 + useEffect(() => { 249 + if (!deployment) return; 250 + 251 + let cancelled = false; 252 + let timer; 253 + let lastText = null; 254 + 255 + const isTerminal = (status) => { 256 + const s = String(status || '').toLowerCase(); 257 + return s === 'active' || s === 'ready' || s === 'failed' || s === 'stopped' || s === 'canceled'; 258 + }; 259 + 260 + const load = async ({ initial } = {}) => { 261 + try { 262 + if (!cancelled && initial) { 263 + setLoading(true); 264 + setError(''); 265 + } 266 + const res = await fetch(`/api/deployments/${deployment.id}/logs`, { 267 + credentials: 'same-origin' 268 + }); 269 + const text = await res.text().catch(() => ''); 270 + if (!res.ok) throw new Error(text || 'Failed to fetch logs'); 271 + if (!cancelled) { 272 + if (text !== lastText) { 273 + setLogs(text); 274 + lastText = text; 275 + } 276 + if (initial) setLoading(false); 277 + } 278 + } catch (e) { 279 + if (!cancelled) { 280 + setError(e.message); 281 + if (initial) setLoading(false); 282 + } 283 + } 284 + }; 285 + 286 + load({ initial: true }); 287 + if (!isTerminal(deployment.status)) { 288 + timer = window.setInterval(() => load({ initial: false }), 2500); 289 + } 290 + 291 + return () => { 292 + cancelled = true; 293 + if (timer) window.clearInterval(timer); 294 + }; 295 + }, [deployment?.id]); 296 + 297 + if (!deployment) return null; 298 + 299 + return ( 300 + <div className="modalOverlay" onClick={onClose}> 301 + <div className="modal logsModal" onClick={(e) => e.stopPropagation()}> 302 + <div className="modalHeader"> 303 + <div className="modalTitle"> 304 + <FileText size={20} style={{ marginRight: 8 }} /> 305 + Deployment Logs 306 + </div> 307 + <button className="iconBtn" onClick={onClose} aria-label="Close"> 308 + <X size={18} /> 309 + </button> 310 + </div> 311 + <div className="modalBody"> 312 + <div className="logsInfo"> 313 + <span className="badge">{deployment.status}</span> 314 + <span className="muted">{deployment.createdAt}</span> 315 + </div> 316 + {loading && <div className="logsLoading">Loading logs...</div>} 317 + {error && <div className="error">{error}</div>} 318 + {!loading && !error && <pre className="logsContent">{logs || 'No logs available'}</pre>} 319 + </div> 320 + </div> 321 + </div> 322 + ); 323 + } 324 + 325 + export default function DashboardSite() { 326 + const { id } = useParams(); 327 + const nav = useNavigate(); 328 + const { api, me, sites, refreshSites, setError } = useOutletContext(); 329 + 330 + const site = useMemo(() => sites.find((s) => s.id === id) || null, [sites, id]); 331 + 332 + const [deployments, setDeployments] = useState([]); 333 + const [envDraft, setEnvDraft] = useState(''); 334 + const [settingsDraft, setSettingsDraft] = useState({ 335 + name: '', 336 + gitUrl: '', 337 + branch: 'main', 338 + subdir: '', 339 + domain: '', 340 + buildCommand: '', 341 + outputDir: '' 342 + }); 343 + const [tab, setTab] = useState('deployments'); 344 + const [envSubTab, setEnvSubTab] = useState('styled'); 345 + const [deployError, setDeployError] = useState(null); 346 + const [deploying, setDeploying] = useState(false); 347 + const [logsDeployment, setLogsDeployment] = useState(null); 348 + const [customDomains, setCustomDomains] = useState([]); 349 + const [customDomainInput, setCustomDomainInput] = useState(''); 350 + const [customDomainLoading, setCustomDomainLoading] = useState(false); 351 + const [customDomainError, setCustomDomainError] = useState(''); 352 + const [toast, setToast] = useState(null); 353 + const [visibleEnvKeys, setVisibleEnvKeys] = useState(new Set()); 354 + const [envModalOpen, setEnvModalOpen] = useState(false); 355 + const [editingEnv, setEditingEnv] = useState(null); // { key, value } for edit mode 356 + 357 + const repoInfo = useMemo(() => { 358 + const gitUrl = site?.git?.url || ''; 359 + const match = gitUrl.match(/github\.com[/:]([^/]+)\/([^/.]+)/i); 360 + if (!match) return null; 361 + const [, owner, repo] = match; 362 + return { owner, repo }; 363 + }, [site]); 364 + 365 + const formatTimestamp = (iso) => { 366 + if (!iso) return ''; 367 + const d = new Date(iso); 368 + if (Number.isNaN(d.getTime())) return iso; 369 + return new Intl.DateTimeFormat(undefined, { 370 + month: 'short', 371 + day: 'numeric', 372 + hour: 'numeric', 373 + minute: '2-digit', 374 + timeZoneName: 'short' 375 + }).format(d); 376 + }; 377 + 378 + const getCommitAuthorName = (author) => { 379 + if (!author) return null; 380 + const match = author.match(/^([^<]+)</); 381 + return match ? match[1].trim() : author.trim(); 382 + }; 383 + 384 + const getCommitAvatar = (author, deployment) => { 385 + if (deployment?.commitAvatar) { 386 + return deployment.commitAvatar; 387 + } 388 + 389 + const emailMatch = author?.match(/<([^>]+)>/); 390 + const email = emailMatch ? emailMatch[1] : null; 391 + 392 + if (email && email.includes('noreply.github.com')) { 393 + const parts = email.split('@')[0].split('+'); 394 + const username = parts.length > 1 ? parts[1] : parts[0]; 395 + return `https://github.com/${username}.png?size=64`; 396 + } 397 + 398 + if (email) { 399 + const hash = Array.from(new TextEncoder().encode(email.trim().toLowerCase())).reduce((hash, c) => { 400 + hash = (hash << 5) - hash + c; 401 + return hash & hash; 402 + }, 0); 403 + return `https://www.gravatar.com/avatar/${Math.abs(hash)}?d=mp&s=64`; 404 + } 405 + 406 + const fallbackName = getCommitAuthorName(author) || repoInfo?.owner || 'Committer'; 407 + return `https://api.dicebear.com/7.x/initials/svg?backgroundColor=6ba3e8,9b87f5,72d2cf&fontWeight=700&size=64&seed=${encodeURIComponent(fallbackName)}`; 408 + }; 409 + 410 + const activeDeployment = useMemo(() => { 411 + return deployments.find((d) => d.status === 'building' || d.status === 'running') || null; 412 + }, [deployments]); 413 + 414 + const [config, setConfig] = useState({ deliveryMode: '', edgeRootDomain: '' }); 415 + 416 + const edgeOnly = String(config?.deliveryMode || '').toLowerCase() === 'edge' && Boolean(config?.edgeRootDomain); 417 + 418 + function toEdgeLabel(value) { 419 + const v = String(value || '') 420 + .trim() 421 + .toLowerCase(); 422 + const root = String(config?.edgeRootDomain || '') 423 + .trim() 424 + .toLowerCase(); 425 + if (!v || !root) return v; 426 + if (v.endsWith(`.${root}`)) return v.slice(0, -(root.length + 1)); 427 + return v; 428 + } 429 + 430 + useEffect(() => { 431 + if (!site) return; 432 + setEnvDraft(site.envText || ''); 433 + setSettingsDraft({ 434 + name: site.name || '', 435 + gitUrl: site.git?.url || '', 436 + branch: site.git?.branch || 'main', 437 + subdir: site.git?.subdir || '', 438 + domain: edgeOnly ? toEdgeLabel(site.domain || '') : site.domain || '', 439 + buildCommand: site.buildCommand || '', 440 + outputDir: site.outputDir || '' 441 + }); 442 + }, [site?.id, edgeOnly, config?.edgeRootDomain]); 443 + 444 + async function refreshDeployments() { 445 + const data = await api(`/api/sites/${encodeURIComponent(id)}/deployments`); 446 + setDeployments(Array.isArray(data) ? data : []); 447 + } 448 + 449 + async function deleteProject() { 450 + if (!confirm('Delete this project and all its deployments?')) return; 451 + setError(''); 452 + await api(`/api/sites/${encodeURIComponent(site.id)}`, { 453 + method: 'DELETE' 454 + }).catch((e) => { 455 + setError(e.message); 456 + throw e; 457 + }); 458 + await refreshSites(); 459 + nav('/dashboard'); 460 + } 461 + 462 + useEffect(() => { 463 + if (!site) return; 464 + refreshDeployments().catch((e) => setError(e.message)); 465 + 466 + let timer; 467 + if (activeDeployment) { 468 + timer = setInterval(() => { 469 + refreshDeployments().catch(() => {}); 470 + }, 2000); 471 + } 472 + return () => { 473 + if (timer) clearInterval(timer); 474 + }; 475 + }, [site?.id, activeDeployment?.status]); 476 + 477 + useEffect(() => { 478 + if (!site) return; 479 + (async () => { 480 + try { 481 + const data = await api(`/api/sites/${encodeURIComponent(site.id)}/custom-domains`); 482 + setCustomDomains(Array.isArray(data) ? data : []); 483 + } catch (e) { 484 + console.error('Failed to load custom domains:', e); 485 + } 486 + })(); 487 + }, [site?.id]); 488 + 489 + useEffect(() => { 490 + api('/api/config') 491 + .then((d) => setConfig({ deliveryMode: d?.deliveryMode || '', edgeRootDomain: d?.edgeRootDomain || '' })) 492 + .catch(() => setConfig({ deliveryMode: '', edgeRootDomain: '' })); 493 + }, []); 494 + 495 + function parseEnvText(text) { 496 + const lines = String(text || '').split('\n'); 497 + const parsed = lines.map((line) => { 498 + if (!line.trim()) return { key: '', value: '' }; 499 + const equalsIdx = line.indexOf('='); 500 + if (equalsIdx === -1) return { key: line.trim(), value: '' }; 501 + return { key: line.slice(0, equalsIdx).trim(), value: line.slice(equalsIdx + 1) }; 502 + }); 503 + return parsed; 504 + } 505 + 506 + function serializeEnvEntries(entries) { 507 + return entries 508 + .filter((e) => e.key.trim() || e.value.trim()) 509 + .map((e) => `${e.key}=${e.value}`) 510 + .join('\n'); 511 + } 512 + 513 + const envEntries = useMemo(() => { 514 + const parsed = parseEnvText(envDraft); 515 + const nonEmpty = parsed.filter((e) => e.key.trim() || e.value.trim()); 516 + const hasBlank = parsed.some((e) => !e.key.trim() && !e.value.trim()); 517 + if (nonEmpty.length === 0 && !hasBlank) return [{ key: '', value: '' }]; 518 + return parsed; 519 + }, [envDraft]); 520 + 521 + const envFilledCount = useMemo(() => envEntries.filter((e) => e.key.trim()).length, [envEntries]); 522 + 523 + const toggleEnvVisibility = (key) => { 524 + setVisibleEnvKeys((prev) => { 525 + const next = new Set(prev); 526 + if (next.has(key)) next.delete(key); 527 + else next.add(key); 528 + return next; 529 + }); 530 + }; 531 + 532 + const openAddEnvModal = () => { 533 + setEditingEnv(null); 534 + setEnvModalOpen(true); 535 + }; 536 + 537 + const openEditEnvModal = (key, value) => { 538 + setEditingEnv({ key, value }); 539 + setEnvModalOpen(true); 540 + }; 541 + 542 + const handleEnvSave = (key, value) => { 543 + if (editingEnv) { 544 + setEnvDraft((prev) => { 545 + const parsed = parseEnvText(prev); 546 + const next = parsed.map((e) => (e.key === editingEnv.key ? { key, value } : e)); 547 + return serializeEnvEntries(next); 548 + }); 549 + } else { 550 + setEnvDraft((prev) => { 551 + const parsed = parseEnvText(prev); 552 + const exists = parsed.some((e) => e.key === key); 553 + if (exists) { 554 + const next = parsed.map((e) => (e.key === key ? { key, value } : e)); 555 + return serializeEnvEntries(next); 556 + } 557 + return serializeEnvEntries([...parsed, { key, value }]); 558 + }); 559 + } 560 + setEnvModalOpen(false); 561 + setEditingEnv(null); 562 + }; 563 + 564 + const removeEnvEntry = (key) => { 565 + setEnvDraft((prev) => { 566 + const parsed = parseEnvText(prev); 567 + const next = parsed.filter((e) => e.key !== key); 568 + return serializeEnvEntries(next); 569 + }); 570 + setVisibleEnvKeys((prev) => { 571 + const next = new Set(prev); 572 + next.delete(key); 573 + return next; 574 + }); 575 + }; 576 + 577 + if (!site) { 578 + return ( 579 + <div className="page"> 580 + <div className="panel" style={{ textAlign: 'center', padding: '48px 24px' }}> 581 + <img 582 + src="/milly.png" 583 + alt="" 584 + width="56" 585 + height="56" 586 + style={{ imageRendering: 'pixelated', marginBottom: 20, opacity: 0.6 }} 587 + /> 588 + <div className="panelTitle" style={{ fontSize: '1.25rem', marginBottom: 8 }}> 589 + Project not found 590 + </div> 591 + <div className="muted" style={{ marginBottom: 24 }}> 592 + That project doesn't exist or you don't have access. 593 + </div> 594 + <button className="btn primary" onClick={() => nav('/dashboard')}> 595 + Back to Dashboard 596 + </button> 597 + </div> 598 + </div> 599 + ); 600 + } 601 + 602 + async function saveEnv() { 603 + setError(''); 604 + await api(`/api/sites/${encodeURIComponent(site.id)}`, { 605 + method: 'PATCH', 606 + body: JSON.stringify({ envText: envDraft }) 607 + }).catch((e) => { 608 + setError(e.message); 609 + throw e; 610 + }); 611 + setToast('Environment variables saved.'); 612 + await refreshSites(); 613 + } 614 + 615 + async function saveSettings() { 616 + setError(''); 617 + 618 + const payload = { 619 + ...settingsDraft, 620 + domain: settingsDraft.domain 621 + }; 622 + 623 + await api(`/api/sites/${encodeURIComponent(site.id)}/settings`, { 624 + method: 'PATCH', 625 + body: JSON.stringify(payload) 626 + }).catch((e) => { 627 + setError(e.message); 628 + throw e; 629 + }); 630 + setToast('Settings saved.'); 631 + await refreshSites(); 632 + } 633 + 634 + async function stopDeployment(deploymentId) { 635 + setError(''); 636 + try { 637 + await api(`/api/deployments/${encodeURIComponent(deploymentId)}/stop`, { 638 + method: 'POST' 639 + }); 640 + setToast('Deployment stopped.'); 641 + await refreshDeployments(); 642 + } catch (e) { 643 + setError(e.message || 'Failed to stop deployment'); 644 + } 645 + } 646 + 647 + async function deploy() { 648 + setError(''); 649 + setDeployError(null); 650 + setDeploying(true); 651 + 652 + try { 653 + const res = await fetch(`/api/sites/${encodeURIComponent(site.id)}/deploy`, { 654 + method: 'POST', 655 + credentials: 'same-origin', 656 + headers: { 'content-type': 'application/json' } 657 + }); 658 + 659 + const data = await res.json().catch(() => null); 660 + 661 + if (!res.ok) { 662 + setDeployError({ 663 + code: data?.error, 664 + message: data?.message || data?.error || 'Deployment failed', 665 + suggestion: data?.suggestion, 666 + details: data?.details 667 + }); 668 + return; 669 + } 670 + 671 + await refreshDeployments(); 672 + } catch (e) { 673 + setDeployError({ 674 + code: 'network-error', 675 + message: e.message || 'Network error occurred', 676 + suggestion: 'Please check your internet connection and try again.' 677 + }); 678 + } finally { 679 + setDeploying(false); 680 + } 681 + } 682 + 683 + async function addCustomDomain() { 684 + setCustomDomainError(''); 685 + if (!customDomainInput.trim()) { 686 + setCustomDomainError('Enter a hostname.'); 687 + return; 688 + } 689 + setCustomDomainLoading(true); 690 + try { 691 + const data = await api(`/api/sites/${encodeURIComponent(site.id)}/custom-domains`, { 692 + method: 'POST', 693 + headers: { 'content-type': 'application/json' }, 694 + body: JSON.stringify({ hostname: customDomainInput.trim() }) 695 + }); 696 + setCustomDomains((prev) => [...prev, data]); 697 + setCustomDomainInput(''); 698 + } catch (e) { 699 + setCustomDomainError(e.message || 'Failed to add custom domain.'); 700 + } finally { 701 + setCustomDomainLoading(false); 702 + } 703 + } 704 + 705 + async function pollCustomDomain(customDomainId) { 706 + try { 707 + const data = await api( 708 + `/api/sites/${encodeURIComponent(site.id)}/custom-domains/${encodeURIComponent(customDomainId)}/poll`, 709 + { method: 'POST' } 710 + ); 711 + setCustomDomains((prev) => prev.map((d) => (d.id === customDomainId ? { ...d, ...data } : d))); 712 + } catch (e) { 713 + console.error('Poll failed:', e); 714 + } 715 + } 716 + 717 + async function removeCustomDomain(customDomainId) { 718 + if (!confirm('Remove this custom domain?')) return; 719 + try { 720 + await api(`/api/sites/${encodeURIComponent(site.id)}/custom-domains/${encodeURIComponent(customDomainId)}`, { 721 + method: 'DELETE' 722 + }); 723 + setCustomDomains((prev) => prev.filter((d) => d.id !== customDomainId)); 724 + } catch (e) { 725 + setError(e.message || 'Failed to remove custom domain.'); 726 + } 727 + } 728 + 729 + return ( 730 + <div className="page"> 731 + <div className="pageHeader"> 732 + <div> 733 + <div className="crumbs"> 734 + <span className="muted">Dashboard</span> 735 + <span className="muted">/</span> 736 + <span className="crumb">{site.name}</span> 737 + </div> 738 + <div className="h">{site.name}</div> 739 + <div className="muted">{site.domain ? site.domain : site.git?.url}</div> 740 + </div> 741 + 742 + <div className="topActions"> 743 + <button className="btn primary" disabled={deploying || (me && me.emailVerified === false)} onClick={deploy}> 744 + {deploying ? 'Deploying...' : 'Deploy'} 745 + </button> 746 + 747 + <button 748 + className="btn ghost" 749 + disabled={!activeDeployment || (me && me.emailVerified === false)} 750 + onClick={() => activeDeployment && stopDeployment(activeDeployment.id)} 751 + style={{ marginLeft: 10 }} 752 + > 753 + Stop 754 + </button> 755 + </div> 756 + </div> 757 + 758 + {me && me.emailVerified === false ? <div className="notice">Verify your email to deploy.</div> : null} 759 + 760 + <ErrorModal error={deployError} onClose={() => setDeployError(null)} /> 761 + <LogsModal deployment={logsDeployment} onClose={() => setLogsDeployment(null)} /> 762 + <EnvVarModal 763 + open={envModalOpen} 764 + onClose={() => { 765 + setEnvModalOpen(false); 766 + setEditingEnv(null); 767 + }} 768 + onSave={handleEnvSave} 769 + initialKey={editingEnv?.key || ''} 770 + initialValue={editingEnv?.value || ''} 771 + isEdit={Boolean(editingEnv)} 772 + /> 773 + {toast && <Toast message={toast} onClose={() => setToast(null)} />} 774 + 775 + <div className="panel"> 776 + <div className="tabs"> 777 + <button className={tab === 'deployments' ? 'tab active' : 'tab'} onClick={() => setTab('deployments')}> 778 + Deployments 779 + </button> 780 + <button className={tab === 'env' ? 'tab active' : 'tab'} onClick={() => setTab('env')}> 781 + Env 782 + </button> 783 + <button className={tab === 'settings' ? 'tab active' : 'tab'} onClick={() => setTab('settings')}> 784 + Settings 785 + </button> 786 + {edgeOnly ? ( 787 + <button className={tab === 'domains' ? 'tab active' : 'tab'} onClick={() => setTab('domains')}> 788 + Domains 789 + </button> 790 + ) : null} 791 + </div> 792 + 793 + {tab === 'deployments' ? ( 794 + <> 795 + <div className="panelTitle" style={{ marginTop: 12 }}> 796 + Deployments 797 + </div> 798 + <div className="muted" style={{ marginBottom: 10 }}> 799 + Deploy + logs + stop 800 + </div> 801 + <div className="deployList"> 802 + {deployments.map((d) => ( 803 + <div key={d.id} className="deployRow"> 804 + <div className="deployRowMain"> 805 + <div className="deployRowTop"> 806 + <span className={`statusPill status-${d.status}`}> 807 + {d.status === 'active' ? 'running' : d.status} 808 + </span> 809 + <span className="muted deployTime">{formatTimestamp(d.createdAt)}</span> 810 + </div> 811 + 812 + <div className="deployLinks"> 813 + {d.url ? ( 814 + <a href={d.url} target="_blank" rel="noopener noreferrer" className="muted linkChip"> 815 + {d.url.replace(/^https?:\/\//, '')}{' '} 816 + <ExternalLink size={12} style={{ marginLeft: 4, verticalAlign: 'middle' }} /> 817 + </a> 818 + ) : null} 819 + 820 + {} 821 + {site.domain && (!d.url || !d.url.includes(site.domain)) && ( 822 + <a 823 + href={`https://${site.domain}`} 824 + target="_blank" 825 + rel="noopener noreferrer" 826 + className="muted linkChip" 827 + > 828 + {site.domain} <ExternalLink size={12} style={{ marginLeft: 4, verticalAlign: 'middle' }} /> 829 + </a> 830 + )} 831 + 832 + {(d.status === 'running' || d.status === 'active') && 833 + customDomains 834 + .filter((cd) => cd.status === 'active') 835 + .filter((cd) => !d.url || !d.url.includes(cd.hostname)) 836 + .map((cd) => ( 837 + <a 838 + key={cd.id} 839 + href={`https://${cd.hostname}`} 840 + target="_blank" 841 + rel="noopener noreferrer" 842 + className="muted linkChip" 843 + > 844 + {cd.hostname}{' '} 845 + <ExternalLink size={12} style={{ marginLeft: 4, verticalAlign: 'middle' }} /> 846 + </a> 847 + ))} 848 + </div> 849 + 850 + {(d.commitSha || d.commitMessage || d.commitAuthor) && ( 851 + <div className="deployCommit"> 852 + {d.commitAuthor && ( 853 + <div className="commitAuthorInfo"> 854 + <img 855 + className="commitAvatar" 856 + src={getCommitAvatar(d.commitAuthor, d)} 857 + alt={getCommitAuthorName(d.commitAuthor) || 'Committer'} 858 + loading="lazy" 859 + /> 860 + <span className="commitAuthorName"> 861 + {getCommitAuthorName(d.commitAuthor) || d.commitAuthor} 862 + </span> 863 + </div> 864 + )} 865 + {d.commitMessage && ( 866 + <span className="commitMessage" title={d.commitMessage}> 867 + {d.commitMessage} 868 + </span> 869 + )} 870 + {(() => { 871 + const shortSha = d.commitSha ? d.commitSha.slice(0, 7) : 'unknown'; 872 + const commitUrl = 873 + repoInfo?.owner && repoInfo?.repo && d.commitSha 874 + ? `https://github.com/${repoInfo.owner}/${repoInfo.repo}/commit/${d.commitSha}` 875 + : null; 876 + return commitUrl ? ( 877 + <a className="commitBadge" href={commitUrl} target="_blank" rel="noopener noreferrer"> 878 + {shortSha} 879 + </a> 880 + ) : ( 881 + <span className="commitBadge">{shortSha}</span> 882 + ); 883 + })()} 884 + </div> 885 + )} 886 + </div> 887 + 888 + <div className="actions"> 889 + <button className="btn ghost" onClick={() => setLogsDeployment(d)}> 890 + <FileText size={14} style={{ marginRight: 6 }} /> 891 + Logs 892 + </button> 893 + </div> 894 + </div> 895 + ))} 896 + {deployments.length === 0 ? <div className="muted">No deployments yet.</div> : null} 897 + </div> 898 + </> 899 + ) : null} 900 + 901 + {tab === 'env' ? ( 902 + <> 903 + <div className="envHeader"> 904 + <div className="envHeaderLeft"> 905 + <div className="panelTitle" style={{ margin: 0 }}> 906 + Environment Variables 907 + </div> 908 + </div> 909 + <div className="envHeaderRight"> 910 + <button className="btn ghost" onClick={() => setEnvSubTab(envSubTab === 'styled' ? 'raw' : 'styled')}> 911 + {envSubTab === 'styled' ? 'Raw Editor' : 'Visual Editor'} 912 + </button> 913 + </div> 914 + </div> 915 + 916 + {envSubTab === 'styled' ? ( 917 + <div className="envContainer"> 918 + <div className="envDescription"> 919 + <div className="muted"> 920 + Environment variables are encrypted and injected during the build process. 921 + </div> 922 + </div> 923 + 924 + {envEntries.filter((e) => e.key.trim()).length === 0 ? ( 925 + <> 926 + <div className="envEmptyState"> 927 + <div className="envEmptyIcon"> 928 + <FileText size={32} strokeWidth={1.5} /> 929 + </div> 930 + <div className="envEmptyTitle">No variables configured</div> 931 + <div className="envEmptyDesc"> 932 + Add environment variables like API keys, secrets, or configuration values. 933 + </div> 934 + <button className="btn primary" onClick={openAddEnvModal}> 935 + <Plus size={16} /> Add Variable 936 + </button> 937 + </div> 938 + {site.envText && ( 939 + <div className="envFooter"> 940 + <div /> 941 + <button className="btn primary" onClick={saveEnv}> 942 + Save Changes 943 + </button> 944 + </div> 945 + )} 946 + </> 947 + ) : ( 948 + <> 949 + <div className="envTable"> 950 + <div className="envTableHeader"> 951 + <div className="envTableCell envTableName">Name</div> 952 + <div className="envTableCell envTableValue">Value</div> 953 + <div className="envTableCell envTableActions"></div> 954 + </div> 955 + {envEntries 956 + .filter((e) => e.key.trim()) 957 + .map((entry) => ( 958 + <div key={entry.key} className="envTableRow"> 959 + <div className="envTableCell envTableName"> 960 + <code className="envKeyCode">{entry.key}</code> 961 + </div> 962 + <div className="envTableCell envTableValue"> 963 + <div className="envValueContainer"> 964 + {visibleEnvKeys.has(entry.key) ? ( 965 + <code className="envValueCode"> 966 + {entry.value || <span className="muted">(empty)</span>} 967 + </code> 968 + ) : ( 969 + <span className="envMasked"> 970 + {'•'.repeat(Math.min(entry.value?.length || 12, 24))} 971 + </span> 972 + )} 973 + </div> 974 + </div> 975 + <div className="envTableCell envTableActions"> 976 + <button 977 + className="envActionBtn" 978 + onClick={() => toggleEnvVisibility(entry.key)} 979 + title={visibleEnvKeys.has(entry.key) ? 'Hide value' : 'Show value'} 980 + > 981 + {visibleEnvKeys.has(entry.key) ? <EyeOff size={16} /> : <Eye size={16} />} 982 + </button> 983 + <button 984 + className="envActionBtn" 985 + onClick={() => openEditEnvModal(entry.key, entry.value)} 986 + title="Edit" 987 + > 988 + <Edit size={16} /> 989 + </button> 990 + <button 991 + className="envActionBtn envActionBtnDanger" 992 + onClick={() => removeEnvEntry(entry.key)} 993 + title="Remove" 994 + > 995 + <Trash2 size={16} /> 996 + </button> 997 + </div> 998 + </div> 999 + ))} 1000 + </div> 1001 + <div className="envFooter"> 1002 + <button className="btn ghost" onClick={openAddEnvModal}> 1003 + <Plus size={16} /> Add Variable 1004 + </button> 1005 + <button className="btn primary" onClick={saveEnv}> 1006 + Save Changes 1007 + </button> 1008 + </div> 1009 + </> 1010 + )} 1011 + </div> 1012 + ) : ( 1013 + <div className="envContainer"> 1014 + <div className="envDescription"> 1015 + <div className="muted"> 1016 + Edit variables directly. One per line: <code>KEY=value</code> 1017 + </div> 1018 + </div> 1019 + <textarea 1020 + className="textarea envRawTextarea" 1021 + value={envDraft} 1022 + onChange={(e) => setEnvDraft(e.target.value)} 1023 + placeholder="API_KEY=your-api-key&#10;DATABASE_URL=postgres://..." 1024 + spellCheck={false} 1025 + /> 1026 + <div className="envFooter"> 1027 + <button className="btn primary" onClick={saveEnv}> 1028 + Save Changes 1029 + </button> 1030 + </div> 1031 + </div> 1032 + )} 1033 + </> 1034 + ) : null} 1035 + 1036 + {tab === 'domains' && edgeOnly ? ( 1037 + <> 1038 + <div className="panelTitle" style={{ marginTop: 12 }}> 1039 + Custom Domains 1040 + </div> 1041 + <div className="muted" style={{ marginBottom: 16 }}> 1042 + Point your own domain to this site. 1043 + </div> 1044 + 1045 + <div className="row" style={{ marginBottom: 16 }}> 1046 + <div className="field" style={{ flex: 1 }}> 1047 + <div className="label">Hostname</div> 1048 + <input 1049 + className="input" 1050 + placeholder="www.example.com" 1051 + value={customDomainInput} 1052 + onChange={(e) => setCustomDomainInput(e.target.value)} 1053 + disabled={customDomainLoading} 1054 + /> 1055 + </div> 1056 + <div className="field" style={{ alignSelf: 'flex-end' }}> 1057 + <button 1058 + className="btn primary" 1059 + onClick={addCustomDomain} 1060 + disabled={customDomainLoading || !customDomainInput.trim()} 1061 + > 1062 + {customDomainLoading ? 'Adding...' : 'Add'} 1063 + </button> 1064 + </div> 1065 + </div> 1066 + {customDomainError && ( 1067 + <div className="error" style={{ marginBottom: 16 }}> 1068 + {customDomainError} 1069 + </div> 1070 + )} 1071 + 1072 + {customDomains.length > 0 && ( 1073 + <div className="deployList"> 1074 + {customDomains.map((d) => { 1075 + const txtRecord = d.verificationRecords?.find((r) => r.type === 'txt' || r.type === 'ssl_txt'); 1076 + const isPending = d.status !== 'active'; 1077 + const needsSetup = d.status === 'pending' || d.status === 'pending_validation'; 1078 + 1079 + return ( 1080 + <div key={d.id} className="deployRow" style={{ flexDirection: 'column', alignItems: 'stretch' }}> 1081 + <div 1082 + style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }} 1083 + > 1084 + <div> 1085 + <div className="siteName">{d.hostname}</div> 1086 + <div className="muted"> 1087 + {d.status === 'active' && <span style={{ color: '#15803d' }}>✓ Active</span>} 1088 + {d.status === 'pending' && '⏳ Pending verification'} 1089 + {d.status === 'pending_ssl' && 1090 + '⏳ SSL certificate issuing... (this may take a few minutes)'} 1091 + {d.status === 'pending_validation' && '⏳ Verifying ownership...'} 1092 + </div> 1093 + </div> 1094 + <div className="actions"> 1095 + {isPending && ( 1096 + <button className="btn ghost" onClick={() => pollCustomDomain(d.id)}> 1097 + Check 1098 + </button> 1099 + )} 1100 + <button className="btn danger" onClick={() => removeCustomDomain(d.id)}> 1101 + Remove 1102 + </button> 1103 + </div> 1104 + </div> 1105 + 1106 + {needsSetup && ( 1107 + <div 1108 + style={{ 1109 + marginTop: 12, 1110 + padding: 12, 1111 + background: 'rgba(255,122,0,.04)', 1112 + borderRadius: 8, 1113 + border: '1px solid rgba(255,122,0,.1)' 1114 + }} 1115 + > 1116 + <div style={{ fontWeight: 700, fontSize: 13, marginBottom: 8 }}>Setup Instructions</div> 1117 + 1118 + {txtRecord && ( 1119 + <div style={{ marginBottom: 12 }}> 1120 + <div className="muted" style={{ fontSize: 12, marginBottom: 4 }}> 1121 + 1. Add this TXT record to verify ownership: 1122 + </div> 1123 + <div 1124 + style={{ 1125 + background: 'rgba(0,0,0,.03)', 1126 + padding: 8, 1127 + borderRadius: 6, 1128 + fontSize: 12, 1129 + fontFamily: 'monospace' 1130 + }} 1131 + > 1132 + <div> 1133 + <strong>Type:</strong> TXT 1134 + </div> 1135 + <div> 1136 + <strong>Name:</strong> {txtRecord.name} 1137 + </div> 1138 + <div style={{ wordBreak: 'break-all' }}> 1139 + <strong>Value:</strong> {txtRecord.value} 1140 + </div> 1141 + </div> 1142 + </div> 1143 + )} 1144 + 1145 + <div> 1146 + <div className="muted" style={{ fontSize: 12, marginBottom: 4 }}> 1147 + {txtRecord ? '2.' : '1.'} Add a CNAME record: 1148 + </div> 1149 + <div 1150 + style={{ 1151 + background: 'rgba(0,0,0,.03)', 1152 + padding: 8, 1153 + borderRadius: 6, 1154 + fontSize: 12, 1155 + fontFamily: 'monospace' 1156 + }} 1157 + > 1158 + <div> 1159 + <strong>Type:</strong> CNAME 1160 + </div> 1161 + <div> 1162 + <strong>Name:</strong>{' '} 1163 + {d.hostname.split('.').length === 2 ? '@' : d.hostname.split('.')[0]} 1164 + </div> 1165 + <div> 1166 + <strong>Target:</strong> sites.{config.edgeRootDomain} 1167 + </div> 1168 + </div> 1169 + </div> 1170 + 1171 + <div className="muted" style={{ fontSize: 12, marginTop: 8 }}> 1172 + After adding records, wait a few minutes then click "Check". 1173 + </div> 1174 + </div> 1175 + )} 1176 + </div> 1177 + ); 1178 + })} 1179 + </div> 1180 + )} 1181 + </> 1182 + ) : null} 1183 + 1184 + {tab === 'settings' ? ( 1185 + <div className="settingsContainer"> 1186 + {} 1187 + <div className="settingsSection"> 1188 + <div className="settingsSectionHeader"> 1189 + <div className="settingsSectionIcon"> 1190 + <FileText size={20} /> 1191 + </div> 1192 + <div> 1193 + <div className="settingsSectionTitle">General</div> 1194 + <div className="settingsSectionDesc">Basic project information</div> 1195 + </div> 1196 + </div> 1197 + <div className="settingsGrid"> 1198 + <div className="settingsField"> 1199 + <label className="settingsLabel">Project Name</label> 1200 + <input 1201 + className="input" 1202 + value={settingsDraft.name} 1203 + onChange={(e) => setSettingsDraft((s) => ({ ...s, name: e.target.value }))} 1204 + /> 1205 + </div> 1206 + <div className="settingsField"> 1207 + <label className="settingsLabel">Subdomain</label> 1208 + {edgeOnly && config.edgeRootDomain ? ( 1209 + <div className="settingsInputGroup"> 1210 + <input 1211 + className="input" 1212 + placeholder="my-site" 1213 + value={settingsDraft.domain} 1214 + onChange={(e) => setSettingsDraft((s) => ({ ...s, domain: e.target.value }))} 1215 + /> 1216 + <span className="settingsInputSuffix">.{config.edgeRootDomain}</span> 1217 + </div> 1218 + ) : ( 1219 + <input 1220 + className="input" 1221 + placeholder="my-site" 1222 + value={settingsDraft.domain} 1223 + onChange={(e) => setSettingsDraft((s) => ({ ...s, domain: e.target.value }))} 1224 + /> 1225 + )} 1226 + </div> 1227 + </div> 1228 + </div> 1229 + 1230 + {} 1231 + <div className="settingsSection"> 1232 + <div className="settingsSectionHeader"> 1233 + <div className="settingsSectionIcon"> 1234 + <ExternalLink size={20} /> 1235 + </div> 1236 + <div> 1237 + <div className="settingsSectionTitle">Repository</div> 1238 + <div className="settingsSectionDesc">Source code configuration</div> 1239 + </div> 1240 + </div> 1241 + <div className="settingsFieldFull"> 1242 + <label className="settingsLabel">Git URL</label> 1243 + <input 1244 + className="input" 1245 + placeholder="https://github.com/user/repo" 1246 + value={settingsDraft.gitUrl} 1247 + onChange={(e) => setSettingsDraft((s) => ({ ...s, gitUrl: e.target.value }))} 1248 + /> 1249 + </div> 1250 + <div className="settingsGrid"> 1251 + <div className="settingsField"> 1252 + <label className="settingsLabel">Branch</label> 1253 + <input 1254 + className="input" 1255 + placeholder="main" 1256 + value={settingsDraft.branch} 1257 + onChange={(e) => setSettingsDraft((s) => ({ ...s, branch: e.target.value }))} 1258 + /> 1259 + </div> 1260 + <div className="settingsField"> 1261 + <label className="settingsLabel">Root Directory</label> 1262 + <input 1263 + className="input" 1264 + placeholder="/ (root)" 1265 + value={settingsDraft.subdir} 1266 + onChange={(e) => setSettingsDraft((s) => ({ ...s, subdir: e.target.value }))} 1267 + /> 1268 + <span className="settingsFieldHint">Leave empty for repository root</span> 1269 + </div> 1270 + </div> 1271 + </div> 1272 + 1273 + {} 1274 + <div className="settingsSection"> 1275 + <div className="settingsSectionHeader"> 1276 + <div className="settingsSectionIcon"> 1277 + <Zap size={20} /> 1278 + </div> 1279 + <div> 1280 + <div className="settingsSectionTitle">Build & Output</div> 1281 + <div className="settingsSectionDesc">Configure build process (auto-detected if empty)</div> 1282 + </div> 1283 + </div> 1284 + <div className="settingsGrid"> 1285 + <div className="settingsField"> 1286 + <label className="settingsLabel">Build Command</label> 1287 + <input 1288 + className="input" 1289 + placeholder="npm run build" 1290 + value={settingsDraft.buildCommand} 1291 + onChange={(e) => setSettingsDraft((s) => ({ ...s, buildCommand: e.target.value }))} 1292 + /> 1293 + </div> 1294 + <div className="settingsField"> 1295 + <label className="settingsLabel">Output Directory</label> 1296 + <input 1297 + className="input" 1298 + placeholder="dist" 1299 + value={settingsDraft.outputDir} 1300 + onChange={(e) => setSettingsDraft((s) => ({ ...s, outputDir: e.target.value }))} 1301 + /> 1302 + </div> 1303 + </div> 1304 + </div> 1305 + 1306 + <div className="settingsActions"> 1307 + <button className="btn primary" onClick={saveSettings}> 1308 + Save Settings 1309 + </button> 1310 + </div> 1311 + 1312 + {} 1313 + <div className="settingsSection settingsDanger"> 1314 + <div className="settingsSectionHeader"> 1315 + <div className="settingsSectionIcon settingsDangerIcon"> 1316 + <Trash2 size={20} /> 1317 + </div> 1318 + <div> 1319 + <div className="settingsSectionTitle">Danger Zone</div> 1320 + <div className="settingsSectionDesc">Irreversible actions</div> 1321 + </div> 1322 + </div> 1323 + <div className="settingsDangerContent"> 1324 + <div> 1325 + <div className="settingsDangerTitle">Delete Project</div> 1326 + <div className="settingsDangerDesc"> 1327 + Permanently delete this project and all its deployments. This action cannot be undone. 1328 + </div> 1329 + </div> 1330 + <button className="btn danger" onClick={deleteProject}> 1331 + Delete Project 1332 + </button> 1333 + </div> 1334 + </div> 1335 + </div> 1336 + ) : null} 1337 + </div> 1338 + </div> 1339 + ); 1340 + }
+67
client/src/pages/Dmca.jsx
··· 1 + import React from 'react'; 2 + import { Link } from 'react-router-dom'; 3 + import ThemeToggle from '../components/ThemeToggle.jsx'; 4 + 5 + export default function Dmca() { 6 + return ( 7 + <div className="legal-page"> 8 + <div className="legal-theme-toggle"> 9 + <ThemeToggle /> 10 + </div> 11 + <div className="legal-card"> 12 + <div className="legal-header"> 13 + <h1>DMCA Policy</h1> 14 + <span className="legal-date">Last updated: December 19, 2025</span> 15 + </div> 16 + 17 + <div className="legal-content"> 18 + <p> 19 + boop.cat respects the intellectual property rights of others and expects its users to do the same. In 20 + accordance with the Digital Millennium Copyright Act of 1998, the text of which may be found on the U.S. 21 + Copyright Office website at{' '} 22 + <a href="http://www.copyright.gov/legislation/dmca.pdf" target="_blank" rel="noopener noreferrer"> 23 + http: 24 + </a> 25 + , we will respond expeditiously to claims of copyright infringement committed using the Service. 26 + </p> 27 + 28 + <h2>Reporting Copyright Infringement</h2> 29 + <p> 30 + If you are a copyright owner, or are authorized to act on behalf of one, or authorized to act under any 31 + exclusive right under copyright, please report alleged copyright infringements taking place on or through 32 + the Service by sending a notice to our designated agent at: 33 + </p> 34 + 35 + <div className="legal-callout"> 36 + <strong>Email:</strong> <a href="mailto:dmca@boop.cat">dmca@boop.cat</a> 37 + </div> 38 + 39 + <p> 40 + Upon receipt of the Notice as described below, we will take whatever action, in our sole discretion, we deem 41 + appropriate, including removal of the challenged material from the Site. 42 + </p> 43 + 44 + <h2>What to include</h2> 45 + <p>Please include the following in your notice:</p> 46 + <ul> 47 + <li>Identify the copyrighted work that you claim has been infringed.</li> 48 + <li> 49 + Identify the material that you claim is infringing (or to be the subject of infringing activity) and that 50 + is to be removed or access to which is to be disabled, and information reasonably sufficient to permit us 51 + to locate the material (e.g., the URL). 52 + </li> 53 + <li>Your contact information (email, address, phone number).</li> 54 + <li> 55 + A statement that you have a good faith belief that use of the material in the manner complained of is not 56 + authorized by the copyright owner, its agent, or the law. 57 + </li> 58 + </ul> 59 + </div> 60 + 61 + <div className="legal-footer"> 62 + <Link to="/">← Back to home</Link> 63 + </div> 64 + </div> 65 + </div> 66 + ); 67 + }
+418
client/src/pages/Login.jsx
··· 1 + import React, { useState, useRef, useEffect, useCallback } from 'react'; 2 + import ThemeToggle from '../components/ThemeToggle.jsx'; 3 + 4 + export default function Login() { 5 + const [email, setEmail] = useState(''); 6 + const [password, setPassword] = useState(''); 7 + const [error, setError] = useState(''); 8 + const [loading, setLoading] = useState(false); 9 + const [providersLoaded, setProvidersLoaded] = useState(false); 10 + const [providers, setProviders] = useState({ github: false, google: false, atproto: false }); 11 + const [turnstileToken, setTurnstileToken] = useState(''); 12 + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); 13 + const [showAtpModal, setShowAtpModal] = useState(false); 14 + const [atpHandle, setAtpHandle] = useState(''); 15 + const [atpError, setAtpError] = useState(''); 16 + const [showForgotModal, setShowForgotModal] = useState(false); 17 + const [forgotEmail, setForgotEmail] = useState(''); 18 + const [forgotLoading, setForgotLoading] = useState(false); 19 + const [forgotSuccess, setForgotSuccess] = useState(false); 20 + const turnstileRef = useRef(null); 21 + const atpInputRef = useRef(null); 22 + const forgotInputRef = useRef(null); 23 + 24 + useEffect(() => { 25 + fetch('/api/auth/me', { credentials: 'same-origin' }) 26 + .then((r) => r.json()) 27 + .then((d) => { 28 + if (d?.authenticated) window.location.href = '/dashboard'; 29 + }) 30 + .catch(() => {}); 31 + }, []); 32 + 33 + useEffect(() => { 34 + fetch('/api/auth/providers') 35 + .then((r) => r.json()) 36 + .then((data) => { 37 + setProviders(data); 38 + setProvidersLoaded(true); 39 + if (data.turnstileSiteKey) { 40 + setTurnstileSiteKey(data.turnstileSiteKey); 41 + } 42 + }) 43 + .catch(() => { 44 + setProvidersLoaded(true); 45 + }); 46 + }, []); 47 + 48 + useEffect(() => { 49 + if (showAtpModal && atpInputRef.current) { 50 + const timer = setTimeout(() => { 51 + atpInputRef.current?.focus(); 52 + }, 50); 53 + return () => clearTimeout(timer); 54 + } 55 + }, [showAtpModal]); 56 + 57 + useEffect(() => { 58 + if (showForgotModal && forgotInputRef.current) { 59 + const timer = setTimeout(() => { 60 + forgotInputRef.current?.focus(); 61 + }, 50); 62 + return () => clearTimeout(timer); 63 + } 64 + }, [showForgotModal]); 65 + 66 + const handleAtprotoClick = useCallback((e) => { 67 + e.preventDefault(); 68 + e.stopPropagation(); 69 + setShowAtpModal(true); 70 + setAtpError(''); 71 + }, []); 72 + 73 + const handleForgotClick = useCallback((e) => { 74 + e.preventDefault(); 75 + setShowForgotModal(true); 76 + setForgotSuccess(false); 77 + setForgotEmail(''); 78 + }, []); 79 + 80 + async function submitForgotPassword() { 81 + if (!forgotEmail.trim()) return; 82 + setForgotLoading(true); 83 + try { 84 + await fetch('/api/auth/forgot-password', { 85 + method: 'POST', 86 + headers: { 'content-type': 'application/json' }, 87 + body: JSON.stringify({ email: forgotEmail.trim() }) 88 + }); 89 + setForgotSuccess(true); 90 + } finally { 91 + setForgotLoading(false); 92 + } 93 + } 94 + 95 + function startAtproto() { 96 + if (!atpHandle.trim()) { 97 + setAtpError('Enter your handle, e.g., yourname.bsky.social'); 98 + return; 99 + } 100 + setAtpError(''); 101 + const url = `/auth/atproto?handle=${encodeURIComponent(atpHandle.trim())}`; 102 + window.location.href = url; 103 + } 104 + 105 + useEffect(() => { 106 + if (!turnstileSiteKey) return; 107 + 108 + if (!document.getElementById('cf-turnstile-script')) { 109 + const script = document.createElement('script'); 110 + script.id = 'cf-turnstile-script'; 111 + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; 112 + script.async = true; 113 + document.head.appendChild(script); 114 + } 115 + }, [turnstileSiteKey]); 116 + 117 + useEffect(() => { 118 + if (!turnstileSiteKey || !turnstileRef.current) return; 119 + 120 + const renderWidget = () => { 121 + if (window.turnstile && turnstileRef.current) { 122 + turnstileRef.current.innerHTML = ''; 123 + window.turnstile.render(turnstileRef.current, { 124 + sitekey: turnstileSiteKey, 125 + callback: (token) => setTurnstileToken(token), 126 + 'expired-callback': () => setTurnstileToken(''), 127 + 'error-callback': () => setTurnstileToken('') 128 + }); 129 + } 130 + }; 131 + 132 + if (window.turnstile) { 133 + renderWidget(); 134 + } else { 135 + const script = document.getElementById('cf-turnstile-script'); 136 + script?.addEventListener('load', renderWidget); 137 + } 138 + }, [turnstileSiteKey]); 139 + 140 + async function onSubmit(e) { 141 + e.preventDefault(); 142 + setError(''); 143 + setLoading(true); 144 + 145 + try { 146 + const res = await fetch('/api/auth/login', { 147 + method: 'POST', 148 + headers: { 'content-type': 'application/json' }, 149 + body: JSON.stringify({ email, password, turnstileToken }) 150 + }); 151 + 152 + if (!res.ok) { 153 + const data = await res.json().catch(() => null); 154 + const errCode = data?.error || data?.message || ''; 155 + const errorMessages = { 156 + 'user-banned': 'Your account has been banned. Contact support if you believe this is an error.', 157 + 'captcha-failed': 'Captcha verification failed. Please try again.', 158 + 'Invalid credentials': 'Invalid email or password.', 159 + 'login-failed': 'Login failed. Please check your credentials.' 160 + }; 161 + setError(errorMessages[errCode] || errCode || 'Login failed'); 162 + 163 + if (window.turnstile && turnstileRef.current) { 164 + window.turnstile.reset(turnstileRef.current); 165 + } 166 + setTurnstileToken(''); 167 + return; 168 + } 169 + window.location.href = '/dashboard'; 170 + } finally { 171 + setLoading(false); 172 + } 173 + } 174 + 175 + return ( 176 + <div className="auth-page"> 177 + <div className="auth-theme-toggle"> 178 + <ThemeToggle /> 179 + </div> 180 + <div className="auth-card"> 181 + <h1>Welcome back</h1> 182 + <p className="subtitle">Log in to your account</p> 183 + 184 + {(providers.github || providers.google || providers.atproto) && ( 185 + <> 186 + <div className="oauth-providers"> 187 + {providers.github && ( 188 + <a className="oauth-btn" href="/auth/github" title="Continue with GitHub"> 189 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 190 + <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> 191 + </svg> 192 + <span>GitHub</span> 193 + </a> 194 + )} 195 + {providers.google && ( 196 + <a className="oauth-btn" href="/auth/google" title="Continue with Google"> 197 + <svg width="20" height="20" viewBox="0 0 24 24"> 198 + <path 199 + fill="#4285F4" 200 + d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" 201 + /> 202 + <path 203 + fill="#34A853" 204 + d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" 205 + /> 206 + <path 207 + fill="#FBBC05" 208 + d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" 209 + /> 210 + <path 211 + fill="#EA4335" 212 + d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" 213 + /> 214 + </svg> 215 + <span>Google</span> 216 + </a> 217 + )} 218 + {providers.atproto && ( 219 + <button type="button" className="oauth-btn" onClick={handleAtprotoClick} title="Continue with Bluesky"> 220 + <svg width="20" height="20" viewBox="0 0 600 530" fill="currentColor"> 221 + <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" /> 222 + </svg> 223 + <span>Bluesky</span> 224 + </button> 225 + )} 226 + </div> 227 + <div className="auth-divider">or continue with email</div> 228 + </> 229 + )} 230 + 231 + <form className="form" onSubmit={onSubmit}> 232 + <input 233 + className="input" 234 + type="email" 235 + placeholder="Email" 236 + value={email} 237 + onChange={(e) => setEmail(e.target.value)} 238 + required 239 + disabled={loading} 240 + /> 241 + <input 242 + className="input" 243 + type="password" 244 + placeholder="Password" 245 + value={password} 246 + onChange={(e) => setPassword(e.target.value)} 247 + required 248 + disabled={loading} 249 + /> 250 + 251 + <div style={{ textAlign: 'right', marginTop: -8, marginBottom: 8 }}> 252 + <button 253 + type="button" 254 + onClick={handleForgotClick} 255 + style={{ 256 + background: 'none', 257 + border: 'none', 258 + color: '#e88978', 259 + fontSize: 13, 260 + cursor: 'pointer', 261 + padding: 0 262 + }} 263 + > 264 + Forgot password? 265 + </button> 266 + </div> 267 + 268 + {turnstileSiteKey && <div ref={turnstileRef} className="turnstile-container" />} 269 + 270 + {error && <div className="error">{error}</div>} 271 + 272 + <button 273 + className="btn primary" 274 + type="submit" 275 + disabled={loading || (turnstileSiteKey && !turnstileToken)} 276 + style={{ width: '100%' }} 277 + > 278 + {loading ? 'Logging in...' : 'Log in'} 279 + </button> 280 + </form> 281 + 282 + <div className="auth-footer"> 283 + Don't have an account? <a href="/signup">Sign up</a> 284 + </div> 285 + </div> 286 + 287 + {showAtpModal && ( 288 + <div 289 + className="modalOverlay" 290 + onClick={(e) => { 291 + if (e.target === e.currentTarget) { 292 + setShowAtpModal(false); 293 + } 294 + }} 295 + > 296 + <div className="modal" onClick={(e) => e.stopPropagation()}> 297 + <div className="modalHeader"> 298 + <div className="modalTitle">Sign in with ATProto</div> 299 + <button className="iconBtn" onClick={() => setShowAtpModal(false)} aria-label="Close ATProto dialog"> 300 + 301 + </button> 302 + </div> 303 + <div className="modalBody"> 304 + <div className="field"> 305 + <div className="label">Handle</div> 306 + <input 307 + className="input" 308 + placeholder="yourname.bsky.social" 309 + value={atpHandle} 310 + onChange={(e) => setAtpHandle(e.target.value)} 311 + onKeyDown={(e) => { 312 + if (e.key === 'Enter') { 313 + e.preventDefault(); 314 + startAtproto(); 315 + } 316 + }} 317 + autoFocus 318 + /> 319 + <div className="muted" style={{ marginTop: 6 }}> 320 + We'll resolve your PDS from the handle and redirect you to its OAuth. 321 + </div> 322 + </div> 323 + {atpError && <div className="error">{atpError}</div>} 324 + <div className="modalActions"> 325 + <button className="btn ghost" type="button" onClick={() => setShowAtpModal(false)}> 326 + Cancel 327 + </button> 328 + <button className="btn primary" type="button" onClick={startAtproto} disabled={!atpHandle.trim()}> 329 + Continue 330 + </button> 331 + </div> 332 + </div> 333 + </div> 334 + </div> 335 + )} 336 + 337 + {showForgotModal && ( 338 + <div 339 + className="modalOverlay" 340 + onClick={(e) => { 341 + if (e.target === e.currentTarget) { 342 + setShowForgotModal(false); 343 + } 344 + }} 345 + > 346 + <div className="modal" onClick={(e) => e.stopPropagation()}> 347 + <div className="modalHeader"> 348 + <div className="modalTitle">Reset password</div> 349 + <button className="iconBtn" onClick={() => setShowForgotModal(false)} aria-label="Close"> 350 + 351 + </button> 352 + </div> 353 + <div className="modalBody"> 354 + {forgotSuccess ? ( 355 + <> 356 + <div style={{ textAlign: 'center', padding: '16px 0' }}> 357 + <div style={{ fontSize: 48, marginBottom: 16 }}>📧</div> 358 + <p style={{ color: '#5a6d9a', marginBottom: 8 }}> 359 + If an account exists with that email, we've sent a password reset link. 360 + </p> 361 + <p style={{ color: '#94a3b8', fontSize: 13 }}>Check your inbox and spam folder.</p> 362 + </div> 363 + <div className="modalActions"> 364 + <button className="btn primary" type="button" onClick={() => setShowForgotModal(false)}> 365 + Done 366 + </button> 367 + </div> 368 + </> 369 + ) : ( 370 + <> 371 + <div className="field"> 372 + <div className="label">Email address</div> 373 + <input 374 + ref={forgotInputRef} 375 + className="input" 376 + type="email" 377 + placeholder="you@example.com" 378 + value={forgotEmail} 379 + onChange={(e) => setForgotEmail(e.target.value)} 380 + onKeyDown={(e) => { 381 + if (e.key === 'Enter') { 382 + e.preventDefault(); 383 + submitForgotPassword(); 384 + } 385 + }} 386 + disabled={forgotLoading} 387 + /> 388 + <div className="muted" style={{ marginTop: 6 }}> 389 + We'll send you a link to reset your password. 390 + </div> 391 + </div> 392 + <div className="modalActions"> 393 + <button 394 + className="btn ghost" 395 + type="button" 396 + onClick={() => setShowForgotModal(false)} 397 + disabled={forgotLoading} 398 + > 399 + Cancel 400 + </button> 401 + <button 402 + className="btn primary" 403 + type="button" 404 + onClick={submitForgotPassword} 405 + disabled={!forgotEmail.trim() || forgotLoading} 406 + > 407 + {forgotLoading ? 'Sending...' : 'Send reset link'} 408 + </button> 409 + </div> 410 + </> 411 + )} 412 + </div> 413 + </div> 414 + </div> 415 + )} 416 + </div> 417 + ); 418 + }
+541
client/src/pages/NewSite.jsx
··· 1 + import React, { useEffect, useState } from 'react'; 2 + import { Link, useNavigate, useOutletContext } from 'react-router-dom'; 3 + import { ArrowLeft, Link as LinkIcon, Github, Lock, Search, Loader2, Book, GitBranch, ArrowRight } from 'lucide-react'; 4 + 5 + export default function NewSite() { 6 + const { me, sites, refreshSites, setError } = useOutletContext(); 7 + const navigate = useNavigate(); 8 + 9 + const [tab, setTab] = useState('git'); 10 + const [name, setName] = useState(''); 11 + const [gitUrl, setGitUrl] = useState(''); 12 + const [branch, setBranch] = useState('main'); 13 + const [subdir, setSubdir] = useState(''); 14 + const [domain, setDomain] = useState(''); 15 + const [loading, setLoading] = useState(false); 16 + const [createError, setCreateError] = useState(''); 17 + 18 + const [repos, setRepos] = useState([]); 19 + const [reposLoading, setReposLoading] = useState(false); 20 + const [githubConnected, setGithubConnected] = useState(false); 21 + const [reposPage, setReposPage] = useState(1); 22 + const [hasNextPage, setHasNextPage] = useState(false); 23 + const [reposError, setReposError] = useState(''); 24 + const [searchQuery, setSearchQuery] = useState(''); 25 + const [isSearching, setIsSearching] = useState(false); 26 + 27 + const [config, setConfig] = useState({ deliveryMode: '', edgeRootDomain: '' }); 28 + 29 + const [preview, setPreview] = useState(null); 30 + const [previewStatus, setPreviewStatus] = useState('idle'); 31 + const [previewError, setPreviewError] = useState(''); 32 + 33 + const siteLimitReached = sites.length >= 10; 34 + 35 + useEffect(() => { 36 + fetch('/api/config', { credentials: 'same-origin' }) 37 + .then((r) => r.json()) 38 + .then((d) => setConfig({ deliveryMode: d?.deliveryMode || '', edgeRootDomain: d?.edgeRootDomain || '' })) 39 + .catch(() => setConfig({ deliveryMode: '', edgeRootDomain: '' })); 40 + }, []); 41 + 42 + useEffect(() => { 43 + if (tab === 'github') { 44 + fetchRepos(1, searchQuery, { background: false }); 45 + } 46 + }, [tab]); 47 + 48 + useEffect(() => { 49 + if (tab !== 'github') return; 50 + const timer = setTimeout(() => { 51 + fetchRepos(1, searchQuery, { background: true }); 52 + }, 500); 53 + return () => clearTimeout(timer); 54 + }, [searchQuery]); 55 + 56 + async function fetchRepos(page = 1, query = '', { background = false } = {}) { 57 + if (background) { 58 + setIsSearching(true); 59 + } else { 60 + setReposLoading(true); 61 + } 62 + setReposError(''); 63 + try { 64 + const qParam = query ? `&q=${encodeURIComponent(query)}` : ''; 65 + const res = await fetch(`/api/github/repos?page=${page}&per_page=100${qParam}`, { credentials: 'same-origin' }); 66 + const data = await res.json(); 67 + if (!res.ok) { 68 + setReposError(data.message || 'Failed to fetch repos'); 69 + return; 70 + } 71 + setRepos(data.repos || []); 72 + setGithubConnected(data.githubConnected); 73 + setHasNextPage(data.hasNextPage); 74 + setReposPage(page); 75 + } catch (e) { 76 + setReposError(e.message || 'Failed to fetch repos'); 77 + } finally { 78 + if (background) { 79 + setIsSearching(false); 80 + } else { 81 + setReposLoading(false); 82 + } 83 + } 84 + } 85 + 86 + const edgeOnly = String(config?.deliveryMode || '').toLowerCase() === 'edge' && Boolean(config?.edgeRootDomain); 87 + const edgeRoot = String(config?.edgeRootDomain || '').trim(); 88 + 89 + const domainLabel = edgeOnly 90 + ? String(domain || '') 91 + .trim() 92 + .toLowerCase() 93 + .replace(new RegExp(`\\.${edgeRoot.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}$`, 'i'), '') 94 + : domain; 95 + 96 + useEffect(() => { 97 + const trimmedUrl = gitUrl.trim(); 98 + const trimmedBranch = branch.trim() || 'main'; 99 + const trimmedSubdir = subdir.trim(); 100 + 101 + if (!trimmedUrl) { 102 + setPreview(null); 103 + setPreviewStatus('idle'); 104 + setPreviewError(''); 105 + return; 106 + } 107 + 108 + const controller = new AbortController(); 109 + const timer = setTimeout(async () => { 110 + setPreviewStatus('loading'); 111 + setPreviewError(''); 112 + setPreview(null); 113 + 114 + try { 115 + const res = await fetch('/api/sites/preview', { 116 + method: 'POST', 117 + headers: { 'content-type': 'application/json' }, 118 + credentials: 'same-origin', 119 + body: JSON.stringify({ gitUrl: trimmedUrl, branch: trimmedBranch, subdir: trimmedSubdir }), 120 + signal: controller.signal 121 + }); 122 + 123 + const data = await res.json().catch(() => null); 124 + if (!res.ok) { 125 + setPreviewStatus('error'); 126 + setPreviewError(data?.message || 'Unable to analyze repo.'); 127 + return; 128 + } 129 + 130 + setPreviewStatus('success'); 131 + setPreview(data); 132 + } catch (e) { 133 + if (controller.signal.aborted) return; 134 + setPreviewStatus('error'); 135 + setPreviewError(e?.message || 'Unable to analyze repo.'); 136 + } 137 + }, 2000); 138 + 139 + return () => { 140 + clearTimeout(timer); 141 + controller.abort(); 142 + }; 143 + }, [gitUrl, branch, subdir]); 144 + 145 + async function handleCreate() { 146 + setLoading(true); 147 + setCreateError(''); 148 + setError(''); 149 + 150 + try { 151 + const res = await fetch('/api/sites', { 152 + method: 'POST', 153 + headers: { 'content-type': 'application/json' }, 154 + credentials: 'same-origin', 155 + body: JSON.stringify({ name, gitUrl, branch, subdir, domain: domainLabel }) 156 + }); 157 + 158 + const data = await res.json().catch(() => null); 159 + 160 + if (!res.ok) { 161 + const errorMessages = { 162 + 'name-required': 'Please enter a project name.', 163 + 'git-url-required': 'Please enter a Git repository URL.', 164 + 'invalid-domain': 'The domain format is invalid.', 165 + 'domain-taken': 'That subdomain is already taken.', 166 + 'site-limit-reached': "You've reached the maximum of 10 projects.", 167 + 'user-required': 'You must be logged in to create a site.' 168 + }; 169 + const errCode = data?.error || 'site-create-failed'; 170 + setCreateError(errorMessages[errCode] || data?.message || 'Failed to create site.'); 171 + return; 172 + } 173 + 174 + await refreshSites(); 175 + navigate('/dashboard'); 176 + } catch (e) { 177 + setCreateError(e?.message || 'Failed to create site.'); 178 + } finally { 179 + setLoading(false); 180 + } 181 + } 182 + 183 + function selectRepo(repo) { 184 + setGitUrl(repo.cloneUrl); 185 + setBranch(repo.defaultBranch || 'main'); 186 + if (!name) { 187 + setName(repo.name); 188 + } 189 + setTab('git'); 190 + } 191 + 192 + function renderSignals() { 193 + if (!preview?.details) return null; 194 + const { 195 + staticDepsFound = [], 196 + staticFilesFound = [], 197 + dynamicDepsFound = [], 198 + dynamicFilesFound = [] 199 + } = preview.details; 200 + const staticSignals = [...staticDepsFound, ...staticFilesFound].slice(0, 4); 201 + const dynamicSignals = [...dynamicDepsFound, ...dynamicFilesFound].slice(0, 4); 202 + 203 + return ( 204 + <> 205 + {staticSignals.length > 0 && ( 206 + <div className="compatSignals"> 207 + <div className="compatSignalsLabel">Static clues</div> 208 + <div className="compatSignalPills"> 209 + {staticSignals.map((sig) => ( 210 + <span key={`static-${sig}`} className="compatPill"> 211 + {sig} 212 + </span> 213 + ))} 214 + </div> 215 + </div> 216 + )} 217 + {dynamicSignals.length > 0 && ( 218 + <div className="compatSignals warn"> 219 + <div className="compatSignalsLabel">Dynamic blockers</div> 220 + <div className="compatSignalPills"> 221 + {dynamicSignals.map((sig) => ( 222 + <span key={`dynamic-${sig}`} className="compatPill pillWarn"> 223 + {sig} 224 + </span> 225 + ))} 226 + </div> 227 + </div> 228 + )} 229 + </> 230 + ); 231 + } 232 + 233 + return ( 234 + <div className="page"> 235 + <div className="pageHeader"> 236 + <div> 237 + <div className="crumbs"> 238 + <Link to="/dashboard" className="muted" style={{ display: 'flex', alignItems: 'center', gap: 4 }}> 239 + <ArrowLeft size={14} /> 240 + Dashboard 241 + </Link> 242 + </div> 243 + <div className="h">New Website</div> 244 + <div className="muted">Deploy a static site from Git</div> 245 + </div> 246 + </div> 247 + 248 + {me && me.emailVerified === false ? ( 249 + <div className="notice"> 250 + <strong>Verify your email</strong> to deploy your websites. Check your inbox for the verification link. 251 + </div> 252 + ) : null} 253 + 254 + {siteLimitReached && ( 255 + <div className="notice"> 256 + <strong>Limit reached:</strong> You have 10 projects. Delete a project to create a new one. 257 + </div> 258 + )} 259 + 260 + {createError && <div className="errorBox">{createError}</div>} 261 + 262 + <div className="panel"> 263 + <div className="tabs"> 264 + <button className={tab === 'git' ? 'tab active' : 'tab'} onClick={() => setTab('git')}> 265 + <LinkIcon size={16} style={{ marginRight: 6 }} /> 266 + Git URL 267 + </button> 268 + <button className={tab === 'github' ? 'tab active' : 'tab'} onClick={() => setTab('github')}> 269 + <Github size={16} style={{ marginRight: 6 }} /> 270 + GitHub 271 + </button> 272 + </div> 273 + 274 + {tab === 'git' && ( 275 + <> 276 + <div className="panelTitle" style={{ marginTop: 16 }}> 277 + Repository 278 + </div> 279 + <div className="muted" style={{ marginBottom: 16 }}> 280 + Enter a Git URL to deploy 281 + </div> 282 + 283 + <div className="field"> 284 + <div className="label">Git Repository URL</div> 285 + <input 286 + className="input" 287 + placeholder="https://github.com/username/repo.git" 288 + value={gitUrl} 289 + onChange={(e) => setGitUrl(e.target.value)} 290 + disabled={loading} 291 + /> 292 + <div className="muted" style={{ marginTop: 6, fontSize: 12 }}> 293 + Public repos work automatically. For private repos, connect via GitHub OAuth in Account settings. 294 + </div> 295 + </div> 296 + 297 + {gitUrl.trim() && ( 298 + <div className="compatCard"> 299 + {previewStatus === 'idle' && <div className="compatHint">Analyzing...</div>} 300 + {previewStatus === 'loading' && <div className="compatHint">Analyzing repository…</div>} 301 + {previewStatus === 'error' && ( 302 + <div className="compatError">{previewError || 'Could not analyze this repo.'}</div> 303 + )} 304 + {previewStatus === 'success' && preview && ( 305 + <> 306 + <div className="compatBadgeRow"> 307 + <div className={`compatBadge ${preview.ok ? '' : 'warn'}`}> 308 + {preview.label || 'Static site check'} 309 + </div> 310 + <div className="compatStatus">{preview.ok ? 'Looks deployable' : 'Needs attention'}</div> 311 + </div> 312 + <div className="compatHeadline">{preview.headline}</div> 313 + {!preview.ok && preview.suggestion && ( 314 + <div className="compatSuggestion"> 315 + <strong>Suggestion:</strong> {preview.suggestion} 316 + </div> 317 + )} 318 + {renderSignals()} 319 + </> 320 + )} 321 + </div> 322 + )} 323 + 324 + <div className="panelTitle" style={{ marginTop: 24 }}> 325 + Configuration 326 + </div> 327 + <div className="muted" style={{ marginBottom: 16 }}> 328 + Project settings 329 + </div> 330 + 331 + <div className="row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}> 332 + <div className="field"> 333 + <div className="label">Project Name</div> 334 + <input 335 + className="input" 336 + placeholder="my-awesome-site" 337 + value={name} 338 + onChange={(e) => setName(e.target.value)} 339 + disabled={loading} 340 + /> 341 + </div> 342 + <div className="field"> 343 + <div className="label">Branch</div> 344 + <input 345 + className="input" 346 + placeholder="main" 347 + value={branch} 348 + onChange={(e) => setBranch(e.target.value)} 349 + disabled={loading} 350 + /> 351 + </div> 352 + </div> 353 + 354 + <div className="row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}> 355 + <div className="field"> 356 + <div className="label">Subdir (optional)</div> 357 + <input 358 + className="input" 359 + placeholder="packages/web" 360 + value={subdir} 361 + onChange={(e) => setSubdir(e.target.value)} 362 + disabled={loading} 363 + /> 364 + </div> 365 + <div className="field"> 366 + <div className="label">Subdomain (optional)</div> 367 + {edgeOnly ? ( 368 + <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> 369 + <input 370 + className="input" 371 + placeholder="your-subdomain" 372 + value={domainLabel} 373 + onChange={(e) => setDomain(e.target.value)} 374 + disabled={loading} 375 + style={{ flex: 1 }} 376 + /> 377 + <span className="muted">.{edgeRoot}</span> 378 + </div> 379 + ) : ( 380 + <input 381 + className="input" 382 + placeholder="your-subdomain" 383 + value={domain} 384 + onChange={(e) => setDomain(e.target.value)} 385 + disabled={loading} 386 + /> 387 + )} 388 + </div> 389 + </div> 390 + 391 + <div 392 + style={{ 393 + display: 'flex', 394 + justifyContent: 'flex-end', 395 + gap: 12, 396 + marginTop: 24, 397 + paddingTop: 16, 398 + borderTop: '1px solid var(--divider)' 399 + }} 400 + > 401 + <Link to="/dashboard" className="btn ghost"> 402 + Cancel 403 + </Link> 404 + <button 405 + className="btn primary" 406 + disabled={siteLimitReached || loading || !name.trim() || !gitUrl.trim()} 407 + onClick={handleCreate} 408 + > 409 + {loading ? 'Creating...' : 'Create Website'} 410 + </button> 411 + </div> 412 + </> 413 + )} 414 + 415 + {tab === 'github' && ( 416 + <> 417 + <div className="panelTitle" style={{ marginTop: 16 }}> 418 + Import from GitHub 419 + </div> 420 + <div className="muted" style={{ marginBottom: 16 }}> 421 + Select a repository to import 422 + </div> 423 + 424 + {reposLoading ? ( 425 + <div className="repoLoadingState" style={{ textAlign: 'center', padding: '48px 24px' }}> 426 + Loading repositories... 427 + </div> 428 + ) : !githubConnected ? ( 429 + <div style={{ textAlign: 'center', padding: '48px 24px' }}> 430 + <Github size={48} style={{ marginBottom: 16, opacity: 0.4 }} /> 431 + <h3 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 8 }}>Connect GitHub</h3> 432 + <div 433 + className="muted" 434 + style={{ marginBottom: 24, maxWidth: 320, marginLeft: 'auto', marginRight: 'auto' }} 435 + > 436 + Link your GitHub account to import repositories directly. 437 + </div> 438 + <a href="/auth/github" className="btn primary"> 439 + <Github size={16} style={{ marginRight: 8 }} /> 440 + Connect GitHub 441 + </a> 442 + </div> 443 + ) : ( 444 + <> 445 + {reposError && <div className="errorBox">{reposError}</div>} 446 + 447 + <div className="field" style={{ marginBottom: 16 }}> 448 + <div style={{ position: 'relative' }}> 449 + {isSearching ? ( 450 + <Loader2 451 + size={16} 452 + className="animate-spin searchIcon" 453 + style={{ 454 + position: 'absolute', 455 + left: 12, 456 + top: '50%', 457 + transform: 'translateY(-50%)' 458 + }} 459 + /> 460 + ) : ( 461 + <Search 462 + size={16} 463 + className="searchIcon" 464 + style={{ 465 + position: 'absolute', 466 + left: 12, 467 + top: '50%', 468 + transform: 'translateY(-50%)' 469 + }} 470 + /> 471 + )} 472 + <input 473 + className="input" 474 + style={{ paddingLeft: 36 }} 475 + placeholder="Search repositories..." 476 + value={searchQuery} 477 + onChange={(e) => setSearchQuery(e.target.value)} 478 + /> 479 + </div> 480 + </div> 481 + 482 + {reposLoading ? ( 483 + <div className="repoLoadingState" style={{ textAlign: 'center', padding: '48px 24px' }}> 484 + Loading repositories... 485 + </div> 486 + ) : ( 487 + <> 488 + <div className="repoGrid"> 489 + {repos.map((repo) => ( 490 + <button key={repo.id} className="repoItem" onClick={() => selectRepo(repo)}> 491 + <div className="repoItemIcon"> 492 + <Book size={20} strokeWidth={1.5} /> 493 + </div> 494 + 495 + <div className="repoItemContent"> 496 + <div className="repoItemName">{repo.name}</div> 497 + <div className="repoItemDesc">{repo.description || 'No description provided'}</div> 498 + </div> 499 + 500 + <div className="repoItemMetaColumn"> 501 + <div className="repoItemMetaRow"> 502 + {repo.private && ( 503 + <span className="repoItemPrivate"> 504 + <Lock size={10} /> 505 + Private 506 + </span> 507 + )} 508 + {repo.language && ( 509 + <span> 510 + <span className="repoLangDot" /> 511 + {repo.language} 512 + </span> 513 + )} 514 + <span> 515 + <GitBranch size={12} /> 516 + {repo.defaultBranch} 517 + </span> 518 + </div> 519 + <div className="repoArrow"> 520 + <ArrowRight size={16} /> 521 + </div> 522 + </div> 523 + </button> 524 + ))} 525 + </div> 526 + 527 + {repos.length === 0 && ( 528 + <div className="repoEmptyState" style={{ textAlign: 'center', padding: '32px' }}> 529 + No repositories found 530 + </div> 531 + )} 532 + </> 533 + )} 534 + </> 535 + )} 536 + </> 537 + )} 538 + </div> 539 + </div> 540 + ); 541 + }
+95
client/src/pages/Privacy.jsx
··· 1 + import React from 'react'; 2 + import { Link } from 'react-router-dom'; 3 + import ThemeToggle from '../components/ThemeToggle.jsx'; 4 + 5 + export default function Privacy() { 6 + return ( 7 + <div className="legal-page"> 8 + <div className="legal-theme-toggle"> 9 + <ThemeToggle /> 10 + </div> 11 + <div className="legal-card"> 12 + <div className="legal-header"> 13 + <h1>Privacy Policy</h1> 14 + <span className="legal-date">Last updated: December 19, 2025</span> 15 + </div> 16 + 17 + <div className="legal-content"> 18 + <p> 19 + This Privacy Policy explains what information boop.cat (the "Service") collects, how we use it, and how it 20 + may be processed by third-party infrastructure providers. 21 + </p> 22 + 23 + <h2>1. Information we collect</h2> 24 + <p>The Service may collect account and usage information such as:</p> 25 + <ul> 26 + <li>Email address and username (for authentication and account management).</li> 27 + <li>OAuth identifiers (when you sign in with a third‑party provider).</li> 28 + <li>IP addresses are hashed and stored for security and abuse prevention.</li> 29 + <li>Basic request metadata (e.g., User-Agent) for rate limiting.</li> 30 + <li>Repository URLs and deployment configuration you provide to create deployments.</li> 31 + <li>Deployment logs generated during build and upload (to help you debug deployments).</li> 32 + <li>Files you deploy (static site assets) and related identifiers needed to serve your site.</li> 33 + </ul> 34 + 35 + <h2>2. How we use information</h2> 36 + <p> 37 + We use information to operate the Service, authenticate users, secure the platform, prevent abuse, and 38 + support deployments. 39 + </p> 40 + 41 + <h2>3. Where your deployed sites are stored and served</h2> 42 + <p> 43 + When you deploy a site, your static build output is uploaded to Backblaze B2 object storage and served 44 + globally via Cloudflare. Cloudflare also stores limited routing metadata in Cloudflare Workers KV to map 45 + your subdomain to your current deployment. 46 + </p> 47 + 48 + <h2>4. Cookies and authentication</h2> 49 + <p> 50 + The Service uses an HTTP-only session cookie to keep you signed in. Session data may be stored on our 51 + servers for account authentication and security. 52 + </p> 53 + 54 + <h2>5. Sharing</h2> 55 + <p> 56 + We do not sell your personal information. We may share information with service providers as needed to 57 + operate the Service (for example, email delivery), or when required by law. 58 + </p> 59 + 60 + <p> 61 + Our infrastructure providers (acting as service providers/processors) may process data necessary to deliver 62 + the Service, including: 63 + </p> 64 + <ul> 65 + <li>Cloudflare (CDN/Workers/KV) for request handling, caching, routing, and delivery of deployed sites.</li> 66 + <li>Backblaze B2 for storage of deployed static site files.</li> 67 + <li> 68 + Hetzner for hosting the main Service application servers (dashboard/API), including build and deployment 69 + orchestration. 70 + </li> 71 + </ul> 72 + 73 + <h2>6. Data retention</h2> 74 + <p> 75 + We retain account and deployment data for as long as necessary to provide the Service and for security and 76 + operational purposes. When you delete a project, we attempt to remove its associated deployments, deployed 77 + files, and routing metadata. 78 + </p> 79 + 80 + <h2>7. Security</h2> 81 + <p>We take reasonable measures to protect data, but no method of transmission or storage is 100% secure.</p> 82 + 83 + <h2>8. Contact</h2> 84 + <p> 85 + If you have questions about this policy, contact <a href="mailto:hello@boop.cat">hello@boop.cat</a>. 86 + </p> 87 + </div> 88 + 89 + <div className="legal-footer"> 90 + <Link to="/">← Back to home</Link> 91 + </div> 92 + </div> 93 + </div> 94 + ); 95 + }
+143
client/src/pages/ResetPassword.jsx
··· 1 + import React, { useState, useEffect } from 'react'; 2 + import { useSearchParams, useNavigate } from 'react-router-dom'; 3 + import ThemeToggle from '../components/ThemeToggle.jsx'; 4 + 5 + const ERROR_MESSAGES = { 6 + 'missing-token': 'Reset link is invalid.', 7 + 'invalid-token': 'Reset link is invalid or has already been used.', 8 + 'already-used': 'This reset link has already been used.', 9 + expired: 'This reset link has expired. Please request a new one.', 10 + 'password-required': 'Please enter a new password.', 11 + 'password-too-short': 'Password must be at least 8 characters.', 12 + 'password-too-long': 'Password is too long.', 13 + 'user-not-found': 'User not found.', 14 + 'reset-failed': 'Failed to reset password. Please try again.' 15 + }; 16 + 17 + export default function ResetPassword() { 18 + const [searchParams] = useSearchParams(); 19 + const navigate = useNavigate(); 20 + const token = searchParams.get('token'); 21 + 22 + const [password, setPassword] = useState(''); 23 + const [confirmPassword, setConfirmPassword] = useState(''); 24 + const [loading, setLoading] = useState(false); 25 + const [error, setError] = useState(''); 26 + const [success, setSuccess] = useState(false); 27 + 28 + useEffect(() => { 29 + if (!token) { 30 + setError('missing-token'); 31 + } 32 + }, [token]); 33 + 34 + async function handleSubmit(e) { 35 + e.preventDefault(); 36 + setError(''); 37 + 38 + if (password !== confirmPassword) { 39 + setError('Passwords do not match.'); 40 + return; 41 + } 42 + 43 + if (password.length < 8) { 44 + setError('password-too-short'); 45 + return; 46 + } 47 + 48 + setLoading(true); 49 + try { 50 + const res = await fetch('/api/auth/reset-password', { 51 + method: 'POST', 52 + headers: { 'content-type': 'application/json' }, 53 + body: JSON.stringify({ token, newPassword: password }) 54 + }); 55 + 56 + const data = await res.json().catch(() => null); 57 + 58 + if (!res.ok) { 59 + const errCode = data?.error || 'reset-failed'; 60 + setError(ERROR_MESSAGES[errCode] || errCode); 61 + return; 62 + } 63 + 64 + setSuccess(true); 65 + } finally { 66 + setLoading(false); 67 + } 68 + } 69 + 70 + if (success) { 71 + return ( 72 + <div className="auth-page"> 73 + <div className="auth-theme-toggle"> 74 + <ThemeToggle /> 75 + </div> 76 + <div className="auth-card"> 77 + <div style={{ textAlign: 'center' }}> 78 + <div style={{ fontSize: 64, marginBottom: 16 }}>✅</div> 79 + <h1>Password reset!</h1> 80 + <p className="subtitle" style={{ marginBottom: 24 }}> 81 + Your password has been successfully changed. 82 + </p> 83 + <button className="btn primary" onClick={() => navigate('/login')} style={{ width: '100%' }}> 84 + Log in with new password 85 + </button> 86 + </div> 87 + </div> 88 + </div> 89 + ); 90 + } 91 + 92 + const displayError = ERROR_MESSAGES[error] || error; 93 + 94 + return ( 95 + <div className="auth-page"> 96 + <div className="auth-theme-toggle"> 97 + <ThemeToggle /> 98 + </div> 99 + <div className="auth-card"> 100 + <h1>Set new password</h1> 101 + <p className="subtitle">Enter your new password below.</p> 102 + 103 + <form className="form" onSubmit={handleSubmit}> 104 + <input 105 + className="input" 106 + type="password" 107 + placeholder="New password" 108 + value={password} 109 + onChange={(e) => setPassword(e.target.value)} 110 + required 111 + minLength={8} 112 + disabled={loading || !token} 113 + /> 114 + <input 115 + className="input" 116 + type="password" 117 + placeholder="Confirm new password" 118 + value={confirmPassword} 119 + onChange={(e) => setConfirmPassword(e.target.value)} 120 + required 121 + minLength={8} 122 + disabled={loading || !token} 123 + /> 124 + 125 + {displayError && <div className="error">{displayError}</div>} 126 + 127 + <button 128 + className="btn primary" 129 + type="submit" 130 + disabled={loading || !token || !password || !confirmPassword} 131 + style={{ width: '100%' }} 132 + > 133 + {loading ? 'Resetting...' : 'Reset password'} 134 + </button> 135 + </form> 136 + 137 + <div className="auth-footer"> 138 + <a href="/login">Back to login</a> 139 + </div> 140 + </div> 141 + </div> 142 + ); 143 + }
+294
client/src/pages/Signup.jsx
··· 1 + import React, { useState, useRef, useEffect, useCallback } from 'react'; 2 + import ThemeToggle from '../components/ThemeToggle.jsx'; 3 + 4 + export default function Signup() { 5 + const [username, setUsername] = useState(''); 6 + const [email, setEmail] = useState(''); 7 + const [password, setPassword] = useState(''); 8 + const [error, setError] = useState(''); 9 + const [loading, setLoading] = useState(false); 10 + const [providers, setProviders] = useState({ github: false, google: false, atproto: false }); 11 + const [turnstileToken, setTurnstileToken] = useState(''); 12 + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); 13 + const [showAtpModal, setShowAtpModal] = useState(false); 14 + const [atpHandle, setAtpHandle] = useState(''); 15 + const [atpError, setAtpError] = useState(''); 16 + const turnstileRef = useRef(null); 17 + 18 + useEffect(() => { 19 + fetch('/api/auth/me', { credentials: 'same-origin' }) 20 + .then((r) => r.json()) 21 + .then((d) => { 22 + if (d?.authenticated) window.location.href = '/dashboard'; 23 + }) 24 + .catch(() => {}); 25 + }, []); 26 + 27 + useEffect(() => { 28 + fetch('/api/auth/providers') 29 + .then((r) => r.json()) 30 + .then((data) => { 31 + setProviders(data); 32 + if (data.turnstileSiteKey) { 33 + setTurnstileSiteKey(data.turnstileSiteKey); 34 + } 35 + }) 36 + .catch(() => {}); 37 + }, []); 38 + 39 + const handleAtprotoClick = useCallback((e) => { 40 + e.preventDefault(); 41 + e.stopPropagation(); 42 + setShowAtpModal(true); 43 + setAtpError(''); 44 + }, []); 45 + 46 + function startAtproto() { 47 + if (!atpHandle.trim()) { 48 + setAtpError('Enter your handle, e.g., yourname.bsky.social'); 49 + return; 50 + } 51 + setAtpError(''); 52 + const url = `/auth/atproto?handle=${encodeURIComponent(atpHandle.trim())}`; 53 + window.location.href = url; 54 + } 55 + 56 + useEffect(() => { 57 + if (!turnstileSiteKey) return; 58 + 59 + if (!document.getElementById('cf-turnstile-script')) { 60 + const script = document.createElement('script'); 61 + script.id = 'cf-turnstile-script'; 62 + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; 63 + script.async = true; 64 + document.head.appendChild(script); 65 + } 66 + }, [turnstileSiteKey]); 67 + 68 + useEffect(() => { 69 + if (!turnstileSiteKey || !turnstileRef.current) return; 70 + 71 + const renderWidget = () => { 72 + if (window.turnstile && turnstileRef.current) { 73 + turnstileRef.current.innerHTML = ''; 74 + window.turnstile.render(turnstileRef.current, { 75 + sitekey: turnstileSiteKey, 76 + callback: (token) => setTurnstileToken(token), 77 + 'expired-callback': () => setTurnstileToken(''), 78 + 'error-callback': () => setTurnstileToken('') 79 + }); 80 + } 81 + }; 82 + 83 + if (window.turnstile) { 84 + renderWidget(); 85 + } else { 86 + const script = document.getElementById('cf-turnstile-script'); 87 + script?.addEventListener('load', renderWidget); 88 + } 89 + }, [turnstileSiteKey]); 90 + 91 + async function onSubmit(e) { 92 + e.preventDefault(); 93 + setError(''); 94 + setLoading(true); 95 + 96 + try { 97 + const res = await fetch('/api/auth/register', { 98 + method: 'POST', 99 + headers: { 'content-type': 'application/json' }, 100 + body: JSON.stringify({ username, email, password, turnstileToken }) 101 + }); 102 + 103 + if (!res.ok) { 104 + const data = await res.json().catch(() => null); 105 + 106 + const errorMessages = { 107 + 'email-required': 'Please enter your email address.', 108 + 'invalid-email-format': 'Please enter a valid email address.', 109 + 'email-too-long': 'Email address is too long.', 110 + 'email-already-registered': 'This email is already registered. Try logging in instead.', 111 + 'password-required': 'Please enter a password.', 112 + 'password-too-short': 'Password must be at least 8 characters.', 113 + 'password-too-long': 'Password is too long.', 114 + 'username-required': 'Please enter a username.', 115 + 'username-too-short': 'Username must be at least 3 characters.', 116 + 'username-too-long': 'Username must be 20 characters or less.', 117 + 'username-invalid-characters': 'Username can only contain letters, numbers, and underscores.', 118 + 'username-already-taken': 'This username is already taken. Please choose another.', 119 + 'captcha-failed': 'Captcha verification failed. Please try again.' 120 + }; 121 + const errCode = data?.error || 'registration-failed'; 122 + setError(errorMessages[errCode] || data?.message || 'Signup failed'); 123 + 124 + if (window.turnstile && turnstileRef.current) { 125 + window.turnstile.reset(turnstileRef.current); 126 + } 127 + setTurnstileToken(''); 128 + return; 129 + } 130 + window.location.href = '/dashboard'; 131 + } finally { 132 + setLoading(false); 133 + } 134 + } 135 + 136 + return ( 137 + <div className="auth-page"> 138 + <div className="auth-theme-toggle"> 139 + <ThemeToggle /> 140 + </div> 141 + <div className="auth-card"> 142 + <h1>Create account</h1> 143 + <p className="subtitle">Start deploying in seconds</p> 144 + 145 + {(providers.github || providers.google || providers.atproto) && ( 146 + <> 147 + <div className="oauth-providers"> 148 + {providers.github && ( 149 + <a className="oauth-btn" href="/auth/github" title="Sign up with GitHub"> 150 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 151 + <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> 152 + </svg> 153 + <span>GitHub</span> 154 + </a> 155 + )} 156 + {providers.google && ( 157 + <a className="oauth-btn" href="/auth/google" title="Sign up with Google"> 158 + <svg width="20" height="20" viewBox="0 0 24 24"> 159 + <path 160 + fill="#4285F4" 161 + d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" 162 + /> 163 + <path 164 + fill="#34A853" 165 + d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" 166 + /> 167 + <path 168 + fill="#FBBC05" 169 + d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" 170 + /> 171 + <path 172 + fill="#EA4335" 173 + d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" 174 + /> 175 + </svg> 176 + <span>Google</span> 177 + </a> 178 + )} 179 + {providers.atproto && ( 180 + <button type="button" className="oauth-btn" onClick={handleAtprotoClick} title="Sign up with Bluesky"> 181 + <svg width="20" height="20" viewBox="0 0 600 530" fill="currentColor"> 182 + <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" /> 183 + </svg> 184 + <span>Bluesky</span> 185 + </button> 186 + )} 187 + </div> 188 + <div className="auth-divider">or continue with email</div> 189 + </> 190 + )} 191 + 192 + <form className="form" onSubmit={onSubmit}> 193 + <input 194 + className="input" 195 + type="text" 196 + placeholder="Username" 197 + value={username} 198 + onChange={(e) => setUsername(e.target.value)} 199 + pattern="[a-zA-Z0-9_]{3,20}" 200 + title="3-20 characters, letters, numbers, and underscores only" 201 + required 202 + disabled={loading} 203 + /> 204 + <input 205 + className="input" 206 + type="email" 207 + placeholder="Email" 208 + value={email} 209 + onChange={(e) => setEmail(e.target.value)} 210 + required 211 + disabled={loading} 212 + /> 213 + <input 214 + className="input" 215 + type="password" 216 + placeholder="Password (min 8 chars)" 217 + minLength={8} 218 + value={password} 219 + onChange={(e) => setPassword(e.target.value)} 220 + required 221 + disabled={loading} 222 + /> 223 + 224 + {turnstileSiteKey && <div ref={turnstileRef} className="turnstile-container" />} 225 + 226 + {error && <div className="error">{error}</div>} 227 + 228 + <button 229 + className="btn primary" 230 + type="submit" 231 + disabled={loading || (turnstileSiteKey && !turnstileToken)} 232 + style={{ width: '100%' }} 233 + > 234 + {loading ? 'Creating account...' : 'Create account'} 235 + </button> 236 + </form> 237 + 238 + <div className="auth-footer"> 239 + Already have an account? <a href="/login">Log in</a> 240 + </div> 241 + </div> 242 + 243 + {showAtpModal && ( 244 + <div 245 + className="modalOverlay" 246 + onClick={(e) => { 247 + if (e.target === e.currentTarget) { 248 + setShowAtpModal(false); 249 + } 250 + }} 251 + > 252 + <div className="modal" onClick={(e) => e.stopPropagation()}> 253 + <div className="modalHeader"> 254 + <div className="modalTitle">Sign up with ATProto</div> 255 + <button className="iconBtn" onClick={() => setShowAtpModal(false)} aria-label="Close ATProto dialog"> 256 + 257 + </button> 258 + </div> 259 + <div className="modalBody"> 260 + <div className="field"> 261 + <div className="label">Handle</div> 262 + <input 263 + className="input" 264 + placeholder="yourname.bsky.social" 265 + value={atpHandle} 266 + onChange={(e) => setAtpHandle(e.target.value)} 267 + onKeyDown={(e) => { 268 + if (e.key === 'Enter') { 269 + e.preventDefault(); 270 + startAtproto(); 271 + } 272 + }} 273 + autoFocus 274 + /> 275 + <div className="muted" style={{ marginTop: 6 }}> 276 + We'll resolve your PDS from the handle and redirect you to its OAuth. 277 + </div> 278 + </div> 279 + {atpError && <div className="error">{atpError}</div>} 280 + <div className="modalActions"> 281 + <button className="btn ghost" type="button" onClick={() => setShowAtpModal(false)}> 282 + Cancel 283 + </button> 284 + <button className="btn primary" type="button" onClick={startAtproto} disabled={!atpHandle.trim()}> 285 + Continue 286 + </button> 287 + </div> 288 + </div> 289 + </div> 290 + </div> 291 + )} 292 + </div> 293 + ); 294 + }
+72
client/src/pages/Tos.jsx
··· 1 + import React from 'react'; 2 + import { Link } from 'react-router-dom'; 3 + import ThemeToggle from '../components/ThemeToggle.jsx'; 4 + 5 + export default function Tos() { 6 + return ( 7 + <div className="legal-page"> 8 + <div className="legal-theme-toggle"> 9 + <ThemeToggle /> 10 + </div> 11 + <div className="legal-card"> 12 + <div className="legal-header"> 13 + <h1>Terms of Service</h1> 14 + <span className="legal-date">Last updated: December 18, 2025</span> 15 + </div> 16 + 17 + <div className="legal-content"> 18 + <p> 19 + These Terms of Service ("Terms") govern your use of boop.cat (the "Service"). By using the Service, you 20 + agree to these Terms. 21 + </p> 22 + 23 + <h2>1. Accounts</h2> 24 + <p> 25 + You are responsible for maintaining the security of your account and for all activity that occurs under your 26 + account. 27 + </p> 28 + 29 + <h2>2. Acceptable use</h2> 30 + <p> 31 + You agree not to use the Service to host, deploy, distribute, or link to content that is illegal, malicious, 32 + infringes intellectual property, violates privacy, or is intended to harm or disrupt systems (including 33 + malware, phishing, or abuse of third‑party services). 34 + </p> 35 + 36 + <h2>3. Deployments and content</h2> 37 + <p> 38 + You retain responsibility for the repositories you connect and the content you deploy. We may suspend or 39 + remove deployments that violate these Terms or applicable law. 40 + </p> 41 + 42 + <h2>4. Rate limits and availability</h2> 43 + <p> 44 + The Service may enforce rate limits, quotas, and other restrictions. The Service is provided on an "as is" 45 + and "as available" basis and may change or be discontinued. 46 + </p> 47 + 48 + <h2>5. Termination</h2> 49 + <p> 50 + We may suspend or terminate access to the Service at any time for violations of these Terms, suspected 51 + abuse, or security reasons. 52 + </p> 53 + 54 + <h2>6. Disclaimer</h2> 55 + <p> 56 + To the maximum extent permitted by law, we disclaim all warranties and will not be liable for any indirect, 57 + incidental, or consequential damages arising from your use of the Service. 58 + </p> 59 + 60 + <h2>7. Contact</h2> 61 + <p> 62 + Questions about these Terms can be directed to <a href="mailto:hello@boop.cat">hello@boop.cat</a>. 63 + </p> 64 + </div> 65 + 66 + <div className="legal-footer"> 67 + <Link to="/">← Back to home</Link> 68 + </div> 69 + </div> 70 + </div> 71 + ); 72 + }
+4224
client/src/styles.css
··· 1 + .compatCard { 2 + margin-top: 12px; 3 + padding: 16px; 4 + border-radius: 18px; 5 + background: rgba(255, 255, 255, 0.82); 6 + border: 1px solid rgba(232, 137, 120, 0.18); 7 + box-shadow: 0 8px 18px rgba(45, 20, 15, 0.12); 8 + } 9 + 10 + [data-theme='dark'] .compatCard { 11 + background: rgba(12, 18, 32, 0.88); 12 + border-color: rgba(255, 255, 255, 0.08); 13 + box-shadow: 0 10px 22px rgba(0, 0, 0, 0.45); 14 + } 15 + 16 + .compatHint { 17 + font-size: 13px; 18 + color: var(--muted); 19 + } 20 + 21 + [data-theme='dark'] .compatHint { 22 + color: rgba(226, 232, 240, 0.7); 23 + } 24 + 25 + .compatBadgeRow { 26 + display: flex; 27 + align-items: center; 28 + gap: 12px; 29 + margin-bottom: 6px; 30 + } 31 + 32 + .compatBadge { 33 + padding: 4px 12px; 34 + border-radius: 999px; 35 + font-size: 12px; 36 + font-weight: 600; 37 + background: rgba(34, 197, 94, 0.16); 38 + color: #166534; 39 + } 40 + 41 + .compatBadge.warn { 42 + background: rgba(248, 113, 113, 0.2); 43 + color: #b91c1c; 44 + } 45 + 46 + [data-theme='dark'] .compatBadge { 47 + background: rgba(34, 197, 94, 0.22); 48 + color: #bbf7d0; 49 + } 50 + 51 + [data-theme='dark'] .compatBadge.warn { 52 + background: rgba(248, 113, 113, 0.25); 53 + color: #fecaca; 54 + } 55 + 56 + .compatStatus { 57 + font-size: 13px; 58 + color: var(--muted); 59 + } 60 + 61 + [data-theme='dark'] .compatStatus { 62 + color: rgba(226, 232, 240, 0.65); 63 + } 64 + 65 + .compatHeadline { 66 + font-size: 14px; 67 + font-weight: 600; 68 + margin-bottom: 8px; 69 + color: var(--card-text); 70 + } 71 + 72 + [data-theme='dark'] .compatHeadline { 73 + color: var(--card-text-light); 74 + } 75 + 76 + .compatSuggestion { 77 + font-size: 13px; 78 + padding: 8px 10px; 79 + border-radius: 10px; 80 + background: rgba(248, 113, 113, 0.12); 81 + color: #b91c1c; 82 + margin-bottom: 8px; 83 + } 84 + 85 + [data-theme='dark'] .compatSuggestion { 86 + background: rgba(248, 113, 113, 0.25); 87 + color: #fecaca; 88 + } 89 + 90 + .compatError { 91 + font-size: 13px; 92 + color: #b91c1c; 93 + } 94 + 95 + [data-theme='dark'] .compatError { 96 + color: #fecaca; 97 + } 98 + 99 + .compatSignals { 100 + margin-top: 10px; 101 + } 102 + 103 + .compatSignals.warn .compatSignalsLabel { 104 + color: #b91c1c; 105 + } 106 + 107 + .compatSignalsLabel { 108 + font-size: 12px; 109 + font-weight: 600; 110 + margin-bottom: 6px; 111 + color: var(--muted); 112 + text-transform: uppercase; 113 + letter-spacing: 0.05em; 114 + } 115 + 116 + [data-theme='dark'] .compatSignalsLabel { 117 + color: rgba(226, 232, 240, 0.7); 118 + } 119 + 120 + .compatSignals.warn .compatSignalsLabel { 121 + color: #b91c1c; 122 + } 123 + 124 + [data-theme='dark'] .compatSignals.warn .compatSignalsLabel { 125 + color: #feb2b2; 126 + } 127 + 128 + .compatSignalPills { 129 + display: flex; 130 + gap: 6px; 131 + flex-wrap: wrap; 132 + } 133 + 134 + .compatPill { 135 + padding: 4px 8px; 136 + border-radius: 999px; 137 + background: rgba(232, 137, 120, 0.15); 138 + color: #c45a47; 139 + font-size: 12px; 140 + } 141 + 142 + .compatPill.pillWarn { 143 + background: rgba(248, 113, 113, 0.18); 144 + color: #991b1b; 145 + } 146 + 147 + [data-theme='dark'] .compatPill { 148 + background: rgba(244, 162, 149, 0.3); 149 + color: #f4a295; 150 + } 151 + 152 + [data-theme='dark'] .compatPill.pillWarn { 153 + background: rgba(248, 113, 113, 0.3); 154 + color: #fecdd3; 155 + } 156 + 157 + :root { 158 + --bg-gradient-1: #fff5f2; 159 + --bg-gradient-2: #ffeee8; 160 + --bg-gradient-3: #ffe4dc; 161 + --card-text: #2d1f1c; 162 + --card-text-light: #7a5a52; 163 + --card-bg: rgba(255, 255, 255, 0.75); 164 + --card-bg-solid: rgba(255, 255, 255, 0.85); 165 + --card-border: rgba(255, 255, 255, 0.8); 166 + --glass-bg: color-mix(in srgb, #f8ded6 60%, white 40%); 167 + --glass-border: rgba(232, 137, 120, 0.15); 168 + --input-bg: rgba(255, 255, 255, 0.8); 169 + --input-border: rgba(232, 137, 120, 0.15); 170 + --sidebar-bg: color-mix(in srgb, #f8ded6 60%, white 40%); 171 + --sidebar-border: rgba(232, 137, 120, 0.15); 172 + --sidebar-hover: rgba(255, 255, 255, 0.6); 173 + --sidebar-active-bg: linear-gradient(135deg, #f4a295 0%, #e88978 100%); 174 + --dropdown-bg: #fff; 175 + --dropdown-border: #f0e2de; 176 + --dropdown-hover: #fdf5f3; 177 + --accent: #e88978; 178 + --accent-gradient: linear-gradient(135deg, #f4a295 0%, #e88978 100%); 179 + --radius-card: 24px; 180 + --radius-btn: 14px; 181 + --blue: #1d9bf0; 182 + --orange: #ff7a00; 183 + --fg: #2d1f1c; 184 + --muted: rgba(45, 31, 28, 0.55); 185 + --border: rgba(45, 31, 28, 0.08); 186 + --shadow: 0 4px 24px rgba(45, 20, 15, 0.06); 187 + --shadow-hover: 0 12px 40px rgba(45, 20, 15, 0.1); 188 + --divider: rgba(0, 0, 0, 0.06); 189 + --code-bg: #fdfaf9; 190 + --modal-bg: color-mix(in srgb, #fce8e2 70%, white 30%); 191 + --modal-border: rgba(255, 255, 255, 0.6); 192 + --modal-inset: rgba(255, 255, 255, 0.6); 193 + --modal-header-bg: rgba(255, 255, 255, 0.5); 194 + --modal-header-border: rgba(232, 137, 120, 0.12); 195 + --icon-btn-bg: rgba(255, 255, 255, 0.6); 196 + --notice-bg: #fef3c7; 197 + --notice-border: #fcd34d; 198 + --notice-text: #92400e; 199 + --error-bg: rgba(254, 226, 226, 0.6); 200 + --error-border: rgba(252, 165, 165, 0.5); 201 + --error-text: #b91c1c; 202 + } 203 + 204 + [data-theme='dark'] .deployRow { 205 + background: color-mix(in srgb, var(--card-bg) 80%, rgba(255, 255, 255, 0.04) 20%); 206 + border-color: rgba(255, 255, 255, 0.08); 207 + } 208 + 209 + .deployRowMain { 210 + display: flex; 211 + flex-direction: column; 212 + gap: 6px; 213 + flex: 1; 214 + } 215 + 216 + .deployRowTop { 217 + display: flex; 218 + align-items: center; 219 + gap: 10px; 220 + flex-wrap: wrap; 221 + } 222 + 223 + .statusPill { 224 + padding: 4px 10px; 225 + border-radius: 999px; 226 + font-size: 12px; 227 + font-weight: 600; 228 + letter-spacing: 0.02em; 229 + background: rgba(232, 137, 120, 0.15); 230 + color: #c45a47; 231 + } 232 + 233 + .status-building { 234 + background: rgba(245, 158, 11, 0.12); 235 + color: #b45309; 236 + } 237 + 238 + .status-running { 239 + background: rgba(34, 197, 94, 0.14); 240 + color: #15803d; 241 + } 242 + 243 + .status-failed { 244 + background: rgba(239, 68, 68, 0.14); 245 + color: #b91c1c; 246 + } 247 + 248 + .status-stopped { 249 + background: rgba(148, 163, 184, 0.16); 250 + color: #475569; 251 + } 252 + 253 + [data-theme='dark'] .statusPill { 254 + background: rgba(244, 162, 149, 0.18); 255 + color: #f4a295; 256 + } 257 + 258 + [data-theme='dark'] .status-building { 259 + background: rgba(245, 158, 11, 0.18); 260 + color: #fcd34d; 261 + } 262 + 263 + [data-theme='dark'] .status-running { 264 + background: rgba(34, 197, 94, 0.18); 265 + color: #86efac; 266 + } 267 + 268 + [data-theme='dark'] .status-failed { 269 + background: rgba(239, 68, 68, 0.18); 270 + color: #fecdd3; 271 + } 272 + 273 + [data-theme='dark'] .status-stopped { 274 + background: rgba(148, 163, 184, 0.18); 275 + color: #cbd5e1; 276 + } 277 + 278 + .deployTime { 279 + font-size: 12px; 280 + } 281 + 282 + .deployLinks { 283 + display: flex; 284 + gap: 8px; 285 + flex-wrap: wrap; 286 + } 287 + 288 + .linkChip { 289 + display: inline-flex; 290 + align-items: center; 291 + gap: 4px; 292 + padding: 6px 10px; 293 + background: rgba(0, 0, 0, 0.03); 294 + border-radius: 10px; 295 + font-size: 13px; 296 + } 297 + 298 + [data-theme='dark'] .linkChip { 299 + background: rgba(255, 255, 255, 0.05); 300 + } 301 + 302 + .deployCommit { 303 + display: flex; 304 + align-items: center; 305 + gap: 8px; 306 + flex-wrap: wrap; 307 + font-size: 13px; 308 + color: var(--muted); 309 + } 310 + 311 + .commitAvatar { 312 + width: 28px; 313 + height: 28px; 314 + border-radius: 50%; 315 + object-fit: cover; 316 + border: 1px solid var(--border); 317 + } 318 + 319 + .commitBadge { 320 + font-family: 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace; 321 + background: rgba(0, 0, 0, 0.05); 322 + padding: 4px 8px; 323 + border-radius: 8px; 324 + letter-spacing: 0.02em; 325 + color: var(--card-text); 326 + text-decoration: none; 327 + } 328 + 329 + [data-theme='dark'] .commitBadge { 330 + background: rgba(255, 255, 255, 0.08); 331 + color: #e5edff; 332 + } 333 + 334 + .commitMessage { 335 + max-width: 320px; 336 + white-space: nowrap; 337 + overflow: hidden; 338 + text-overflow: ellipsis; 339 + color: var(--card-text); 340 + } 341 + 342 + [data-theme='dark'] .commitMessage { 343 + color: var(--card-text-light); 344 + } 345 + 346 + .commitAuthor { 347 + color: var(--muted); 348 + } 349 + 350 + [data-theme='dark'] .commitAuthor { 351 + color: #7a8ba8; 352 + } 353 + 354 + .commitAuthorInfo { 355 + display: flex; 356 + align-items: center; 357 + gap: 6px; 358 + } 359 + 360 + .commitAuthorName { 361 + font-weight: 600; 362 + font-size: 13px; 363 + color: #1e293b; 364 + } 365 + 366 + [data-theme='dark'] .commitAuthorName { 367 + color: #f1f5f9; 368 + } 369 + 370 + .actions .btn.ghost { 371 + border-color: var(--border); 372 + } 373 + 374 + [data-theme='dark'] { 375 + --bg-gradient-1: #1a0f0d; 376 + --bg-gradient-2: #1f1412; 377 + --bg-gradient-3: #251916; 378 + --card-text: #f5ebe8; 379 + --card-text-light: #d6b4a9; 380 + --card-bg: rgba(32, 18, 14, 0.82); 381 + --card-bg-solid: rgba(32, 18, 14, 0.94); 382 + --card-border: rgba(255, 255, 255, 0.14); 383 + --glass-bg: color-mix(in srgb, #26140d 70%, rgba(32, 18, 14, 0.9) 30%); 384 + --glass-border: rgba(255, 255, 255, 0.16); 385 + --input-bg: rgba(32, 18, 14, 0.78); 386 + --input-border: rgba(255, 255, 255, 0.14); 387 + --sidebar-bg: rgba(26, 14, 10, 0.95); 388 + --sidebar-border: rgba(255, 255, 255, 0.12); 389 + --sidebar-hover: rgba(255, 255, 255, 0.08); 390 + --sidebar-active-bg: linear-gradient(135deg, #f4a295 0%, #e88978 100%); 391 + --dropdown-bg: #1f1210; 392 + --dropdown-border: rgba(255, 255, 255, 0.14); 393 + --dropdown-hover: rgba(255, 255, 255, 0.08); 394 + --accent: #f4a295; 395 + --accent-gradient: linear-gradient(135deg, #f4a295 0%, #e88978 100%); 396 + --shadow: 0 6px 28px rgba(0, 0, 0, 0.35); 397 + --shadow-hover: 0 14px 46px rgba(0, 0, 0, 0.45); 398 + --divider: rgba(255, 255, 255, 0.12); 399 + --code-bg: rgba(32, 18, 14, 0.65); 400 + --modal-bg: rgba(30, 17, 14, 0.96); 401 + --modal-border: rgba(255, 255, 255, 0.14); 402 + --modal-inset: rgba(255, 255, 255, 0.08); 403 + --modal-header-bg: rgba(36, 20, 16, 0.7); 404 + --modal-header-border: rgba(255, 255, 255, 0.12); 405 + --icon-btn-bg: rgba(255, 255, 255, 0.14); 406 + --notice-bg: rgba(251, 191, 36, 0.25); 407 + --notice-border: rgba(251, 191, 36, 0.45); 408 + --notice-text: #f7e3a3; 409 + --error-bg: rgba(239, 68, 68, 0.28); 410 + --error-border: rgba(239, 68, 68, 0.45); 411 + --error-text: #fca5a5; 412 + } 413 + 414 + .repoGrid { 415 + display: flex; 416 + flex-direction: column; 417 + gap: 12px; 418 + margin-top: 16px; 419 + max-height: 600px; 420 + overflow-y: auto; 421 + padding: 12px; 422 + margin-left: -12px; 423 + margin-right: -12px; 424 + } 425 + 426 + .repoGrid::-webkit-scrollbar { 427 + width: 6px; 428 + } 429 + 430 + .repoGrid::-webkit-scrollbar-track { 431 + background: transparent; 432 + } 433 + 434 + .repoGrid::-webkit-scrollbar-thumb { 435 + background: var(--border); 436 + border-radius: 99px; 437 + } 438 + 439 + .repoGrid::-webkit-scrollbar-thumb:hover { 440 + background: var(--muted); 441 + } 442 + 443 + .repoItem { 444 + display: flex; 445 + flex-direction: row; 446 + align-items: center; 447 + width: 100%; 448 + padding: 16px; 449 + gap: 16px; 450 + background: rgba(255, 255, 255, 0.65); 451 + border: 1px solid rgba(255, 255, 255, 0.8); 452 + border-radius: 18px; 453 + text-align: left; 454 + cursor: pointer; 455 + transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); 456 + position: relative; 457 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02); 458 + } 459 + 460 + .repoItem:hover { 461 + background: rgba(255, 255, 255, 0.95); 462 + transform: scale(1.005); 463 + box-shadow: 0 8px 16px -4px rgba(0, 0, 0, 0.08); 464 + border-color: rgba(255, 255, 255, 1); 465 + z-index: 2; 466 + } 467 + 468 + [data-theme='dark'] .repoItem { 469 + background: rgba(15, 23, 42, 0.6); 470 + border-color: rgba(255, 255, 255, 0.06); 471 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 472 + } 473 + 474 + [data-theme='dark'] .repoItem:hover { 475 + background: rgba(30, 41, 59, 1); 476 + border-color: rgba(244, 162, 149, 0.3); 477 + box-shadow: 0 8px 16px -4px rgba(0, 0, 0, 0.4); 478 + } 479 + 480 + .repoItemIcon { 481 + display: flex; 482 + align-items: center; 483 + justify-content: center; 484 + width: 42px; 485 + height: 42px; 486 + background: white; 487 + border-radius: 12px; 488 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); 489 + border: 1px solid rgba(0, 0, 0, 0.04); 490 + color: #171717; 491 + flex-shrink: 0; 492 + } 493 + 494 + [data-theme='dark'] .repoItemIcon { 495 + background: #1e293b; 496 + border-color: rgba(255, 255, 255, 0.1); 497 + color: #e2e8f0; 498 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 499 + } 500 + 501 + .repoItemContent { 502 + display: flex; 503 + flex-direction: column; 504 + flex: 1; 505 + min-width: 0; 506 + gap: 4px; 507 + } 508 + 509 + .repoItemName { 510 + font-weight: 600; 511 + font-size: 15px; 512 + color: var(--card-text); 513 + white-space: nowrap; 514 + overflow: hidden; 515 + text-overflow: ellipsis; 516 + } 517 + 518 + [data-theme='dark'] .repoItemName { 519 + color: var(--card-text-light); 520 + } 521 + 522 + .repoItemDesc { 523 + font-size: 13px; 524 + color: var(--muted); 525 + line-height: 1.4; 526 + white-space: nowrap; 527 + overflow: hidden; 528 + text-overflow: ellipsis; 529 + } 530 + 531 + [data-theme='dark'] .repoItemDesc { 532 + color: rgba(148, 163, 184, 0.7); 533 + } 534 + 535 + .repoItemMetaColumn { 536 + display: flex; 537 + flex-direction: column; 538 + align-items: flex-end; 539 + gap: 6px; 540 + flex-shrink: 0; 541 + margin-left: auto; 542 + } 543 + 544 + .repoItemPrivate { 545 + background: rgba(245, 158, 11, 0.1); 546 + color: #b45309; 547 + padding: 2px 8px; 548 + border-radius: 99px; 549 + font-size: 11px; 550 + font-weight: 600; 551 + display: inline-flex; 552 + align-items: center; 553 + gap: 4px; 554 + } 555 + 556 + [data-theme='dark'] .repoItemPrivate { 557 + background: rgba(245, 158, 11, 0.15); 558 + color: #fbbf24; 559 + } 560 + 561 + .repoItemMetaRow { 562 + display: flex; 563 + align-items: center; 564 + gap: 12px; 565 + font-size: 12px; 566 + color: var(--muted); 567 + } 568 + 569 + [data-theme='dark'] .repoItemMetaRow { 570 + color: rgba(148, 163, 184, 0.9); 571 + } 572 + 573 + .repoItemMetaRow span { 574 + display: flex; 575 + align-items: center; 576 + gap: 4px; 577 + } 578 + 579 + .repoLangDot { 580 + width: 8px; 581 + height: 8px; 582 + border-radius: 50%; 583 + background-color: var(--accent); 584 + display: inline-block; 585 + } 586 + 587 + [data-theme='dark'] .repoItem:hover { 588 + background: rgba(12, 18, 32, 0.9); 589 + border-color: var(--accent); 590 + } 591 + 592 + .repoArrow { 593 + color: var(--muted); 594 + opacity: 0.5; 595 + margin-left: 8px; 596 + } 597 + 598 + [data-theme='dark'] .repoArrow { 599 + color: #fbbf24; 600 + opacity: 0.8; 601 + } 602 + 603 + [data-theme='dark'] .repoItemPrivate { 604 + background: rgba(255, 255, 255, 0.1); 605 + color: var(--card-text-light); 606 + } 607 + 608 + .searchIcon { 609 + color: var(--muted); 610 + pointer-events: none; 611 + } 612 + 613 + .repoLoadingState, 614 + .repoEmptyState { 615 + color: var(--muted); 616 + } 617 + 618 + [data-theme='dark'] .searchIcon, 619 + html[data-theme='dark'] .searchIcon, 620 + [data-theme='dark'] .repoLoadingState, 621 + html[data-theme='dark'] .repoLoadingState, 622 + [data-theme='dark'] .repoEmptyState, 623 + html[data-theme='dark'] .repoEmptyState { 624 + color: #94a3b8 !important; 625 + opacity: 1 !important; 626 + } 627 + 628 + * { 629 + box-sizing: border-box; 630 + } 631 + 632 + html { 633 + scroll-behavior: smooth; 634 + background: linear-gradient(160deg, var(--bg-gradient-1) 0%, var(--bg-gradient-2) 50%, var(--bg-gradient-3) 100%); 635 + background-color: var(--bg-gradient-3); 636 + } 637 + 638 + html, 639 + body { 640 + height: 100%; 641 + margin: 0; 642 + } 643 + 644 + body { 645 + font-family: 646 + 'Kumbh Sans', 647 + 'Inter', 648 + ui-sans-serif, 649 + system-ui, 650 + -apple-system, 651 + sans-serif; 652 + background: linear-gradient(160deg, var(--bg-gradient-1) 0%, var(--bg-gradient-2) 50%, var(--bg-gradient-3) 100%); 653 + color: var(--card-text); 654 + line-height: 1.6; 655 + transition: 656 + background 0.3s ease, 657 + color 0.3s ease; 658 + } 659 + 660 + a { 661 + color: inherit; 662 + text-decoration: none; 663 + } 664 + 665 + a:hover { 666 + text-decoration: none; 667 + } 668 + 669 + .themeToggle { 670 + display: flex; 671 + align-items: center; 672 + justify-content: center; 673 + width: 40px; 674 + height: 40px; 675 + border-radius: 12px; 676 + border: none; 677 + background: var(--card-bg); 678 + backdrop-filter: blur(12px); 679 + -webkit-backdrop-filter: blur(12px); 680 + cursor: pointer; 681 + color: var(--card-text); 682 + transition: all 0.2s ease; 683 + position: relative; 684 + overflow: hidden; 685 + } 686 + 687 + .themeToggle:hover { 688 + background: var(--sidebar-hover); 689 + transform: scale(1.05); 690 + } 691 + 692 + .themeToggle svg { 693 + width: 20px; 694 + height: 20px; 695 + transition: 696 + transform 0.3s ease, 697 + opacity 0.3s ease; 698 + } 699 + 700 + .themeToggle .sunIcon { 701 + position: absolute; 702 + opacity: 0; 703 + transform: rotate(-90deg) scale(0.5); 704 + } 705 + 706 + .themeToggle .moonIcon { 707 + opacity: 1; 708 + transform: rotate(0deg) scale(1); 709 + } 710 + 711 + [data-theme='dark'] .themeToggle .sunIcon { 712 + opacity: 1; 713 + transform: rotate(0deg) scale(1); 714 + } 715 + 716 + [data-theme='dark'] .themeToggle .moonIcon { 717 + opacity: 0; 718 + transform: rotate(90deg) scale(0.5); 719 + } 720 + 721 + .landing-page { 722 + min-height: 100vh; 723 + color: var(--card-text); 724 + } 725 + 726 + .navbar-frame { 727 + position: fixed; 728 + top: 16px; 729 + left: 50%; 730 + transform: translateX(-50%) translateY(0); 731 + z-index: 100; 732 + width: calc(100% - 32px); 733 + max-width: 1100px; 734 + transition: 735 + transform 0.3s ease, 736 + opacity 0.3s ease; 737 + } 738 + 739 + .navbar-frame.nav-hidden { 740 + transform: translateX(-50%) translateY(-120%); 741 + opacity: 0; 742 + pointer-events: none; 743 + } 744 + 745 + .navbar-content { 746 + display: flex; 747 + justify-content: space-between; 748 + align-items: center; 749 + background: var(--card-bg); 750 + backdrop-filter: blur(20px); 751 + -webkit-backdrop-filter: blur(20px); 752 + padding: 12px 20px; 753 + border-radius: 20px; 754 + box-shadow: var(--shadow); 755 + border: 1px solid var(--card-border); 756 + transition: 757 + background 0.3s ease, 758 + border 0.3s ease; 759 + } 760 + 761 + .navbar-logo { 762 + display: flex; 763 + align-items: center; 764 + gap: 10px; 765 + font-weight: 700; 766 + font-size: 1.1rem; 767 + color: var(--card-text); 768 + } 769 + 770 + .navbar-logo svg { 771 + color: var(--accent); 772 + } 773 + 774 + .navbar-buttons { 775 + display: flex; 776 + gap: 8px; 777 + align-items: center; 778 + } 779 + 780 + .glass-btn { 781 + display: inline-flex; 782 + align-items: center; 783 + gap: 8px; 784 + padding: 10px 16px; 785 + background: var(--card-bg); 786 + border: 1px solid var(--card-border); 787 + border-radius: var(--radius-btn); 788 + color: var(--card-text); 789 + font-weight: 600; 790 + font-size: 0.9rem; 791 + text-decoration: none; 792 + cursor: pointer; 793 + transition: all 0.2s ease; 794 + } 795 + 796 + .glass-btn:hover { 797 + background: var(--sidebar-hover); 798 + transform: translateY(-1px); 799 + } 800 + 801 + .glass-btn.accent { 802 + background: var(--accent-gradient); 803 + color: white; 804 + } 805 + 806 + .glass-btn.accent:hover { 807 + background: linear-gradient(135deg, #f4a295 0%, #e88978 100%); 808 + box-shadow: 0 4px 16px rgba(232, 137, 120, 0.3); 809 + } 810 + 811 + .glass-btn.large { 812 + padding: 14px 24px; 813 + font-size: 1rem; 814 + border-radius: 16px; 815 + } 816 + 817 + .page-frame { 818 + max-width: 1100px; 819 + margin: 0 auto; 820 + padding: 100px 24px 40px; 821 + } 822 + 823 + .content-section { 824 + margin-bottom: 80px; 825 + } 826 + 827 + .section-title { 828 + font-size: 1.75rem; 829 + font-weight: 700; 830 + text-align: center; 831 + margin: 0 0 32px; 832 + color: var(--card-text); 833 + } 834 + 835 + .hero-section { 836 + text-align: center; 837 + padding: 60px 0 80px; 838 + animation: fadeInUp 0.7s ease-out; 839 + } 840 + 841 + .hero-icon { 842 + display: inline-flex; 843 + align-items: center; 844 + justify-content: center; 845 + margin-bottom: 32px; 846 + animation: float 4s ease-in-out infinite; 847 + } 848 + 849 + .hero-icon img { 850 + width: 80px; 851 + height: 80px; 852 + image-rendering: pixelated; 853 + } 854 + 855 + @keyframes float { 856 + 0%, 857 + 100% { 858 + transform: translateY(0); 859 + } 860 + 861 + 50% { 862 + transform: translateY(-10px); 863 + } 864 + } 865 + 866 + @keyframes fadeInUp { 867 + from { 868 + opacity: 0; 869 + transform: translateY(24px); 870 + } 871 + 872 + to { 873 + opacity: 1; 874 + transform: translateY(0); 875 + } 876 + } 877 + 878 + .hero-section h1 { 879 + font-size: 3rem; 880 + font-weight: 800; 881 + margin: 0 0 16px; 882 + color: var(--card-text); 883 + } 884 + 885 + .hero-desc { 886 + font-size: 1.2rem; 887 + color: var(--card-text-light); 888 + max-width: 480px; 889 + margin: 0 auto 32px; 890 + line-height: 1.6; 891 + } 892 + 893 + .hero-buttons { 894 + display: flex; 895 + justify-content: center; 896 + gap: 12px; 897 + flex-wrap: wrap; 898 + } 899 + 900 + .block-card, 901 + .step-card, 902 + .cta-card { 903 + --card-color: #d0e4f8; 904 + background: color-mix(in srgb, var(--card-color) 60%, transparent 40%); 905 + backdrop-filter: blur(12px); 906 + -webkit-backdrop-filter: blur(12px); 907 + border-radius: var(--radius-card); 908 + border: 1px solid color-mix(in srgb, var(--card-color) 40%, white 60%); 909 + padding: 28px; 910 + position: relative; 911 + box-shadow: var(--shadow); 912 + transition: 913 + transform 0.3s ease, 914 + box-shadow 0.3s ease, 915 + background 0.3s ease; 916 + } 917 + 918 + [data-theme='dark'] .block-card, 919 + [data-theme='dark'] .step-card, 920 + [data-theme='dark'] .cta-card { 921 + background: color-mix(in srgb, var(--card-color) 65%, rgba(12, 18, 32, 0.6) 35%); 922 + border: 1px solid color-mix(in srgb, var(--card-color) 60%, rgba(255, 255, 255, 0.1) 40%); 923 + } 924 + 925 + .block-card:hover, 926 + .step-card:hover { 927 + transform: translateY(-6px); 928 + box-shadow: var(--shadow-hover); 929 + } 930 + 931 + .features-row { 932 + display: grid; 933 + grid-template-columns: repeat(4, 1fr); 934 + gap: 16px; 935 + } 936 + 937 + @media (max-width: 900px) { 938 + .features-row { 939 + grid-template-columns: repeat(2, 1fr); 940 + } 941 + } 942 + 943 + @media (max-width: 560px) { 944 + .features-row { 945 + grid-template-columns: 1fr; 946 + } 947 + 948 + .hero-section h1 { 949 + font-size: 2.25rem; 950 + } 951 + } 952 + 953 + html[data-theme='dark'] .landing-page .block-card { 954 + --card-color: #4cc9f0 !important; 955 + } 956 + 957 + html[data-theme='dark'] .landing-page .block-card:nth-child(2) { 958 + --card-color: #7bf1a8 !important; 959 + } 960 + 961 + html[data-theme='dark'] .landing-page .block-card:nth-child(3) { 962 + --card-color: #f5a524 !important; 963 + } 964 + 965 + html[data-theme='dark'] .landing-page .block-card:nth-child(4) { 966 + --card-color: #ff6fb1 !important; 967 + } 968 + 969 + html[data-theme='dark'] .landing-page .step-card { 970 + --card-color: #6bd1ff !important; 971 + } 972 + 973 + html[data-theme='dark'] .landing-page .step-card:nth-of-type(2) { 974 + --card-color: #9cff6b !important; 975 + } 976 + 977 + html[data-theme='dark'] .landing-page .step-card:nth-of-type(3) { 978 + --card-color: #ffb86b !important; 979 + } 980 + 981 + html[data-theme='dark'] .landing-page .framework-chip:nth-child(1) { 982 + background: #61dafb33 !important; 983 + } 984 + 985 + html[data-theme='dark'] .landing-page .framework-chip:nth-child(2) { 986 + background: #42b88333 !important; 987 + } 988 + 989 + html[data-theme='dark'] .landing-page .framework-chip:nth-child(3) { 990 + background: #ff3e0033 !important; 991 + } 992 + 993 + html[data-theme='dark'] .landing-page .framework-chip:nth-child(4) { 994 + background: #ff5d0133 !important; 995 + } 996 + 997 + html[data-theme='dark'] .landing-page .framework-chip:nth-child(5) { 998 + background: #00000022 !important; 999 + } 1000 + 1001 + html[data-theme='dark'] .landing-page .framework-chip:nth-child(6) { 1002 + background: #646cff33 !important; 1003 + } 1004 + 1005 + html[data-theme='dark'] .landing-page .block-card, 1006 + html[data-theme='dark'] .landing-page .step-card, 1007 + html[data-theme='dark'] .landing-page .cta-card { 1008 + background: color-mix(in srgb, var(--card-color) 55%, rgba(8, 12, 24, 0.75) 45%); 1009 + border: 1px solid color-mix(in srgb, var(--card-color) 50%, rgba(255, 255, 255, 0.08) 50%); 1010 + } 1011 + 1012 + .block-card .card-icon-wrap { 1013 + display: inline-flex; 1014 + align-items: center; 1015 + justify-content: center; 1016 + width: 48px; 1017 + height: 48px; 1018 + background: color-mix(in srgb, var(--card-color) 70%, white 30%); 1019 + border-radius: 14px; 1020 + margin-bottom: 16px; 1021 + color: var(--card-text); 1022 + } 1023 + 1024 + [data-theme='dark'] .block-card .card-icon-wrap { 1025 + background: color-mix(in srgb, var(--card-color) 75%, rgba(15, 22, 40, 0.7) 25%); 1026 + } 1027 + 1028 + .block-card h3 { 1029 + font-size: 1.05rem; 1030 + font-weight: 700; 1031 + margin: 0 0 8px; 1032 + color: var(--card-text); 1033 + } 1034 + 1035 + .block-card p { 1036 + font-size: 0.9rem; 1037 + color: var(--card-text-light); 1038 + line-height: 1.5; 1039 + margin: 0; 1040 + } 1041 + 1042 + [data-theme='dark'] .landing-page .block-card h3 { 1043 + color: #f6f8ff; 1044 + } 1045 + 1046 + [data-theme='dark'] .landing-page .block-card p { 1047 + color: color-mix(in srgb, #e5edff 90%, #9fb4d6 10%); 1048 + } 1049 + 1050 + .steps-row { 1051 + display: flex; 1052 + align-items: center; 1053 + justify-content: center; 1054 + gap: 16px; 1055 + flex-wrap: wrap; 1056 + } 1057 + 1058 + .step-card { 1059 + flex: 1; 1060 + min-width: 200px; 1061 + max-width: 280px; 1062 + text-align: center; 1063 + } 1064 + 1065 + .step-num { 1066 + display: inline-flex; 1067 + align-items: center; 1068 + justify-content: center; 1069 + width: 40px; 1070 + height: 40px; 1071 + background: color-mix(in srgb, var(--card-color) 70%, white 30%); 1072 + border-radius: 12px; 1073 + font-weight: 800; 1074 + font-size: 1.1rem; 1075 + margin-bottom: 12px; 1076 + color: var(--card-text); 1077 + } 1078 + 1079 + [data-theme='dark'] .step-num { 1080 + background: color-mix(in srgb, var(--card-color) 75%, rgba(15, 22, 40, 0.7) 25%); 1081 + } 1082 + 1083 + .step-card h3 { 1084 + font-size: 1rem; 1085 + font-weight: 700; 1086 + margin: 0 0 6px; 1087 + color: var(--card-text); 1088 + } 1089 + 1090 + .step-card p { 1091 + font-size: 0.85rem; 1092 + color: var(--card-text-light); 1093 + margin: 0; 1094 + line-height: 1.4; 1095 + } 1096 + 1097 + .step-arrow { 1098 + color: var(--card-text-light); 1099 + opacity: 0.4; 1100 + } 1101 + 1102 + @media (max-width: 700px) { 1103 + .step-arrow { 1104 + display: none; 1105 + } 1106 + 1107 + .steps-row { 1108 + flex-direction: column; 1109 + } 1110 + 1111 + .step-card { 1112 + max-width: 100%; 1113 + } 1114 + } 1115 + 1116 + .frameworks-row { 1117 + display: flex; 1118 + justify-content: center; 1119 + gap: 12px; 1120 + flex-wrap: wrap; 1121 + } 1122 + 1123 + .framework-chip { 1124 + padding: 10px 20px; 1125 + border-radius: 12px; 1126 + font-weight: 600; 1127 + font-size: 0.95rem; 1128 + color: var(--card-text); 1129 + transition: transform 0.2s ease; 1130 + } 1131 + 1132 + .framework-chip:hover { 1133 + transform: scale(1.05); 1134 + } 1135 + 1136 + .cta-section { 1137 + margin-bottom: 60px; 1138 + } 1139 + 1140 + .cta-card { 1141 + text-align: center; 1142 + padding: 48px 32px; 1143 + max-width: 600px; 1144 + margin: 0 auto; 1145 + } 1146 + 1147 + .cta-card svg { 1148 + color: var(--card-text); 1149 + opacity: 0.7; 1150 + margin-bottom: 20px; 1151 + } 1152 + 1153 + .cta-card h2 { 1154 + font-size: 1.5rem; 1155 + font-weight: 700; 1156 + margin: 0 0 12px; 1157 + color: var(--card-text); 1158 + } 1159 + 1160 + .cta-card p { 1161 + font-size: 1rem; 1162 + color: var(--card-text-light); 1163 + margin: 0 0 24px; 1164 + line-height: 1.5; 1165 + } 1166 + 1167 + .site-footer { 1168 + padding: 24px; 1169 + border-top: 1px solid rgba(0, 0, 0, 0.05); 1170 + } 1171 + 1172 + .footer-inner { 1173 + max-width: 1100px; 1174 + margin: 0 auto; 1175 + display: flex; 1176 + justify-content: space-between; 1177 + align-items: center; 1178 + font-size: 0.85rem; 1179 + color: var(--card-text-light); 1180 + flex-wrap: wrap; 1181 + gap: 16px; 1182 + } 1183 + 1184 + .footer-links { 1185 + display: flex; 1186 + gap: 20px; 1187 + } 1188 + 1189 + .footer-links a { 1190 + color: var(--card-text); 1191 + text-decoration: none; 1192 + font-weight: 500; 1193 + transition: opacity 0.2s; 1194 + } 1195 + 1196 + .footer-links a:hover { 1197 + opacity: 0.6; 1198 + } 1199 + 1200 + .content-section { 1201 + animation: fadeInUp 0.6s ease-out backwards; 1202 + } 1203 + 1204 + .content-section:nth-child(2) { 1205 + animation-delay: 0.1s; 1206 + } 1207 + 1208 + .content-section:nth-child(3) { 1209 + animation-delay: 0.2s; 1210 + } 1211 + 1212 + .content-section:nth-child(4) { 1213 + animation-delay: 0.3s; 1214 + } 1215 + 1216 + .block-card, 1217 + .step-card { 1218 + animation: fadeInUp 0.5s ease-out backwards; 1219 + } 1220 + 1221 + .features-row .block-card:nth-child(1) { 1222 + animation-delay: 0.1s; 1223 + } 1224 + 1225 + .features-row .block-card:nth-child(2) { 1226 + animation-delay: 0.15s; 1227 + } 1228 + 1229 + .features-row .block-card:nth-child(3) { 1230 + animation-delay: 0.2s; 1231 + } 1232 + 1233 + .features-row .block-card:nth-child(4) { 1234 + animation-delay: 0.25s; 1235 + } 1236 + 1237 + .auth-page { 1238 + min-height: 100vh; 1239 + display: flex; 1240 + align-items: center; 1241 + justify-content: center; 1242 + padding: 32px; 1243 + position: relative; 1244 + } 1245 + 1246 + .auth-theme-toggle { 1247 + position: absolute; 1248 + top: 24px; 1249 + right: 24px; 1250 + } 1251 + 1252 + .auth-card { 1253 + width: min(440px, 100%); 1254 + background: var(--glass-bg); 1255 + backdrop-filter: blur(16px); 1256 + -webkit-backdrop-filter: blur(16px); 1257 + border: 1px solid var(--card-border); 1258 + border-radius: 28px; 1259 + padding: 40px; 1260 + box-shadow: var(--shadow); 1261 + animation: fadeInUp 0.5s ease-out; 1262 + transition: 1263 + background 0.3s ease, 1264 + border 0.3s ease; 1265 + } 1266 + 1267 + .auth-card h1 { 1268 + font-size: 2rem; 1269 + font-weight: 800; 1270 + margin: 0 0 8px; 1271 + text-align: center; 1272 + color: var(--card-text); 1273 + } 1274 + 1275 + .auth-card .subtitle { 1276 + text-align: center; 1277 + color: var(--card-text-light); 1278 + margin-bottom: 28px; 1279 + font-size: 0.95rem; 1280 + } 1281 + 1282 + .auth-divider { 1283 + display: flex; 1284 + align-items: center; 1285 + gap: 16px; 1286 + margin: 20px 0; 1287 + color: var(--card-text-light); 1288 + font-size: 0.85rem; 1289 + } 1290 + 1291 + .auth-divider::before, 1292 + .auth-divider::after { 1293 + content: ''; 1294 + flex: 1; 1295 + height: 1px; 1296 + background: var(--glass-border); 1297 + } 1298 + 1299 + .auth-footer { 1300 + text-align: center; 1301 + margin-top: 24px; 1302 + font-size: 0.9rem; 1303 + color: var(--card-text-light); 1304 + } 1305 + 1306 + .auth-footer a { 1307 + color: var(--accent); 1308 + font-weight: 600; 1309 + } 1310 + 1311 + .auth-footer a:hover { 1312 + text-decoration: underline; 1313 + } 1314 + 1315 + .center { 1316 + min-height: 100vh; 1317 + display: flex; 1318 + flex-direction: column; 1319 + align-items: center; 1320 + justify-content: center; 1321 + padding: 32px; 1322 + text-align: center; 1323 + } 1324 + 1325 + .title { 1326 + font-size: 2.5rem; 1327 + line-height: 1.1; 1328 + margin: 0 0 20px; 1329 + letter-spacing: -0.03em; 1330 + font-weight: 800; 1331 + color: var(--card-text); 1332 + } 1333 + 1334 + .buttons { 1335 + display: flex; 1336 + gap: 12px; 1337 + flex-wrap: wrap; 1338 + justify-content: center; 1339 + } 1340 + 1341 + .btn { 1342 + display: inline-flex; 1343 + align-items: center; 1344 + justify-content: center; 1345 + gap: 8px; 1346 + border-radius: 14px; 1347 + border: none; 1348 + padding: 12px 20px; 1349 + font-weight: 600; 1350 + text-decoration: none; 1351 + cursor: pointer; 1352 + background: var(--card-bg); 1353 + color: var(--card-text); 1354 + min-width: 140px; 1355 + font-size: 14px; 1356 + transition: all 0.2s ease; 1357 + } 1358 + 1359 + .btn:hover { 1360 + transform: translateY(-2px); 1361 + background: var(--sidebar-hover); 1362 + box-shadow: var(--shadow); 1363 + } 1364 + 1365 + .btn:disabled { 1366 + opacity: 0.5; 1367 + cursor: not-allowed; 1368 + transform: none; 1369 + } 1370 + 1371 + .btn.primary { 1372 + background: var(--accent-gradient); 1373 + color: white; 1374 + } 1375 + 1376 + .btn.primary:hover { 1377 + box-shadow: 0 4px 20px rgba(232, 137, 120, 0.4); 1378 + } 1379 + 1380 + .btn.accent { 1381 + background: linear-gradient(135deg, #e8a36b 0%, #d89058 100%); 1382 + color: white; 1383 + } 1384 + 1385 + .btn.accent:hover { 1386 + background: linear-gradient(135deg, #f0b07a 0%, #e09860 100%); 1387 + box-shadow: 0 4px 20px rgba(212, 143, 90, 0.3); 1388 + } 1389 + 1390 + .btn.ghost { 1391 + background: var(--card-bg); 1392 + border: 1px solid var(--glass-border); 1393 + } 1394 + 1395 + .btn.ghost:hover { 1396 + background: var(--sidebar-hover); 1397 + } 1398 + 1399 + .btn.danger { 1400 + background: rgba(220, 38, 38, 0.1); 1401 + color: #b91c1c; 1402 + } 1403 + 1404 + .btn.danger:hover { 1405 + background: rgba(220, 38, 38, 0.18); 1406 + } 1407 + 1408 + .form { 1409 + width: 100%; 1410 + display: flex; 1411 + flex-direction: column; 1412 + gap: 14px; 1413 + } 1414 + 1415 + .input { 1416 + border-radius: 14px; 1417 + border: 1px solid var(--input-border); 1418 + padding: 14px 16px; 1419 + font-size: 15px; 1420 + background: var(--input-bg); 1421 + color: var(--card-text); 1422 + transition: all 0.2s ease; 1423 + } 1424 + 1425 + .input:focus { 1426 + outline: none; 1427 + border-color: var(--accent); 1428 + background: var(--card-bg-solid); 1429 + box-shadow: 0 0 0 4px rgba(232, 137, 120, 0.15); 1430 + } 1431 + 1432 + .input:disabled { 1433 + background: var(--sidebar-hover); 1434 + cursor: not-allowed; 1435 + opacity: 0.6; 1436 + } 1437 + 1438 + .input::placeholder { 1439 + color: var(--card-text-light); 1440 + } 1441 + 1442 + .oauth { 1443 + display: flex; 1444 + flex-direction: column; 1445 + gap: 10px; 1446 + } 1447 + 1448 + .oauth-providers { 1449 + display: flex; 1450 + gap: 10px; 1451 + justify-content: center; 1452 + } 1453 + 1454 + .oauth-btn { 1455 + flex: 1; 1456 + display: flex; 1457 + flex-direction: column; 1458 + align-items: center; 1459 + justify-content: center; 1460 + gap: 8px; 1461 + padding: 16px 12px; 1462 + background: var(--card-bg); 1463 + border: 1px solid var(--glass-border); 1464 + border-radius: 14px; 1465 + color: var(--card-text); 1466 + font-weight: 600; 1467 + font-size: 13px; 1468 + text-decoration: none; 1469 + cursor: pointer; 1470 + transition: all 0.2s ease; 1471 + min-width: 0; 1472 + } 1473 + 1474 + .oauth-btn:hover { 1475 + background: var(--sidebar-hover); 1476 + border-color: var(--accent); 1477 + transform: translateY(-2px); 1478 + box-shadow: var(--shadow); 1479 + } 1480 + 1481 + .oauth-btn svg { 1482 + flex-shrink: 0; 1483 + } 1484 + 1485 + .oauth-btn span { 1486 + white-space: nowrap; 1487 + overflow: hidden; 1488 + text-overflow: ellipsis; 1489 + } 1490 + 1491 + @media (max-width: 400px) { 1492 + .oauth-providers { 1493 + gap: 8px; 1494 + } 1495 + 1496 + .oauth-btn { 1497 + padding: 14px 8px; 1498 + } 1499 + 1500 + .oauth-btn span { 1501 + font-size: 11px; 1502 + } 1503 + } 1504 + 1505 + .error { 1506 + color: #b91c1c; 1507 + font-weight: 600; 1508 + font-size: 14px; 1509 + padding: 12px 16px; 1510 + background: rgba(220, 38, 38, 0.08); 1511 + border-radius: 12px; 1512 + border: 1px solid rgba(220, 38, 38, 0.12); 1513 + } 1514 + 1515 + .muted { 1516 + color: var(--card-text-light); 1517 + font-size: 14px; 1518 + } 1519 + 1520 + .muted a { 1521 + color: var(--accent); 1522 + font-weight: 600; 1523 + } 1524 + 1525 + .muted a:hover { 1526 + text-decoration: underline; 1527 + } 1528 + 1529 + .legal-page { 1530 + min-height: 100vh; 1531 + padding: 40px 24px; 1532 + position: relative; 1533 + } 1534 + 1535 + .legal-theme-toggle { 1536 + position: absolute; 1537 + top: 24px; 1538 + right: 24px; 1539 + } 1540 + 1541 + .legal-container { 1542 + max-width: 800px; 1543 + margin: 0 auto; 1544 + } 1545 + 1546 + .legal-header { 1547 + margin-bottom: 32px; 1548 + } 1549 + 1550 + .legal-header h1 { 1551 + font-size: 2.25rem; 1552 + font-weight: 800; 1553 + margin: 0 0 8px; 1554 + color: var(--card-text); 1555 + } 1556 + 1557 + .legal-header .date, 1558 + .legal-date { 1559 + color: var(--card-text-light); 1560 + font-size: 0.9rem; 1561 + } 1562 + 1563 + .legal-card { 1564 + background: var(--glass-bg); 1565 + backdrop-filter: blur(12px); 1566 + -webkit-backdrop-filter: blur(12px); 1567 + border: 1px solid var(--card-border); 1568 + border-radius: 24px; 1569 + padding: 32px; 1570 + box-shadow: var(--shadow); 1571 + color: var(--card-text); 1572 + max-width: 860px; 1573 + margin: 0 auto; 1574 + transition: 1575 + background 0.3s ease, 1576 + border 0.3s ease; 1577 + } 1578 + 1579 + .legal-card a { 1580 + color: var(--accent); 1581 + font-weight: 500; 1582 + } 1583 + 1584 + .legal-card a:hover { 1585 + text-decoration: underline; 1586 + } 1587 + 1588 + .legal-callout { 1589 + margin: 16px 0; 1590 + padding: 16px 20px; 1591 + background: var(--card-bg); 1592 + border: 1px solid var(--glass-border); 1593 + border-radius: 14px; 1594 + font-size: 14px; 1595 + color: var(--card-text); 1596 + } 1597 + 1598 + .legal-callout a { 1599 + color: var(--accent); 1600 + font-weight: 600; 1601 + } 1602 + 1603 + .legal-content { 1604 + line-height: 1.7; 1605 + } 1606 + 1607 + .legal-content h2 { 1608 + font-size: 1.15rem; 1609 + font-weight: 700; 1610 + margin: 24px 0 10px; 1611 + color: var(--card-text); 1612 + } 1613 + 1614 + .legal-content h2:first-child { 1615 + margin-top: 0; 1616 + } 1617 + 1618 + .legal-content p { 1619 + margin: 0 0 12px; 1620 + color: var(--card-text-light); 1621 + } 1622 + 1623 + .legal-content ul { 1624 + margin: 8px 0 16px 20px; 1625 + padding: 0; 1626 + } 1627 + 1628 + .legal-content li { 1629 + margin-bottom: 6px; 1630 + color: var(--card-text-light); 1631 + } 1632 + 1633 + .legal-content a { 1634 + color: #e88978; 1635 + font-weight: 500; 1636 + } 1637 + 1638 + .legal-content a:hover { 1639 + text-decoration: underline; 1640 + } 1641 + 1642 + .legal-footer { 1643 + margin-top: 24px; 1644 + } 1645 + 1646 + .legal-footer a { 1647 + display: inline-flex; 1648 + align-items: center; 1649 + gap: 6px; 1650 + color: var(--card-text-light); 1651 + font-weight: 500; 1652 + font-size: 0.9rem; 1653 + } 1654 + 1655 + .legal-footer a:hover { 1656 + color: var(--card-text); 1657 + } 1658 + 1659 + .dashWrapper { 1660 + min-height: 100vh; 1661 + display: flex; 1662 + position: relative; 1663 + background: linear-gradient(180deg, var(--bg-gradient-1) 0%, var(--bg-gradient-2) 100%); 1664 + transition: background 0.3s ease; 1665 + } 1666 + 1667 + @keyframes spin { 1668 + to { 1669 + transform: rotate(360deg); 1670 + } 1671 + } 1672 + 1673 + .animate-spin { 1674 + animation: spin 1s linear infinite; 1675 + } 1676 + 1677 + .sidebarUser { 1678 + display: flex; 1679 + align-items: center; 1680 + gap: 10px; 1681 + padding: 12px; 1682 + margin: auto 12px 12px; 1683 + border-radius: 12px; 1684 + background: var(--sidebar-hover); 1685 + transition: background 0.3s ease; 1686 + } 1687 + 1688 + .sidebarUserInfo { 1689 + display: flex; 1690 + align-items: center; 1691 + gap: 10px; 1692 + min-width: 0; 1693 + flex: 1; 1694 + } 1695 + 1696 + .sidebarAvatar { 1697 + width: 32px; 1698 + height: 32px; 1699 + border-radius: 10px; 1700 + object-fit: cover; 1701 + flex-shrink: 0; 1702 + } 1703 + 1704 + .sidebarAvatarPlaceholder { 1705 + width: 32px; 1706 + height: 32px; 1707 + border-radius: 10px; 1708 + background: var(--card-bg); 1709 + display: flex; 1710 + align-items: center; 1711 + justify-content: center; 1712 + color: var(--accent); 1713 + flex-shrink: 0; 1714 + } 1715 + 1716 + .sidebarUserText { 1717 + display: flex; 1718 + flex-direction: column; 1719 + min-width: 0; 1720 + gap: 1px; 1721 + } 1722 + 1723 + .sidebarUserName { 1724 + font-weight: 600; 1725 + font-size: 13px; 1726 + color: var(--card-text); 1727 + white-space: nowrap; 1728 + overflow: hidden; 1729 + text-overflow: ellipsis; 1730 + } 1731 + 1732 + .sidebarUserEmail { 1733 + font-size: 11px; 1734 + color: var(--card-text-light); 1735 + white-space: nowrap; 1736 + overflow: hidden; 1737 + text-overflow: ellipsis; 1738 + } 1739 + 1740 + .sidebarLogout { 1741 + display: flex; 1742 + align-items: center; 1743 + justify-content: center; 1744 + width: 28px; 1745 + height: 28px; 1746 + border-radius: 8px; 1747 + border: none; 1748 + background: transparent; 1749 + color: var(--card-text-light); 1750 + cursor: pointer; 1751 + transition: all 0.15s ease; 1752 + flex-shrink: 0; 1753 + } 1754 + 1755 + .sidebarLogout:hover { 1756 + background: rgba(220, 38, 38, 0.15); 1757 + color: #dc2626; 1758 + } 1759 + 1760 + .sidebar.collapsed .sidebarUser { 1761 + flex-direction: column; 1762 + padding: 10px 8px; 1763 + margin: auto 8px 12px; 1764 + gap: 6px; 1765 + } 1766 + 1767 + .sidebar.collapsed .sidebarUserText { 1768 + display: none; 1769 + } 1770 + 1771 + .sidebar { 1772 + position: fixed; 1773 + left: 0; 1774 + top: 0; 1775 + bottom: 0; 1776 + width: 260px; 1777 + background: var(--sidebar-bg); 1778 + backdrop-filter: blur(16px); 1779 + -webkit-backdrop-filter: blur(16px); 1780 + border-right: 1px solid var(--sidebar-border); 1781 + display: flex; 1782 + flex-direction: column; 1783 + transition: 1784 + width 0.25s ease, 1785 + background 0.3s ease, 1786 + border 0.3s ease; 1787 + z-index: 40; 1788 + overflow: hidden; 1789 + } 1790 + 1791 + .sidebar.collapsed { 1792 + width: 72px; 1793 + } 1794 + 1795 + .sidebarHeader { 1796 + display: flex; 1797 + align-items: center; 1798 + justify-content: flex-start; 1799 + padding: 24px 20px; 1800 + border-bottom: 1px solid var(--sidebar-border); 1801 + min-height: 72px; 1802 + } 1803 + 1804 + .sidebar.collapsed .sidebarHeader { 1805 + justify-content: center; 1806 + padding: 24px 12px; 1807 + } 1808 + 1809 + .brand { 1810 + display: flex; 1811 + align-items: center; 1812 + gap: 12px; 1813 + font-weight: 700; 1814 + font-size: 16px; 1815 + color: var(--card-text); 1816 + white-space: nowrap; 1817 + text-decoration: none; 1818 + transition: opacity 0.15s; 1819 + } 1820 + 1821 + .brand:hover { 1822 + opacity: 0.8; 1823 + } 1824 + 1825 + .brand svg { 1826 + flex-shrink: 0; 1827 + color: var(--accent); 1828 + } 1829 + 1830 + .sidebar.collapsed .brand span { 1831 + display: none; 1832 + } 1833 + 1834 + .sidebarFooter { 1835 + padding: 12px 14px; 1836 + border-top: 1px solid var(--sidebar-border); 1837 + } 1838 + 1839 + .sidebarToggle { 1840 + width: 100%; 1841 + display: flex; 1842 + align-items: center; 1843 + justify-content: center; 1844 + gap: 10px; 1845 + padding: 10px 14px; 1846 + border-radius: 12px; 1847 + border: none; 1848 + background: var(--sidebar-hover); 1849 + cursor: pointer; 1850 + color: var(--card-text-light); 1851 + font-weight: 600; 1852 + font-size: 13px; 1853 + transition: all 0.2s ease; 1854 + } 1855 + 1856 + .sidebarToggle:hover { 1857 + background: var(--card-bg); 1858 + color: var(--card-text); 1859 + } 1860 + 1861 + .sidebar.collapsed .sidebarToggle span { 1862 + display: none; 1863 + } 1864 + 1865 + .sidebar.collapsed .sidebarToggle { 1866 + padding: 12px; 1867 + } 1868 + 1869 + .sidebarNav { 1870 + display: flex; 1871 + flex-direction: column; 1872 + gap: 4px; 1873 + padding: 16px 12px; 1874 + } 1875 + 1876 + .sidebarLink { 1877 + display: flex; 1878 + align-items: center; 1879 + gap: 12px; 1880 + padding: 12px 14px; 1881 + border-radius: 12px; 1882 + color: var(--card-text-light); 1883 + text-decoration: none; 1884 + font-weight: 600; 1885 + font-size: 14px; 1886 + transition: all 0.15s ease; 1887 + white-space: nowrap; 1888 + } 1889 + 1890 + .sidebarLink:hover { 1891 + background: var(--sidebar-hover); 1892 + color: var(--card-text); 1893 + text-decoration: none; 1894 + } 1895 + 1896 + .sidebarLink.active { 1897 + background: var(--sidebar-active-bg); 1898 + color: #fff; 1899 + box-shadow: 0 4px 12px rgba(232, 137, 120, 0.25); 1900 + } 1901 + 1902 + .sidebarLink svg { 1903 + flex-shrink: 0; 1904 + color: inherit; 1905 + opacity: 0.7; 1906 + transition: opacity 0.15s; 1907 + } 1908 + 1909 + .sidebarLink:hover svg { 1910 + opacity: 1; 1911 + } 1912 + 1913 + .sidebarLink.active svg { 1914 + color: #fff; 1915 + opacity: 1; 1916 + } 1917 + 1918 + .sidebar.collapsed .sidebarLink { 1919 + justify-content: center; 1920 + padding: 12px; 1921 + } 1922 + 1923 + .sidebar.collapsed .sidebarLink span { 1924 + display: none; 1925 + } 1926 + 1927 + .sidebarSection { 1928 + padding: 12px 12px; 1929 + border-top: 1px solid var(--sidebar-border); 1930 + margin-top: 8px; 1931 + flex: 1; 1932 + min-height: 0; 1933 + display: flex; 1934 + flex-direction: column; 1935 + overflow: hidden; 1936 + } 1937 + 1938 + .sidebarLabel { 1939 + font-size: 10px; 1940 + font-weight: 700; 1941 + color: var(--card-text-light); 1942 + text-transform: uppercase; 1943 + letter-spacing: 0.08em; 1944 + padding: 8px 14px 12px; 1945 + white-space: nowrap; 1946 + } 1947 + 1948 + .sidebarProjects { 1949 + display: flex; 1950 + flex-direction: column; 1951 + gap: 2px; 1952 + flex: 1; 1953 + min-height: 0; 1954 + overflow-y: auto; 1955 + } 1956 + 1957 + .sidebarProjects::-webkit-scrollbar { 1958 + width: 4px; 1959 + } 1960 + 1961 + .sidebarProjects::-webkit-scrollbar-track { 1962 + background: transparent; 1963 + } 1964 + 1965 + .sidebarProjects::-webkit-scrollbar-thumb { 1966 + background: var(--glass-border); 1967 + border-radius: 4px; 1968 + } 1969 + 1970 + .sidebarProject { 1971 + display: flex; 1972 + align-items: center; 1973 + gap: 12px; 1974 + padding: 10px 14px; 1975 + border-radius: 10px; 1976 + color: var(--card-text-light); 1977 + text-decoration: none; 1978 + transition: all 0.15s ease; 1979 + } 1980 + 1981 + .sidebarProject:hover { 1982 + background: var(--sidebar-hover); 1983 + color: var(--card-text); 1984 + text-decoration: none; 1985 + } 1986 + 1987 + .sidebarProject.active { 1988 + background: var(--card-bg); 1989 + color: var(--accent); 1990 + } 1991 + 1992 + .sidebarProject svg { 1993 + flex-shrink: 0; 1994 + color: var(--card-text-light); 1995 + } 1996 + 1997 + .sidebarProject.active svg, 1998 + .sidebarProject:hover svg { 1999 + color: var(--accent); 2000 + } 2001 + 2002 + .sidebarProjectInfo { 2003 + display: flex; 2004 + flex-direction: column; 2005 + min-width: 0; 2006 + gap: 2px; 2007 + } 2008 + 2009 + .sidebarProjectName { 2010 + font-weight: 600; 2011 + font-size: 13px; 2012 + white-space: nowrap; 2013 + overflow: hidden; 2014 + text-overflow: ellipsis; 2015 + color: inherit; 2016 + } 2017 + 2018 + .sidebarProjectUrl { 2019 + font-size: 11px; 2020 + color: var(--card-text-light); 2021 + white-space: nowrap; 2022 + overflow: hidden; 2023 + text-overflow: ellipsis; 2024 + } 2025 + 2026 + .dashMain { 2027 + flex: 1; 2028 + margin-left: 260px; 2029 + padding: 24px 32px 32px; 2030 + min-height: 100vh; 2031 + transition: margin-left 0.25s ease; 2032 + } 2033 + 2034 + .dashHeader { 2035 + display: flex; 2036 + justify-content: flex-end; 2037 + align-items: center; 2038 + margin-bottom: 24px; 2039 + } 2040 + 2041 + .dashWrapper.sidebarCollapsed .dashMain { 2042 + margin-left: 72px; 2043 + } 2044 + 2045 + .sidebar.collapsed .sidebarProject { 2046 + justify-content: center; 2047 + padding: 10px; 2048 + } 2049 + 2050 + .sidebar.collapsed .sidebarProject .sidebarProjectInfo { 2051 + display: none; 2052 + } 2053 + 2054 + .sidebar.collapsed .sidebarSection { 2055 + padding: 12px 8px; 2056 + } 2057 + 2058 + .sidebar.collapsed .sidebarLabel { 2059 + display: none; 2060 + } 2061 + 2062 + .mobileMenuBtn { 2063 + display: none; 2064 + align-items: center; 2065 + justify-content: center; 2066 + width: 40px; 2067 + height: 40px; 2068 + border-radius: 12px; 2069 + border: none; 2070 + background: var(--card-bg); 2071 + backdrop-filter: blur(12px); 2072 + -webkit-backdrop-filter: blur(12px); 2073 + cursor: pointer; 2074 + color: var(--card-text); 2075 + transition: all 0.2s ease; 2076 + } 2077 + 2078 + .mobileMenuBtn:hover { 2079 + background: var(--sidebar-hover); 2080 + } 2081 + 2082 + .sidebarOverlay { 2083 + display: none; 2084 + position: fixed; 2085 + inset: 0; 2086 + background: rgba(0, 0, 0, 0.5); 2087 + backdrop-filter: blur(4px); 2088 + -webkit-backdrop-filter: blur(4px); 2089 + z-index: 39; 2090 + opacity: 0; 2091 + transition: opacity 0.3s ease; 2092 + } 2093 + 2094 + .sidebarOverlay.visible { 2095 + opacity: 1; 2096 + } 2097 + 2098 + @media (max-width: 768px) { 2099 + .mobileMenuBtn { 2100 + display: flex; 2101 + } 2102 + 2103 + .sidebarOverlay { 2104 + display: block; 2105 + pointer-events: none; 2106 + } 2107 + 2108 + .sidebarOverlay.visible { 2109 + pointer-events: auto; 2110 + } 2111 + 2112 + .sidebar { 2113 + width: 280px; 2114 + transform: translateX(-100%); 2115 + transition: 2116 + transform 0.3s ease, 2117 + width 0.25s ease; 2118 + z-index: 50; 2119 + } 2120 + 2121 + .sidebar.mobileOpen { 2122 + transform: translateX(0); 2123 + } 2124 + 2125 + .sidebar.collapsed { 2126 + width: 280px; 2127 + } 2128 + 2129 + .sidebar.collapsed .sidebarLink span, 2130 + .sidebar.collapsed .sidebarSection .sidebarLabel, 2131 + .sidebar.collapsed .sidebarProjectInfo, 2132 + .sidebar.collapsed .sidebarUserText, 2133 + .sidebar.collapsed .brand span { 2134 + display: unset; 2135 + } 2136 + 2137 + .sidebar.collapsed .sidebarLink, 2138 + .sidebar.collapsed .sidebarProject { 2139 + justify-content: flex-start; 2140 + } 2141 + 2142 + .sidebar.collapsed .sidebarHeader { 2143 + justify-content: flex-start; 2144 + padding: 24px 20px; 2145 + } 2146 + 2147 + .sidebar.collapsed .sidebarUser { 2148 + flex-direction: row; 2149 + padding: 12px; 2150 + margin: auto 12px 12px; 2151 + gap: 10px; 2152 + } 2153 + 2154 + .sidebar .sidebarFooter { 2155 + display: none; 2156 + } 2157 + 2158 + .dashMain { 2159 + margin-left: 0; 2160 + padding: 16px; 2161 + } 2162 + 2163 + .dashWrapper.sidebarCollapsed .dashMain { 2164 + margin-left: 0; 2165 + } 2166 + 2167 + .dashHeader { 2168 + justify-content: space-between; 2169 + gap: 12px; 2170 + } 2171 + } 2172 + 2173 + @media (max-width: 560px) { 2174 + .dashMain { 2175 + padding: 12px; 2176 + } 2177 + 2178 + .dashHeader { 2179 + margin-bottom: 16px; 2180 + } 2181 + } 2182 + 2183 + .userDropdown { 2184 + position: relative; 2185 + } 2186 + 2187 + .userDropdownTrigger { 2188 + display: flex; 2189 + align-items: center; 2190 + gap: 8px; 2191 + padding: 4px; 2192 + border-radius: 999px; 2193 + border: 1px solid var(--card-border); 2194 + background: var(--dropdown-bg); 2195 + cursor: pointer; 2196 + transition: all 0.15s ease; 2197 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); 2198 + } 2199 + 2200 + .userDropdownTrigger:hover { 2201 + border-color: var(--sidebar-border); 2202 + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); 2203 + } 2204 + 2205 + .userDropdownTrigger .userInfo { 2206 + display: none; 2207 + } 2208 + 2209 + .avatar { 2210 + width: 36px; 2211 + height: 36px; 2212 + border-radius: 50%; 2213 + object-fit: cover; 2214 + } 2215 + 2216 + .avatarPlaceholder { 2217 + width: 36px; 2218 + height: 36px; 2219 + border-radius: 50%; 2220 + background: #e0f2fe; 2221 + display: flex; 2222 + align-items: center; 2223 + justify-content: center; 2224 + color: #0284c7; 2225 + } 2226 + 2227 + .userInfo { 2228 + text-align: left; 2229 + } 2230 + 2231 + .userName { 2232 + display: block; 2233 + font-weight: 600; 2234 + font-size: 13px; 2235 + color: var(--card-text); 2236 + } 2237 + 2238 + .userEmail { 2239 + display: block; 2240 + font-size: 11px; 2241 + color: var(--card-text-light); 2242 + max-width: 150px; 2243 + overflow: hidden; 2244 + text-overflow: ellipsis; 2245 + white-space: nowrap; 2246 + } 2247 + 2248 + .chevron { 2249 + display: none; 2250 + color: #94a3b8; 2251 + transition: transform 0.2s ease; 2252 + } 2253 + 2254 + .chevron.open { 2255 + transform: rotate(180deg); 2256 + } 2257 + 2258 + .dropdownOverlay { 2259 + position: fixed; 2260 + inset: 0; 2261 + z-index: 99; 2262 + } 2263 + 2264 + .dropdownMenu { 2265 + position: absolute; 2266 + top: calc(100% + 8px); 2267 + right: 0; 2268 + min-width: 180px; 2269 + background: var(--dropdown-bg); 2270 + border: 1px solid var(--dropdown-border); 2271 + border-radius: 12px; 2272 + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12); 2273 + overflow: hidden; 2274 + z-index: 100; 2275 + animation: dropdownFadeIn 0.15s ease; 2276 + padding: 6px; 2277 + } 2278 + 2279 + @keyframes dropdownFadeIn { 2280 + from { 2281 + opacity: 0; 2282 + transform: translateY(-4px); 2283 + } 2284 + 2285 + to { 2286 + opacity: 1; 2287 + transform: translateY(0); 2288 + } 2289 + } 2290 + 2291 + .dropdownItem { 2292 + display: flex; 2293 + align-items: center; 2294 + gap: 10px; 2295 + width: 100%; 2296 + padding: 10px 12px; 2297 + border: none; 2298 + background: transparent; 2299 + text-align: left; 2300 + font-size: 14px; 2301 + font-weight: 500; 2302 + color: var(--card-text-light); 2303 + cursor: pointer; 2304 + text-decoration: none; 2305 + transition: all 0.1s ease; 2306 + border-radius: 8px; 2307 + } 2308 + 2309 + .dropdownItem:hover { 2310 + background: var(--dropdown-hover); 2311 + text-decoration: none; 2312 + color: var(--card-text); 2313 + } 2314 + 2315 + .dropdownItem svg { 2316 + color: var(--card-text-light); 2317 + } 2318 + 2319 + .page { 2320 + display: flex; 2321 + flex-direction: column; 2322 + gap: 24px; 2323 + } 2324 + 2325 + .pageHeader { 2326 + display: flex; 2327 + align-items: flex-end; 2328 + justify-content: space-between; 2329 + gap: 20px; 2330 + } 2331 + 2332 + @media (max-width: 768px) { 2333 + .pageHeader { 2334 + flex-direction: column; 2335 + align-items: stretch; 2336 + } 2337 + } 2338 + 2339 + .h { 2340 + font-size: 1.75rem; 2341 + font-weight: 700; 2342 + letter-spacing: -0.02em; 2343 + line-height: 1.2; 2344 + color: var(--card-text); 2345 + } 2346 + 2347 + .topActions { 2348 + display: flex; 2349 + gap: 10px; 2350 + align-items: center; 2351 + justify-content: flex-end; 2352 + } 2353 + 2354 + .crumbs { 2355 + display: flex; 2356 + gap: 8px; 2357 + align-items: center; 2358 + margin-bottom: 6px; 2359 + font-size: 13px; 2360 + } 2361 + 2362 + .crumb { 2363 + font-weight: 600; 2364 + color: var(--accent); 2365 + } 2366 + 2367 + .panel { 2368 + background: var(--card-bg); 2369 + backdrop-filter: blur(12px); 2370 + -webkit-backdrop-filter: blur(12px); 2371 + border: 1px solid var(--card-border); 2372 + border-radius: 20px; 2373 + padding: 24px; 2374 + box-shadow: var(--shadow); 2375 + transition: 2376 + background 0.3s ease, 2377 + border 0.3s ease; 2378 + } 2379 + 2380 + .panel:hover { 2381 + box-shadow: var(--shadow-hover); 2382 + } 2383 + 2384 + .panelTitle { 2385 + font-weight: 700; 2386 + font-size: 16px; 2387 + margin-bottom: 10px; 2388 + color: var(--card-text); 2389 + display: flex; 2390 + align-items: center; 2391 + } 2392 + 2393 + .panelActions { 2394 + display: flex; 2395 + justify-content: flex-end; 2396 + gap: 10px; 2397 + margin-top: 20px; 2398 + } 2399 + 2400 + .box { 2401 + background: var(--card-bg); 2402 + backdrop-filter: blur(12px); 2403 + -webkit-backdrop-filter: blur(12px); 2404 + border: 1px solid var(--card-border); 2405 + border-radius: 20px; 2406 + padding: 24px; 2407 + box-shadow: var(--shadow); 2408 + transition: 2409 + background 0.3s ease, 2410 + border 0.3s ease; 2411 + } 2412 + 2413 + .divider { 2414 + height: 1px; 2415 + background: var(--glass-border); 2416 + margin: 20px 0; 2417 + } 2418 + 2419 + .gridCards { 2420 + display: grid; 2421 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 2422 + gap: 16px; 2423 + } 2424 + 2425 + .grid2 { 2426 + display: grid; 2427 + grid-template-columns: repeat(2, 1fr); 2428 + gap: 16px; 2429 + align-items: start; 2430 + } 2431 + 2432 + @media (max-width: 900px) { 2433 + .grid2 { 2434 + grid-template-columns: 1fr; 2435 + } 2436 + } 2437 + 2438 + .projectCard { 2439 + display: block; 2440 + position: relative; 2441 + background: var(--card-bg); 2442 + backdrop-filter: blur(12px); 2443 + -webkit-backdrop-filter: blur(12px); 2444 + border: 1px solid var(--card-border); 2445 + border-radius: 16px; 2446 + padding: 20px; 2447 + box-shadow: var(--shadow); 2448 + text-decoration: none; 2449 + color: var(--card-text); 2450 + transition: all 0.2s ease; 2451 + } 2452 + 2453 + .projectCard:hover { 2454 + transform: translateY(-4px); 2455 + box-shadow: var(--shadow-hover); 2456 + text-decoration: none; 2457 + } 2458 + 2459 + .projectTitle { 2460 + font-weight: 700; 2461 + font-size: 16px; 2462 + letter-spacing: -0.01em; 2463 + color: var(--card-text); 2464 + } 2465 + 2466 + .projectMeta { 2467 + display: flex; 2468 + gap: 8px; 2469 + flex-wrap: wrap; 2470 + margin-top: 12px; 2471 + } 2472 + 2473 + .chip { 2474 + font-size: 11px; 2475 + font-weight: 600; 2476 + padding: 5px 12px; 2477 + border-radius: 999px; 2478 + background: var(--card-bg); 2479 + color: var(--card-text-light); 2480 + } 2481 + 2482 + .badge { 2483 + display: inline-flex; 2484 + align-items: center; 2485 + justify-content: center; 2486 + padding: 5px 12px; 2487 + border-radius: 999px; 2488 + background: var(--card-bg); 2489 + font-weight: 600; 2490 + font-size: 11px; 2491 + color: var(--card-text-light); 2492 + } 2493 + 2494 + .field { 2495 + display: flex; 2496 + flex-direction: column; 2497 + margin-bottom: 4px; 2498 + min-width: 0; 2499 + } 2500 + 2501 + .label { 2502 + font-size: 12px; 2503 + font-weight: 600; 2504 + color: var(--card-text); 2505 + margin-bottom: 8px; 2506 + letter-spacing: 0.01em; 2507 + } 2508 + 2509 + .row { 2510 + display: grid; 2511 + grid-template-columns: 1fr 1fr; 2512 + gap: 16px; 2513 + } 2514 + 2515 + .row .field .input { 2516 + min-width: 0; 2517 + width: 100%; 2518 + } 2519 + 2520 + @media (max-width: 560px) { 2521 + .row { 2522 + grid-template-columns: 1fr; 2523 + } 2524 + } 2525 + 2526 + .textarea { 2527 + width: 100%; 2528 + min-height: 160px; 2529 + border-radius: 10px; 2530 + border: 1px solid var(--card-border); 2531 + padding: 14px; 2532 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 2533 + font-size: 13px; 2534 + background: var(--input-bg); 2535 + color: var(--card-text); 2536 + resize: vertical; 2537 + transition: all 0.15s ease; 2538 + } 2539 + 2540 + .textarea:focus { 2541 + outline: none; 2542 + border-color: #e88978; 2543 + box-shadow: 0 0 0 3px rgba(232, 137, 120, 0.1); 2544 + } 2545 + 2546 + .tabs { 2547 + display: flex; 2548 + gap: 4px; 2549 + align-items: center; 2550 + flex-wrap: wrap; 2551 + padding: 4px; 2552 + background: var(--code-bg); 2553 + border-radius: 10px; 2554 + margin-bottom: 20px; 2555 + } 2556 + 2557 + .tab { 2558 + display: inline-flex; 2559 + align-items: center; 2560 + background: transparent; 2561 + border: none; 2562 + border-radius: 8px; 2563 + padding: 10px 16px; 2564 + font-weight: 600; 2565 + font-size: 13px; 2566 + cursor: pointer; 2567 + color: var(--card-text-light); 2568 + transition: all 0.15s ease; 2569 + } 2570 + 2571 + .tab:hover { 2572 + color: var(--card-text); 2573 + } 2574 + 2575 + .tab.active { 2576 + background: var(--card-bg); 2577 + color: var(--card-text); 2578 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); 2579 + } 2580 + 2581 + .subTabs { 2582 + display: inline-flex; 2583 + padding: 4px; 2584 + background: var(--code-bg); 2585 + border-radius: 10px; 2586 + margin: 12px 0 16px; 2587 + gap: 4px; 2588 + } 2589 + 2590 + .subTab { 2591 + background: transparent; 2592 + border: none; 2593 + border-radius: 8px; 2594 + padding: 8px 14px; 2595 + font-weight: 600; 2596 + font-size: 13px; 2597 + cursor: pointer; 2598 + color: var(--card-text-light); 2599 + transition: all 0.15s ease; 2600 + } 2601 + 2602 + .subTab:hover { 2603 + color: var(--card-text); 2604 + } 2605 + 2606 + .subTab.active { 2607 + background: var(--card-bg); 2608 + color: var(--card-text); 2609 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); 2610 + } 2611 + 2612 + [data-theme='dark'] .tabs { 2613 + background: color-mix(in srgb, var(--sidebar-bg) 70%, #0b1221 30%); 2614 + border: 1px solid var(--sidebar-border); 2615 + } 2616 + 2617 + [data-theme='dark'] .tab { 2618 + color: color-mix(in srgb, var(--card-text-light) 70%, #f5cdc6 30%); 2619 + } 2620 + 2621 + [data-theme='dark'] .tab.active { 2622 + background: color-mix(in srgb, var(--card-bg) 70%, rgba(244, 162, 149, 0.3) 30%); 2623 + box-shadow: 2624 + 0 0 0 1px color-mix(in srgb, var(--card-color, #f4a295) 35%, rgba(255, 255, 255, 0.25) 65%), 2625 + 0 6px 20px rgba(0, 0, 0, 0.35); 2626 + color: #fff5f2; 2627 + } 2628 + 2629 + [data-theme='dark'] .subTabs { 2630 + background: color-mix(in srgb, var(--sidebar-bg) 70%, #0b1221 30%); 2631 + border: 1px solid var(--sidebar-border); 2632 + } 2633 + 2634 + [data-theme='dark'] .subTab { 2635 + color: color-mix(in srgb, var(--card-text-light) 70%, #f5cdc6 30%); 2636 + } 2637 + 2638 + [data-theme='dark'] .subTab.active { 2639 + background: color-mix(in srgb, var(--card-bg) 70%, rgba(244, 162, 149, 0.25) 30%); 2640 + color: #fff5f2; 2641 + box-shadow: 2642 + 0 0 0 1px color-mix(in srgb, var(--card-color, #f4a295) 35%, rgba(255, 255, 255, 0.2) 65%), 2643 + 0 6px 20px rgba(0, 0, 0, 0.25); 2644 + } 2645 + 2646 + .deployList { 2647 + display: flex; 2648 + flex-direction: column; 2649 + gap: 8px; 2650 + } 2651 + 2652 + .deployRow { 2653 + display: flex; 2654 + align-items: flex-start; 2655 + justify-content: space-between; 2656 + gap: 16px; 2657 + background: var(--card-bg); 2658 + border: 1px solid var(--card-border); 2659 + border-radius: 12px; 2660 + padding: 16px; 2661 + transition: all 0.15s ease; 2662 + } 2663 + 2664 + .deployRow:hover { 2665 + border-color: var(--sidebar-border); 2666 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); 2667 + } 2668 + 2669 + .deployRow .siteName { 2670 + font-weight: 600; 2671 + font-size: 14px; 2672 + color: var(--card-text); 2673 + } 2674 + 2675 + .envHeader { 2676 + display: flex; 2677 + align-items: center; 2678 + justify-content: space-between; 2679 + margin-top: 0; 2680 + margin-bottom: 10px; 2681 + gap: 12px; 2682 + } 2683 + 2684 + .envHeaderLeft { 2685 + display: flex; 2686 + align-items: center; 2687 + gap: 10px; 2688 + } 2689 + 2690 + .envHeaderRight { 2691 + display: flex; 2692 + gap: 8px; 2693 + } 2694 + 2695 + .envCount { 2696 + display: inline-flex; 2697 + align-items: center; 2698 + justify-content: center; 2699 + min-width: 24px; 2700 + height: 24px; 2701 + padding: 0 8px; 2702 + background: var(--accent); 2703 + color: white; 2704 + border-radius: 12px; 2705 + font-size: 12px; 2706 + font-weight: 700; 2707 + } 2708 + 2709 + .envContainer { 2710 + display: flex; 2711 + flex-direction: column; 2712 + gap: 16px; 2713 + } 2714 + 2715 + .envDescription { 2716 + padding-left: 0; 2717 + margin-bottom: 16px; 2718 + } 2719 + 2720 + .envDescription code { 2721 + background: var(--code-bg); 2722 + padding: 2px 6px; 2723 + border-radius: 4px; 2724 + font-size: 12px; 2725 + } 2726 + 2727 + .envEmptyState { 2728 + display: flex; 2729 + flex-direction: column; 2730 + align-items: center; 2731 + justify-content: center; 2732 + padding: 48px 24px; 2733 + text-align: center; 2734 + background: var(--code-bg); 2735 + border: 1px dashed var(--card-border); 2736 + border-radius: 12px; 2737 + } 2738 + 2739 + .envEmptyIcon { 2740 + width: 64px; 2741 + height: 64px; 2742 + display: flex; 2743 + align-items: center; 2744 + justify-content: center; 2745 + background: var(--card-bg); 2746 + border-radius: 16px; 2747 + margin-bottom: 16px; 2748 + color: var(--card-text-light); 2749 + } 2750 + 2751 + .envEmptyTitle { 2752 + font-size: 16px; 2753 + font-weight: 600; 2754 + color: var(--card-text); 2755 + margin-bottom: 4px; 2756 + } 2757 + 2758 + .envEmptyDesc { 2759 + font-size: 14px; 2760 + color: var(--card-text-light); 2761 + margin-bottom: 20px; 2762 + max-width: 320px; 2763 + } 2764 + 2765 + .envTable { 2766 + border: 1px solid var(--card-border); 2767 + border-radius: 12px; 2768 + overflow: hidden; 2769 + background: var(--card-bg); 2770 + } 2771 + 2772 + .envTableHeader { 2773 + display: grid; 2774 + grid-template-columns: 200px 1fr 120px; 2775 + background: var(--code-bg); 2776 + border-bottom: 1px solid var(--card-border); 2777 + padding: 12px 16px; 2778 + font-size: 12px; 2779 + font-weight: 600; 2780 + color: var(--card-text-light); 2781 + text-transform: uppercase; 2782 + letter-spacing: 0.03em; 2783 + } 2784 + 2785 + .envTableRow { 2786 + display: grid; 2787 + grid-template-columns: 200px 1fr 120px; 2788 + padding: 12px 16px; 2789 + border-bottom: 1px solid var(--card-border); 2790 + transition: background 0.15s ease; 2791 + } 2792 + 2793 + .envTableRow:last-child { 2794 + border-bottom: none; 2795 + } 2796 + 2797 + .envTableRow:hover { 2798 + background: var(--code-bg); 2799 + } 2800 + 2801 + .envTableCell { 2802 + display: flex; 2803 + align-items: center; 2804 + min-width: 0; 2805 + } 2806 + 2807 + .envTableName { 2808 + font-weight: 500; 2809 + } 2810 + 2811 + .envKeyCode { 2812 + background: var(--code-bg); 2813 + padding: 4px 8px; 2814 + border-radius: 6px; 2815 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 2816 + font-size: 13px; 2817 + color: var(--accent); 2818 + white-space: nowrap; 2819 + overflow: hidden; 2820 + text-overflow: ellipsis; 2821 + max-width: 100%; 2822 + } 2823 + 2824 + .envValueContainer { 2825 + min-width: 0; 2826 + overflow: hidden; 2827 + } 2828 + 2829 + .envValueCode { 2830 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 2831 + font-size: 13px; 2832 + color: var(--card-text); 2833 + word-break: break-all; 2834 + white-space: pre-wrap; 2835 + } 2836 + 2837 + .envMasked { 2838 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 2839 + font-size: 13px; 2840 + color: var(--card-text-light); 2841 + letter-spacing: 2px; 2842 + opacity: 0.6; 2843 + } 2844 + 2845 + .envTableActions { 2846 + display: flex; 2847 + align-items: center; 2848 + justify-content: flex-end; 2849 + gap: 4px; 2850 + } 2851 + 2852 + .envActionBtn { 2853 + display: flex; 2854 + align-items: center; 2855 + justify-content: center; 2856 + width: 32px; 2857 + height: 32px; 2858 + border: none; 2859 + background: transparent; 2860 + border-radius: 8px; 2861 + color: var(--card-text-light); 2862 + cursor: pointer; 2863 + transition: all 0.15s ease; 2864 + } 2865 + 2866 + .envActionBtn:hover { 2867 + background: var(--sidebar-hover); 2868 + color: var(--card-text); 2869 + } 2870 + 2871 + .envActionBtnDanger:hover { 2872 + background: rgba(220, 38, 38, 0.1); 2873 + color: #dc2626; 2874 + } 2875 + 2876 + .envFooter { 2877 + display: flex; 2878 + align-items: center; 2879 + justify-content: space-between; 2880 + gap: 12px; 2881 + padding-top: 4px; 2882 + } 2883 + 2884 + .envRawTextarea { 2885 + min-height: 300px; 2886 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 2887 + font-size: 13px; 2888 + line-height: 1.6; 2889 + resize: vertical; 2890 + } 2891 + 2892 + .envModal .envKeyInput { 2893 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 2894 + text-transform: uppercase; 2895 + } 2896 + 2897 + .envModal .envValueInput { 2898 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 2899 + min-height: 100px; 2900 + } 2901 + 2902 + [data-theme='dark'] .envCount { 2903 + background: var(--accent); 2904 + } 2905 + 2906 + [data-theme='dark'] .envEmptyState { 2907 + background: color-mix(in srgb, var(--card-bg) 50%, transparent 50%); 2908 + border-color: rgba(255, 255, 255, 0.1); 2909 + } 2910 + 2911 + [data-theme='dark'] .envEmptyIcon { 2912 + background: var(--sidebar-bg); 2913 + } 2914 + 2915 + [data-theme='dark'] .envTable { 2916 + background: color-mix(in srgb, var(--card-bg) 80%, rgba(255, 255, 255, 0.02) 20%); 2917 + border-color: rgba(255, 255, 255, 0.08); 2918 + } 2919 + 2920 + [data-theme='dark'] .envTableHeader { 2921 + background: var(--sidebar-bg); 2922 + border-color: rgba(255, 255, 255, 0.08); 2923 + } 2924 + 2925 + [data-theme='dark'] .envTableRow { 2926 + border-color: rgba(255, 255, 255, 0.06); 2927 + } 2928 + 2929 + [data-theme='dark'] .envTableRow:hover { 2930 + background: rgba(255, 255, 255, 0.03); 2931 + } 2932 + 2933 + [data-theme='dark'] .envKeyCode { 2934 + background: rgba(255, 255, 255, 0.06); 2935 + color: var(--accent); 2936 + } 2937 + 2938 + [data-theme='dark'] .envActionBtn:hover { 2939 + background: rgba(255, 255, 255, 0.08); 2940 + } 2941 + 2942 + [data-theme='dark'] .envActionBtnDanger:hover { 2943 + background: rgba(248, 113, 113, 0.15); 2944 + color: #f87171; 2945 + } 2946 + 2947 + @media (max-width: 768px) { 2948 + .envHeader { 2949 + flex-direction: column; 2950 + align-items: flex-start; 2951 + } 2952 + 2953 + .envTableHeader { 2954 + display: none; 2955 + } 2956 + 2957 + .envTableRow { 2958 + display: flex; 2959 + flex-direction: column; 2960 + gap: 8px; 2961 + padding: 16px; 2962 + } 2963 + 2964 + .envTableCell { 2965 + width: 100%; 2966 + } 2967 + 2968 + .envTableName { 2969 + order: 1; 2970 + } 2971 + 2972 + .envTableValue { 2973 + order: 2; 2974 + padding: 8px 0; 2975 + } 2976 + 2977 + .envTableActions { 2978 + order: 3; 2979 + justify-content: flex-start; 2980 + padding-top: 8px; 2981 + border-top: 1px solid var(--card-border); 2982 + } 2983 + 2984 + .envFooter { 2985 + flex-direction: column; 2986 + } 2987 + 2988 + .envFooter .btn { 2989 + width: 100%; 2990 + } 2991 + } 2992 + 2993 + .envList { 2994 + display: flex; 2995 + flex-direction: column; 2996 + gap: 8px; 2997 + margin: 12px 0 16px; 2998 + } 2999 + 3000 + .envListItem { 3001 + background: var(--card-bg); 3002 + border: 1px solid var(--card-border); 3003 + border-radius: 12px; 3004 + padding: 12px 16px; 3005 + display: flex; 3006 + align-items: center; 3007 + justify-content: space-between; 3008 + gap: 12px; 3009 + transition: all 0.15s ease; 3010 + } 3011 + 3012 + .envListItem:hover { 3013 + border-color: var(--sidebar-border); 3014 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); 3015 + } 3016 + 3017 + .envListItemMain { 3018 + display: flex; 3019 + align-items: center; 3020 + gap: 16px; 3021 + flex: 1; 3022 + min-width: 0; 3023 + } 3024 + 3025 + .envListItemRight { 3026 + display: flex; 3027 + align-items: center; 3028 + gap: 8px; 3029 + } 3030 + 3031 + .envKey { 3032 + font-weight: 600; 3033 + font-size: 14px; 3034 + color: var(--card-text); 3035 + white-space: nowrap; 3036 + overflow: hidden; 3037 + text-overflow: ellipsis; 3038 + max-width: 200px; 3039 + } 3040 + 3041 + .envValue { 3042 + flex: 1; 3043 + min-width: 0; 3044 + } 3045 + 3046 + .envValueText { 3047 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 3048 + font-size: 13px; 3049 + color: var(--card-text); 3050 + word-break: break-all; 3051 + } 3052 + 3053 + .maskedPlaceholder { 3054 + color: var(--muted); 3055 + font-style: italic; 3056 + } 3057 + 3058 + .envMaskedPill { 3059 + display: flex; 3060 + align-items: center; 3061 + justify-content: center; 3062 + padding: 8px 12px; 3063 + background: var(--code-bg); 3064 + border: 1px solid var(--card-border); 3065 + border-radius: 0; 3066 + min-width: 120px; 3067 + max-width: 300px; 3068 + min-height: 40px; 3069 + font-size: 16px; 3070 + color: var(--muted); 3071 + } 3072 + 3073 + .envMaskedPill.clickable { 3074 + cursor: pointer; 3075 + transition: all 0.15s ease; 3076 + } 3077 + 3078 + .envMaskedPill.clickable:hover { 3079 + background: var(--card-bg); 3080 + border-color: var(--sidebar-border); 3081 + } 3082 + 3083 + .envMaskedPill.clickable:active { 3084 + transform: scale(0.98); 3085 + } 3086 + 3087 + .inlineTextarea { 3088 + width: 100%; 3089 + min-width: 120px; 3090 + max-width: 300px; 3091 + height: auto; 3092 + min-height: 32px; 3093 + padding: 4px 0; 3094 + border: none; 3095 + border-radius: 0; 3096 + background: transparent; 3097 + resize: none; 3098 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 3099 + font-size: 15px; 3100 + line-height: 1.2; 3101 + } 3102 + 3103 + .maskCircles { 3104 + font-size: 16px; 3105 + color: var(--muted); 3106 + opacity: 0.6; 3107 + letter-spacing: 0; 3108 + } 3109 + 3110 + .maskValue { 3111 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 3112 + font-size: 12px; 3113 + color: var(--card-text); 3114 + white-space: nowrap; 3115 + overflow: hidden; 3116 + text-overflow: ellipsis; 3117 + max-width: 120px; 3118 + } 3119 + 3120 + .envListItemActions { 3121 + display: flex; 3122 + align-items: center; 3123 + gap: 6px; 3124 + } 3125 + 3126 + .dropdownWrapper { 3127 + position: relative; 3128 + } 3129 + 3130 + .dropdownMenu { 3131 + position: absolute; 3132 + top: 100%; 3133 + right: 0; 3134 + margin-top: 4px; 3135 + background: var(--dropdown-bg); 3136 + border: 1px solid var(--dropdown-border); 3137 + border-radius: 8px; 3138 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 3139 + z-index: 10; 3140 + min-width: 140px; 3141 + overflow: hidden; 3142 + } 3143 + 3144 + .dropdownItem { 3145 + display: flex; 3146 + align-items: center; 3147 + gap: 8px; 3148 + width: 100%; 3149 + padding: 8px 12px; 3150 + background: none; 3151 + border: none; 3152 + font-size: 13px; 3153 + color: var(--card-text); 3154 + cursor: pointer; 3155 + transition: background 0.15s ease; 3156 + } 3157 + 3158 + .dropdownItem:hover { 3159 + background: var(--dropdown-hover); 3160 + } 3161 + 3162 + .dropdownItem.danger { 3163 + color: #dc2626; 3164 + } 3165 + 3166 + .dropdownItem.danger:hover { 3167 + background: rgba(220, 38, 38, 0.1); 3168 + } 3169 + 3170 + [data-theme='dark'] .envListItem { 3171 + background: color-mix(in srgb, var(--card-bg) 80%, rgba(255, 255, 255, 0.04) 20%); 3172 + border-color: rgba(255, 255, 255, 0.08); 3173 + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); 3174 + } 3175 + 3176 + [data-theme='dark'] .envListItem:hover { 3177 + border-color: rgba(255, 255, 255, 0.14); 3178 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); 3179 + } 3180 + 3181 + [data-theme='dark'] .envValueText { 3182 + color: var(--card-text-light); 3183 + } 3184 + 3185 + [data-theme='dark'] .maskedPlaceholder { 3186 + color: var(--card-text-light); 3187 + } 3188 + 3189 + [data-theme='dark'] .envMaskedPill { 3190 + background: var(--code-bg); 3191 + border-color: var(--card-border); 3192 + color: var(--card-text-light); 3193 + } 3194 + 3195 + [data-theme='dark'] .envMaskedPill.clickable:hover { 3196 + background: var(--card-bg); 3197 + border-color: rgba(255, 255, 255, 0.14); 3198 + } 3199 + 3200 + [data-theme='dark'] .inlineTextarea { 3201 + color: var(--card-text-light); 3202 + } 3203 + 3204 + [data-theme='dark'] .maskCircles { 3205 + color: var(--card-text-light); 3206 + opacity: 0.5; 3207 + } 3208 + 3209 + [data-theme='dark'] .maskValue { 3210 + color: var(--card-text-light); 3211 + } 3212 + 3213 + [data-theme='dark'] .dropdownMenu { 3214 + background: var(--dropdown-bg); 3215 + border-color: var(--dropdown-border); 3216 + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); 3217 + } 3218 + 3219 + [data-theme='dark'] .dropdownItem:hover { 3220 + background: var(--dropdown-hover); 3221 + } 3222 + 3223 + [data-theme='dark'] .dropdownItem.danger:hover { 3224 + background: rgba(248, 113, 113, 0.2); 3225 + } 3226 + 3227 + .envToolbar { 3228 + display: flex; 3229 + align-items: center; 3230 + justify-content: space-between; 3231 + gap: 12px; 3232 + padding: 12px; 3233 + background: var(--code-bg); 3234 + border-radius: 12px; 3235 + border: 1px solid var(--card-border); 3236 + margin-bottom: 12px; 3237 + } 3238 + 3239 + .envToolbarActions { 3240 + display: flex; 3241 + gap: 8px; 3242 + flex-wrap: wrap; 3243 + } 3244 + 3245 + .envRawArea { 3246 + min-height: 220px; 3247 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 3248 + } 3249 + 3250 + .pill { 3251 + display: inline-flex; 3252 + align-items: center; 3253 + gap: 6px; 3254 + border-radius: 999px; 3255 + padding: 4px 10px; 3256 + font-weight: 600; 3257 + font-size: 12px; 3258 + border: 1px solid var(--border); 3259 + background: rgba(0, 0, 0, 0.02); 3260 + color: var(--muted); 3261 + } 3262 + 3263 + .mutedChip { 3264 + background: rgba(15, 23, 42, 0.04); 3265 + border-color: rgba(15, 23, 42, 0.08); 3266 + } 3267 + 3268 + .softPill { 3269 + background: rgba(232, 137, 120, 0.12); 3270 + border-color: rgba(232, 137, 120, 0.2); 3271 + color: #c45a47; 3272 + } 3273 + 3274 + [data-theme='dark'] .pill { 3275 + border-color: rgba(255, 255, 255, 0.12); 3276 + background: rgba(255, 255, 255, 0.05); 3277 + color: var(--card-text-light); 3278 + } 3279 + 3280 + [data-theme='dark'] .mutedChip { 3281 + background: rgba(255, 255, 255, 0.06); 3282 + border-color: rgba(255, 255, 255, 0.1); 3283 + } 3284 + 3285 + [data-theme='dark'] .softPill { 3286 + background: rgba(244, 162, 149, 0.18); 3287 + border-color: rgba(244, 162, 149, 0.3); 3288 + color: #f4a295; 3289 + } 3290 + 3291 + [data-theme='dark'] .envCard { 3292 + background: color-mix(in srgb, var(--card-bg) 80%, rgba(255, 255, 255, 0.04) 20%); 3293 + border-color: rgba(255, 255, 255, 0.08); 3294 + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); 3295 + } 3296 + 3297 + .actions { 3298 + display: flex; 3299 + gap: 8px; 3300 + flex-wrap: wrap; 3301 + justify-content: flex-end; 3302 + } 3303 + 3304 + .notice { 3305 + background: var(--notice-bg); 3306 + border: 1px solid var(--notice-border); 3307 + padding: 14px 18px; 3308 + border-radius: 10px; 3309 + font-weight: 500; 3310 + color: var(--notice-text); 3311 + font-size: 14px; 3312 + display: flex; 3313 + align-items: center; 3314 + gap: 10px; 3315 + } 3316 + 3317 + .notice::before { 3318 + content: '⚡'; 3319 + font-size: 16px; 3320 + } 3321 + 3322 + .errorBox { 3323 + background: var(--error-bg); 3324 + border: 1px solid var(--error-border); 3325 + padding: 14px 18px; 3326 + border-radius: 12px; 3327 + font-weight: 500; 3328 + color: var(--error-text); 3329 + font-size: 14px; 3330 + } 3331 + 3332 + .modalOverlay { 3333 + position: fixed; 3334 + inset: 0; 3335 + background: rgba(15, 23, 42, 0.4); 3336 + backdrop-filter: blur(8px); 3337 + -webkit-backdrop-filter: blur(8px); 3338 + display: flex; 3339 + align-items: center; 3340 + justify-content: center; 3341 + padding: 24px; 3342 + z-index: 100; 3343 + animation: modalOverlayIn 0.2s ease; 3344 + } 3345 + 3346 + @keyframes modalOverlayIn { 3347 + from { 3348 + opacity: 0; 3349 + } 3350 + 3351 + to { 3352 + opacity: 1; 3353 + } 3354 + } 3355 + 3356 + .modal { 3357 + width: min(540px, 96vw); 3358 + max-height: calc(100vh - 48px); 3359 + overflow-y: auto; 3360 + background: var(--modal-bg); 3361 + backdrop-filter: blur(20px); 3362 + -webkit-backdrop-filter: blur(20px); 3363 + border: 1px solid var(--modal-border); 3364 + border-radius: 24px; 3365 + box-shadow: 3366 + 0 24px 80px rgba(0, 0, 0, 0.15), 3367 + inset 0 1px 1px var(--modal-inset); 3368 + animation: modalIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); 3369 + } 3370 + 3371 + @keyframes modalIn { 3372 + from { 3373 + opacity: 0; 3374 + transform: translateY(20px) scale(0.96); 3375 + } 3376 + 3377 + to { 3378 + opacity: 1; 3379 + transform: translateY(0) scale(1); 3380 + } 3381 + } 3382 + 3383 + .modalHeader { 3384 + display: flex; 3385 + align-items: center; 3386 + justify-content: space-between; 3387 + padding: 24px 28px; 3388 + border-bottom: 1px solid var(--modal-header-border); 3389 + background: var(--modal-header-bg); 3390 + } 3391 + 3392 + .modalTitle { 3393 + font-weight: 700; 3394 + font-size: 18px; 3395 + display: flex; 3396 + align-items: center; 3397 + gap: 10px; 3398 + color: var(--card-text); 3399 + } 3400 + 3401 + .iconBtn { 3402 + width: 36px; 3403 + height: 36px; 3404 + border-radius: 12px; 3405 + border: none; 3406 + background: var(--icon-btn-bg); 3407 + cursor: pointer; 3408 + font-size: 20px; 3409 + line-height: 1; 3410 + display: flex; 3411 + align-items: center; 3412 + justify-content: center; 3413 + transition: all 0.15s ease; 3414 + color: var(--card-text-light); 3415 + } 3416 + 3417 + .iconBtn:hover { 3418 + background: rgba(220, 38, 38, 0.12); 3419 + color: #dc2626; 3420 + } 3421 + 3422 + .modalBody { 3423 + padding: 28px; 3424 + display: flex; 3425 + flex-direction: column; 3426 + gap: 24px; 3427 + } 3428 + 3429 + .modalActions { 3430 + display: flex; 3431 + justify-content: flex-end; 3432 + gap: 12px; 3433 + margin-top: 8px; 3434 + padding-top: 20px; 3435 + border-top: 1px solid var(--modal-header-border); 3436 + } 3437 + 3438 + .errorModal .modalHeader { 3439 + background: rgba(254, 226, 226, 0.5); 3440 + } 3441 + 3442 + .errorContent { 3443 + display: flex; 3444 + flex-direction: column; 3445 + gap: 16px; 3446 + } 3447 + 3448 + .errorMessage { 3449 + font-size: 15px; 3450 + color: var(--card-text); 3451 + line-height: 1.6; 3452 + margin: 0; 3453 + } 3454 + 3455 + .statusPill.status-ready, 3456 + .statusPill.status-active, 3457 + .statusPill.status-success { 3458 + background: rgba(34, 197, 94, 0.1); 3459 + color: #22c55e; 3460 + border: 1px solid rgba(34, 197, 94, 0.2); 3461 + } 3462 + 3463 + .errorSuggestion { 3464 + display: flex; 3465 + align-items: flex-start; 3466 + gap: 10px; 3467 + background: rgba(220, 252, 231, 0.6); 3468 + border: 1px solid rgba(134, 239, 172, 0.5); 3469 + border-radius: 12px; 3470 + padding: 14px; 3471 + } 3472 + 3473 + .errorSuggestion svg { 3474 + color: #15803d; 3475 + flex-shrink: 0; 3476 + margin-top: 2px; 3477 + } 3478 + 3479 + .errorSuggestion strong { 3480 + color: #15803d; 3481 + font-size: 13px; 3482 + } 3483 + 3484 + .errorSuggestion p { 3485 + margin: 4px 0 0; 3486 + color: #166534; 3487 + font-size: 14px; 3488 + } 3489 + 3490 + .errorDetails { 3491 + margin-top: 8px; 3492 + } 3493 + 3494 + .errorDetails summary { 3495 + cursor: pointer; 3496 + font-size: 13px; 3497 + color: var(--card-text-light); 3498 + font-weight: 600; 3499 + } 3500 + 3501 + .errorDetails pre { 3502 + background: var(--code-bg); 3503 + border-radius: 8px; 3504 + padding: 12px; 3505 + font-size: 12px; 3506 + overflow-x: auto; 3507 + margin-top: 8px; 3508 + color: var(--card-text); 3509 + } 3510 + 3511 + .logsModal { 3512 + width: min(900px, 96vw); 3513 + max-height: 80vh; 3514 + display: flex; 3515 + flex-direction: column; 3516 + } 3517 + 3518 + .logsModal .modalBody { 3519 + flex: 1; 3520 + overflow: hidden; 3521 + display: flex; 3522 + flex-direction: column; 3523 + gap: 12px; 3524 + } 3525 + 3526 + .logsInfo { 3527 + display: flex; 3528 + align-items: center; 3529 + gap: 12px; 3530 + flex-wrap: wrap; 3531 + } 3532 + 3533 + .logsLoading { 3534 + padding: 24px; 3535 + text-align: center; 3536 + color: var(--card-text-light); 3537 + } 3538 + 3539 + .logsContent { 3540 + flex: 1; 3541 + margin: 0; 3542 + padding: 14px; 3543 + background: var(--code-bg); 3544 + border-radius: 10px; 3545 + border: 1px solid var(--card-border); 3546 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 3547 + font-size: 12px; 3548 + line-height: 1.6; 3549 + overflow: auto; 3550 + white-space: pre-wrap; 3551 + word-break: break-all; 3552 + max-height: 60vh; 3553 + color: var(--card-text); 3554 + } 3555 + 3556 + .turnstile-container { 3557 + display: flex; 3558 + justify-content: center; 3559 + margin: 8px 0; 3560 + } 3561 + 3562 + .toast { 3563 + position: fixed; 3564 + bottom: 24px; 3565 + right: 24px; 3566 + padding: 14px 20px; 3567 + background: var(--card-bg-solid); 3568 + color: var(--card-text); 3569 + border: 1px solid var(--card-border); 3570 + border-radius: 12px; 3571 + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); 3572 + font-size: 14px; 3573 + font-weight: 500; 3574 + z-index: 9999; 3575 + animation: toastIn 0.25s ease; 3576 + display: flex; 3577 + align-items: center; 3578 + gap: 10px; 3579 + backdrop-filter: blur(12px); 3580 + -webkit-backdrop-filter: blur(12px); 3581 + } 3582 + 3583 + .toast svg { 3584 + color: #4ade80; 3585 + } 3586 + 3587 + @keyframes toastIn { 3588 + from { 3589 + transform: translateY(16px) scale(0.95); 3590 + opacity: 0; 3591 + } 3592 + 3593 + to { 3594 + transform: translateY(0) scale(1); 3595 + opacity: 1; 3596 + } 3597 + } 3598 + 3599 + @media (max-width: 768px) { 3600 + .pageHeader { 3601 + flex-direction: column; 3602 + align-items: stretch; 3603 + gap: 16px; 3604 + } 3605 + 3606 + .pageHeader .topActions { 3607 + flex-wrap: wrap; 3608 + } 3609 + 3610 + .h { 3611 + font-size: 1.5rem; 3612 + } 3613 + 3614 + .crumbs { 3615 + font-size: 12px; 3616 + } 3617 + } 3618 + 3619 + @media (max-width: 768px) { 3620 + .panel { 3621 + padding: 16px; 3622 + border-radius: 16px; 3623 + } 3624 + 3625 + .panelTitle { 3626 + font-size: 15px; 3627 + } 3628 + 3629 + .panelActions { 3630 + flex-direction: column; 3631 + } 3632 + 3633 + .panelActions .btn { 3634 + width: 100%; 3635 + } 3636 + } 3637 + 3638 + @media (max-width: 768px) { 3639 + .gridCards { 3640 + grid-template-columns: 1fr; 3641 + gap: 12px; 3642 + } 3643 + } 3644 + 3645 + @media (max-width: 560px) { 3646 + .gridCards { 3647 + gap: 10px; 3648 + } 3649 + } 3650 + 3651 + @media (max-width: 560px) { 3652 + .projectCard { 3653 + padding: 16px; 3654 + border-radius: 14px; 3655 + } 3656 + 3657 + .projectTitle { 3658 + font-size: 15px; 3659 + } 3660 + 3661 + .projectMeta { 3662 + margin-top: 10px; 3663 + } 3664 + } 3665 + 3666 + @media (max-width: 768px) { 3667 + .tabs { 3668 + width: 100%; 3669 + overflow-x: auto; 3670 + -webkit-overflow-scrolling: touch; 3671 + scrollbar-width: none; 3672 + -ms-overflow-style: none; 3673 + } 3674 + 3675 + .tabs::-webkit-scrollbar { 3676 + display: none; 3677 + } 3678 + 3679 + .tab { 3680 + flex-shrink: 0; 3681 + padding: 10px 14px; 3682 + font-size: 13px; 3683 + } 3684 + } 3685 + 3686 + @media (max-width: 768px) { 3687 + .deployRow { 3688 + flex-direction: column; 3689 + align-items: stretch; 3690 + gap: 12px; 3691 + padding: 14px; 3692 + } 3693 + 3694 + .deployRowMain { 3695 + gap: 8px; 3696 + } 3697 + 3698 + .deployRowTop { 3699 + flex-wrap: wrap; 3700 + gap: 8px; 3701 + } 3702 + 3703 + .deployLinks { 3704 + flex-direction: column; 3705 + gap: 6px; 3706 + } 3707 + 3708 + .linkChip { 3709 + width: 100%; 3710 + justify-content: flex-start; 3711 + padding: 8px 10px; 3712 + font-size: 12px; 3713 + word-break: break-all; 3714 + } 3715 + 3716 + .deployCommit { 3717 + flex-direction: column; 3718 + align-items: flex-start; 3719 + gap: 6px; 3720 + } 3721 + 3722 + .commitMessage { 3723 + max-width: 100%; 3724 + } 3725 + 3726 + .actions { 3727 + width: 100%; 3728 + justify-content: stretch; 3729 + } 3730 + 3731 + .actions .btn { 3732 + flex: 1; 3733 + justify-content: center; 3734 + } 3735 + } 3736 + 3737 + @media (max-width: 560px) { 3738 + .deployRow { 3739 + padding: 12px; 3740 + border-radius: 10px; 3741 + } 3742 + 3743 + .statusPill { 3744 + padding: 3px 8px; 3745 + font-size: 11px; 3746 + } 3747 + 3748 + .deployTime { 3749 + font-size: 11px; 3750 + } 3751 + } 3752 + 3753 + @media (max-width: 560px) { 3754 + .row { 3755 + grid-template-columns: 1fr; 3756 + gap: 12px; 3757 + } 3758 + 3759 + .field .input { 3760 + font-size: 16px; 3761 + } 3762 + 3763 + .textarea { 3764 + font-size: 14px; 3765 + min-height: 120px; 3766 + } 3767 + } 3768 + 3769 + @media (max-width: 560px) { 3770 + .btn { 3771 + padding: 12px 16px; 3772 + font-size: 14px; 3773 + min-width: unset; 3774 + } 3775 + 3776 + .topActions { 3777 + width: 100%; 3778 + } 3779 + 3780 + .topActions .btn { 3781 + flex: 1; 3782 + justify-content: center; 3783 + } 3784 + } 3785 + 3786 + @media (max-width: 768px) { 3787 + .modalOverlay { 3788 + padding: 16px; 3789 + align-items: flex-end; 3790 + } 3791 + 3792 + .modal { 3793 + width: 100%; 3794 + max-height: 90vh; 3795 + border-radius: 20px 20px 0 0; 3796 + animation: modalMobileIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); 3797 + } 3798 + 3799 + @keyframes modalMobileIn { 3800 + from { 3801 + opacity: 0; 3802 + transform: translateY(100%); 3803 + } 3804 + 3805 + to { 3806 + opacity: 1; 3807 + transform: translateY(0); 3808 + } 3809 + } 3810 + 3811 + .modalHeader { 3812 + padding: 20px; 3813 + } 3814 + 3815 + .modalTitle { 3816 + font-size: 16px; 3817 + } 3818 + 3819 + .modalBody { 3820 + padding: 20px; 3821 + gap: 16px; 3822 + } 3823 + 3824 + .modalActions { 3825 + flex-direction: column-reverse; 3826 + gap: 10px; 3827 + } 3828 + 3829 + .modalActions .btn { 3830 + width: 100%; 3831 + } 3832 + 3833 + .logsModal { 3834 + width: 100%; 3835 + max-height: 90vh; 3836 + } 3837 + 3838 + .logsContent { 3839 + font-size: 11px; 3840 + max-height: 50vh; 3841 + } 3842 + } 3843 + 3844 + @media (max-width: 560px) { 3845 + .modalOverlay { 3846 + padding: 0; 3847 + } 3848 + 3849 + .modal { 3850 + border-radius: 16px 16px 0 0; 3851 + } 3852 + 3853 + .modalHeader { 3854 + padding: 16px; 3855 + } 3856 + 3857 + .modalBody { 3858 + padding: 16px; 3859 + } 3860 + } 3861 + 3862 + @media (max-width: 560px) { 3863 + .toast { 3864 + left: 12px; 3865 + right: 12px; 3866 + bottom: 12px; 3867 + width: auto; 3868 + justify-content: center; 3869 + } 3870 + } 3871 + 3872 + @media (max-width: 560px) { 3873 + .notice { 3874 + padding: 12px 14px; 3875 + font-size: 13px; 3876 + border-radius: 10px; 3877 + } 3878 + 3879 + .errorBox { 3880 + padding: 12px 14px; 3881 + font-size: 13px; 3882 + } 3883 + } 3884 + 3885 + @media (max-width: 768px) { 3886 + .grid2 { 3887 + grid-template-columns: 1fr; 3888 + gap: 12px; 3889 + } 3890 + } 3891 + 3892 + @media (max-width: 560px) { 3893 + .box { 3894 + padding: 16px; 3895 + border-radius: 14px; 3896 + } 3897 + } 3898 + 3899 + @media (max-width: 560px) { 3900 + .chip { 3901 + padding: 4px 10px; 3902 + font-size: 10px; 3903 + } 3904 + 3905 + .badge { 3906 + padding: 4px 10px; 3907 + font-size: 10px; 3908 + } 3909 + } 3910 + 3911 + @media (max-width: 768px) { 3912 + .userDropdown { 3913 + order: 2; 3914 + } 3915 + 3916 + .dropdownMenu { 3917 + right: 0; 3918 + min-width: 160px; 3919 + } 3920 + } 3921 + 3922 + @media (max-width: 768px) { 3923 + .errorModal .modalBody { 3924 + padding: 16px; 3925 + } 3926 + 3927 + .errorSuggestion { 3928 + padding: 12px; 3929 + } 3930 + 3931 + .errorMessage { 3932 + font-size: 14px; 3933 + } 3934 + } 3935 + 3936 + @media (max-width: 768px) { 3937 + .sidebarProjects { 3938 + max-height: calc(100vh - 320px); 3939 + } 3940 + } 3941 + 3942 + @media (max-width: 560px) { 3943 + .panel .row { 3944 + flex-direction: column; 3945 + align-items: stretch; 3946 + } 3947 + 3948 + .panel .row .field { 3949 + align-self: stretch; 3950 + } 3951 + 3952 + .panel .row .field .btn { 3953 + width: 100%; 3954 + } 3955 + } 3956 + 3957 + @media (max-width: 768px) { 3958 + .topbar { 3959 + flex-direction: column; 3960 + align-items: stretch; 3961 + gap: 16px; 3962 + } 3963 + 3964 + .topbar .topActions { 3965 + width: 100%; 3966 + display: flex; 3967 + } 3968 + 3969 + .topbar .topActions .btn { 3970 + flex: 1; 3971 + } 3972 + } 3973 + 3974 + @media (max-width: 560px) { 3975 + .panel .divider { 3976 + margin: 16px 0; 3977 + } 3978 + } 3979 + 3980 + @supports (padding-bottom: env(safe-area-inset-bottom)) { 3981 + @media (max-width: 768px) { 3982 + .modal { 3983 + padding-bottom: env(safe-area-inset-bottom); 3984 + } 3985 + 3986 + .sidebar { 3987 + padding-bottom: env(safe-area-inset-bottom); 3988 + } 3989 + 3990 + .toast { 3991 + bottom: calc(12px + env(safe-area-inset-bottom)); 3992 + } 3993 + } 3994 + } 3995 + 3996 + .settingsContainer { 3997 + display: flex; 3998 + flex-direction: column; 3999 + gap: 20px; 4000 + padding-top: 16px; 4001 + } 4002 + 4003 + .settingsSection { 4004 + background: var(--code-bg); 4005 + border: 1px solid var(--card-border); 4006 + border-radius: 16px; 4007 + padding: 20px; 4008 + transition: all 0.2s ease; 4009 + } 4010 + 4011 + .settingsSection:hover { 4012 + border-color: var(--glass-border); 4013 + } 4014 + 4015 + .settingsSectionHeader { 4016 + display: flex; 4017 + align-items: flex-start; 4018 + gap: 14px; 4019 + margin-bottom: 20px; 4020 + } 4021 + 4022 + .settingsSectionIcon { 4023 + width: 40px; 4024 + height: 40px; 4025 + display: flex; 4026 + align-items: center; 4027 + justify-content: center; 4028 + background: var(--accent); 4029 + color: white; 4030 + border-radius: 10px; 4031 + flex-shrink: 0; 4032 + } 4033 + 4034 + .settingsSectionTitle { 4035 + font-size: 16px; 4036 + font-weight: 700; 4037 + color: var(--card-text); 4038 + margin-bottom: 2px; 4039 + } 4040 + 4041 + .settingsSectionDesc { 4042 + font-size: 13px; 4043 + color: var(--card-text-light); 4044 + } 4045 + 4046 + .settingsGrid { 4047 + display: grid; 4048 + grid-template-columns: repeat(2, 1fr); 4049 + gap: 16px; 4050 + } 4051 + 4052 + .settingsField { 4053 + display: flex; 4054 + flex-direction: column; 4055 + gap: 6px; 4056 + } 4057 + 4058 + .settingsFieldFull { 4059 + display: flex; 4060 + flex-direction: column; 4061 + gap: 6px; 4062 + margin-bottom: 16px; 4063 + } 4064 + 4065 + .settingsLabel { 4066 + font-size: 13px; 4067 + font-weight: 600; 4068 + color: var(--card-text); 4069 + } 4070 + 4071 + .settingsFieldHint { 4072 + font-size: 11px; 4073 + color: var(--card-text-light); 4074 + margin-top: 2px; 4075 + } 4076 + 4077 + .settingsInputGroup { 4078 + display: flex; 4079 + align-items: stretch; 4080 + width: 100%; 4081 + } 4082 + 4083 + .settingsInputGroup .input { 4084 + border-top-right-radius: 0; 4085 + border-bottom-right-radius: 0; 4086 + flex: 1; 4087 + min-width: 0; 4088 + } 4089 + 4090 + .settingsInputSuffix { 4091 + display: flex; 4092 + align-items: center; 4093 + padding: 0 14px; 4094 + background: var(--card-bg); 4095 + border: 1px solid var(--input-border); 4096 + border-left: 0; 4097 + border-radius: 0 14px 14px 0; 4098 + font-size: 13px; 4099 + color: var(--card-text-light); 4100 + white-space: nowrap; 4101 + flex-shrink: 0; 4102 + } 4103 + 4104 + .settingsActions { 4105 + display: flex; 4106 + justify-content: flex-end; 4107 + padding-top: 4px; 4108 + } 4109 + 4110 + .settingsDanger { 4111 + background: rgba(239, 68, 68, 0.04); 4112 + border-color: rgba(239, 68, 68, 0.15); 4113 + } 4114 + 4115 + .settingsDanger:hover { 4116 + border-color: rgba(239, 68, 68, 0.25); 4117 + } 4118 + 4119 + .settingsDangerIcon { 4120 + background: rgba(239, 68, 68, 0.12) !important; 4121 + color: #dc2626 !important; 4122 + } 4123 + 4124 + .settingsDangerContent { 4125 + display: flex; 4126 + align-items: center; 4127 + justify-content: space-between; 4128 + gap: 20px; 4129 + } 4130 + 4131 + .settingsDangerTitle { 4132 + font-size: 14px; 4133 + font-weight: 600; 4134 + color: var(--card-text); 4135 + margin-bottom: 4px; 4136 + } 4137 + 4138 + .settingsDangerDesc { 4139 + font-size: 13px; 4140 + color: var(--card-text-light); 4141 + max-width: 400px; 4142 + } 4143 + 4144 + [data-theme='dark'] .settingsSection { 4145 + background: color-mix(in srgb, var(--card-bg) 60%, transparent 40%); 4146 + border-color: rgba(255, 255, 255, 0.08); 4147 + } 4148 + 4149 + [data-theme='dark'] .settingsSection:hover { 4150 + border-color: rgba(255, 255, 255, 0.14); 4151 + } 4152 + 4153 + [data-theme='dark'] .settingsSectionIcon { 4154 + background: rgba(244, 162, 149, 0.2); 4155 + color: #f4a295; 4156 + } 4157 + 4158 + [data-theme='dark'] .settingsInputSuffix { 4159 + background: var(--sidebar-bg); 4160 + border-color: var(--input-border); 4161 + } 4162 + 4163 + [data-theme='dark'] .settingsDanger { 4164 + background: rgba(248, 113, 113, 0.06); 4165 + border-color: rgba(248, 113, 113, 0.15); 4166 + } 4167 + 4168 + [data-theme='dark'] .settingsDanger:hover { 4169 + border-color: rgba(248, 113, 113, 0.25); 4170 + } 4171 + 4172 + [data-theme='dark'] .settingsDangerIcon { 4173 + background: rgba(248, 113, 113, 0.15) !important; 4174 + color: #f87171 !important; 4175 + } 4176 + 4177 + @media (max-width: 768px) { 4178 + .settingsGrid { 4179 + grid-template-columns: 1fr; 4180 + } 4181 + 4182 + .settingsDangerContent { 4183 + flex-direction: column; 4184 + align-items: flex-start; 4185 + gap: 16px; 4186 + } 4187 + 4188 + .settingsDangerContent .btn { 4189 + width: 100%; 4190 + } 4191 + 4192 + .settingsDangerDesc { 4193 + max-width: none; 4194 + } 4195 + } 4196 + 4197 + @media (max-width: 560px) { 4198 + .settingsSection { 4199 + padding: 16px; 4200 + border-radius: 12px; 4201 + } 4202 + 4203 + .settingsSectionHeader { 4204 + gap: 12px; 4205 + margin-bottom: 16px; 4206 + } 4207 + 4208 + .settingsSectionIcon { 4209 + width: 36px; 4210 + height: 36px; 4211 + } 4212 + 4213 + .settingsSectionTitle { 4214 + font-size: 15px; 4215 + } 4216 + 4217 + .settingsActions { 4218 + flex-direction: column; 4219 + } 4220 + 4221 + .settingsActions .btn { 4222 + width: 100%; 4223 + } 4224 + }
+33
client/vite.config.js
··· 1 + import { defineConfig } from 'vite'; 2 + import react from '@vitejs/plugin-react'; 3 + import path from 'node:path'; 4 + 5 + export default defineConfig({ 6 + root: path.resolve(process.cwd(), 'client'), 7 + plugins: [react()], 8 + build: { 9 + outDir: path.resolve(process.cwd(), 'client/dist'), 10 + emptyOutDir: true, 11 + 12 + rollupOptions: { 13 + output: { 14 + manualChunks: { 15 + 'vendor-react': ['react', 'react-dom', 'react-router-dom'], 16 + 'vendor-icons': ['lucide-react'] 17 + } 18 + } 19 + }, 20 + 21 + minify: 'esbuild', 22 + 23 + target: 'es2020', 24 + 25 + sourcemap: false, 26 + 27 + cssCodeSplit: true 28 + }, 29 + 30 + optimizeDeps: { 31 + include: ['react', 'react-dom', 'react-router-dom', 'lucide-react'] 32 + } 33 + });
+183
edge/worker.js
··· 1 + const ASSET_EXTENSIONS = 2 + /\.(js|mjs|css|png|jpg|jpeg|webp|avif|svg|gif|ico|woff|woff2|ttf|otf|eot|map|json|xml|txt|pdf|mp4|webm|mp3|wav)$/i; 3 + 4 + function parseSubdomain(hostname, rootDomain) { 5 + if (!rootDomain) return null; 6 + const h = hostname.toLowerCase(); 7 + const root = rootDomain.toLowerCase(); 8 + if (h === root || !h.endsWith(`.${root}`)) return null; 9 + const sub = h.slice(0, -(root.length + 1)); 10 + return !sub || sub.includes('.') ? null : sub; 11 + } 12 + 13 + function isAssetPath(pathname) { 14 + return pathname.startsWith('/assets/') || ASSET_EXTENSIONS.test(pathname); 15 + } 16 + 17 + function getCacheControl(pathname) { 18 + if (pathname === '/' || pathname.endsWith('.html')) { 19 + return 'public, max-age=60, s-maxage=60'; 20 + } 21 + 22 + if (pathname.startsWith('/assets/') || /\.[a-f0-9]{8,}\.(js|css)$/i.test(pathname)) { 23 + return 'public, max-age=31536000, immutable'; 24 + } 25 + 26 + if (ASSET_EXTENSIONS.test(pathname)) { 27 + return 'public, max-age=86400, s-maxage=604800'; 28 + } 29 + 30 + return 'public, max-age=300, s-maxage=3600'; 31 + } 32 + 33 + function stripFirstSegment(pathname) { 34 + const parts = pathname.split('/').filter(Boolean); 35 + return parts.length > 1 ? `/${parts.slice(1).join('/')}` : pathname; 36 + } 37 + 38 + let b2AuthCache = { token: null, downloadUrl: null, expiresAt: 0 }; 39 + 40 + async function ensureB2Auth(env) { 41 + const now = Date.now(); 42 + if (b2AuthCache.token && now < b2AuthCache.expiresAt) return b2AuthCache; 43 + 44 + if (!env.B2_KEY_ID || !env.B2_APP_KEY) { 45 + throw new Error('missing-b2-credentials'); 46 + } 47 + 48 + const res = await fetch('https://api.backblazeb2.com/b2api/v2/b2_authorize_account', { 49 + headers: { 50 + authorization: `Basic ${btoa(`${env.B2_KEY_ID}:${env.B2_APP_KEY}`)}` 51 + } 52 + }); 53 + 54 + if (!res.ok) { 55 + const t = await res.text().catch(() => ''); 56 + throw new Error(`b2_authorize_account failed (${res.status}): ${t}`); 57 + } 58 + const data = await res.json(); 59 + b2AuthCache = { 60 + token: data.authorizationToken, 61 + downloadUrl: String(data.downloadUrl || '').replace(/\/$/, ''), 62 + expiresAt: now + 1000 * 60 * 60 * 12 63 + }; 64 + return b2AuthCache; 65 + } 66 + 67 + async function fetchFromB2({ base, bucket, objectKey, acceptEncoding, authToken }) { 68 + const url = `${base}/file/${bucket}/${objectKey}`; 69 + const headers = new Headers(); 70 + if (acceptEncoding) headers.set('accept-encoding', acceptEncoding); 71 + if (authToken) headers.set('authorization', authToken); 72 + return fetch(url, { headers }); 73 + } 74 + 75 + export default { 76 + async fetch(request, env, ctx) { 77 + const url = new URL(request.url); 78 + const hostname = (request.headers.get('x-forwarded-host') || url.hostname).toLowerCase(); 79 + const { ROOT_DOMAIN, B2_DOWNLOAD_BASE, B2_BUCKET_NAME, B2_KEY_ID, B2_APP_KEY, ROUTING } = env; 80 + 81 + if (!B2_DOWNLOAD_BASE || !B2_BUCKET_NAME) { 82 + return new Response('Service misconfigured', { status: 500 }); 83 + } 84 + 85 + let siteId = await ROUTING.get(`host:${hostname}`); 86 + if (!siteId) { 87 + const sub = parseSubdomain(hostname, ROOT_DOMAIN); 88 + if (sub) siteId = await ROUTING.get(`host:${sub}`); 89 + } 90 + if (!siteId) { 91 + return new Response('Site not found', { status: 404 }); 92 + } 93 + 94 + const deployId = await ROUTING.get(`current:${siteId}`); 95 + if (!deployId) { 96 + return new Response('No deployment found', { status: 404 }); 97 + } 98 + 99 + const pathname = url.pathname; 100 + const keyPath = pathname.replace(/^\//, '') || 'index.html'; 101 + const basePath = `sites/${siteId}/${deployId}`; 102 + const acceptEncoding = request.headers.get('accept-encoding'); 103 + 104 + let authToken = null; 105 + let base = (B2_DOWNLOAD_BASE || '').replace(/\/$/, ''); 106 + 107 + if (B2_KEY_ID && B2_APP_KEY) { 108 + const auth = await ensureB2Auth(env); 109 + authToken = auth.token; 110 + base = auth.downloadUrl; 111 + } 112 + 113 + let res = await fetchFromB2({ 114 + base, 115 + bucket: B2_BUCKET_NAME, 116 + objectKey: `${basePath}/${keyPath}`, 117 + acceptEncoding, 118 + authToken 119 + }); 120 + 121 + if (res.status === 404 && isAssetPath(pathname)) { 122 + const rewritten = stripFirstSegment(pathname); 123 + if (rewritten !== pathname) { 124 + const rewrittenKey = rewritten.replace(/^\//, ''); 125 + res = await fetchFromB2({ 126 + base, 127 + bucket: B2_BUCKET_NAME, 128 + objectKey: `${basePath}/${rewrittenKey}`, 129 + acceptEncoding, 130 + authToken 131 + }); 132 + } 133 + } 134 + 135 + if (res.status === 404 && !isAssetPath(pathname)) { 136 + const dirPath = pathname.endsWith('/') ? pathname : `${pathname}/`; 137 + const dirKey = `${keyPath.replace(/\/$/, '')}/index.html`; 138 + 139 + const dirRes = await fetchFromB2({ 140 + base, 141 + bucket: B2_BUCKET_NAME, 142 + objectKey: `${basePath}/${dirKey}`, 143 + acceptEncoding, 144 + authToken 145 + }); 146 + 147 + if (dirRes.ok) { 148 + res = dirRes; 149 + } 150 + } 151 + 152 + if (res.status === 404 && !isAssetPath(pathname)) { 153 + res = await fetchFromB2({ 154 + base, 155 + bucket: B2_BUCKET_NAME, 156 + objectKey: `${basePath}/index.html`, 157 + acceptEncoding, 158 + authToken 159 + }); 160 + } 161 + 162 + if (!res.ok) { 163 + return new Response('Not found', { status: 404 }); 164 + } 165 + 166 + const headers = new Headers(res.headers); 167 + headers.set('cache-control', getCacheControl(pathname)); 168 + headers.set('x-content-type-options', 'nosniff'); 169 + 170 + headers.set('server', 'boop.cat'); 171 + headers.set('x-boop-host', 'boop.cat'); 172 + headers.set('x-boop-site-id', siteId); 173 + headers.set('x-boop-deploy-id', deployId); 174 + 175 + headers.delete('x-bz-file-id'); 176 + headers.delete('x-bz-file-name'); 177 + headers.delete('x-bz-content-sha1'); 178 + headers.delete('x-bz-upload-timestamp'); 179 + headers.delete('x-bz-info-src_last_modified_millis'); 180 + 181 + return new Response(res.body, { status: 200, headers }); 182 + } 183 + };
+27
edge/wrangler.toml
··· 1 + name = "boop-cat-sites" 2 + main = "worker.js" 3 + compatibility_date = "2025-12-18" 4 + 5 + # Enable workers.dev subdomain (needed for custom domain fallback origin) 6 + workers_dev = true 7 + 8 + # Handle *.boop.cat subdomains 9 + # Custom domains use Cloudflare for SaaS and route through sites.boop.cat fallback 10 + routes = [ 11 + { pattern = "*.boop.cat/*", zone_name = "boop.cat" }, 12 + { pattern = "sites.boop.cat", custom_domain = true } 13 + ] 14 + 15 + # Custom domain support - the fallback origin (sites.boop.cat) should CNAME to this worker 16 + kv_namespaces = [ 17 + { binding = "ROUTING", id = "3ba9d4ae9dcc431d84061e77c73b8fe7" } 18 + ] 19 + 20 + [vars] 21 + ROOT_DOMAIN = "boop.cat" 22 + B2_DOWNLOAD_BASE = "https://f004.backblazeb2.com" 23 + B2_BUCKET_NAME = "scan-blue-sites" 24 + 25 + # Optimize for performance 26 + [placement] 27 + mode = "smart"
+1494
package-lock.json
··· 1 + { 2 + "name": "free-static-host", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "free-static-host", 9 + "version": "0.1.0", 10 + "license": "MIT", 11 + "dependencies": { 12 + "@vitejs/plugin-react": "^4.3.4", 13 + "blueimp-md5": "^2.19.0", 14 + "lucide-react": "^0.561.0", 15 + "react": "^18.3.1", 16 + "react-dom": "^18.3.1", 17 + "react-router-dom": "^6.28.0", 18 + "vite": "^5.4.11" 19 + }, 20 + "devDependencies": { 21 + "prettier": "^3.7.4" 22 + } 23 + }, 24 + "node_modules/@babel/code-frame": { 25 + "version": "7.27.1", 26 + "license": "MIT", 27 + "dependencies": { 28 + "@babel/helper-validator-identifier": "^7.27.1", 29 + "js-tokens": "^4.0.0", 30 + "picocolors": "^1.1.1" 31 + }, 32 + "engines": { 33 + "node": ">=6.9.0" 34 + } 35 + }, 36 + "node_modules/@babel/compat-data": { 37 + "version": "7.28.5", 38 + "license": "MIT", 39 + "engines": { 40 + "node": ">=6.9.0" 41 + } 42 + }, 43 + "node_modules/@babel/core": { 44 + "version": "7.28.5", 45 + "license": "MIT", 46 + "peer": true, 47 + "dependencies": { 48 + "@babel/code-frame": "^7.27.1", 49 + "@babel/generator": "^7.28.5", 50 + "@babel/helper-compilation-targets": "^7.27.2", 51 + "@babel/helper-module-transforms": "^7.28.3", 52 + "@babel/helpers": "^7.28.4", 53 + "@babel/parser": "^7.28.5", 54 + "@babel/template": "^7.27.2", 55 + "@babel/traverse": "^7.28.5", 56 + "@babel/types": "^7.28.5", 57 + "@jridgewell/remapping": "^2.3.5", 58 + "convert-source-map": "^2.0.0", 59 + "debug": "^4.1.0", 60 + "gensync": "^1.0.0-beta.2", 61 + "json5": "^2.2.3", 62 + "semver": "^6.3.1" 63 + }, 64 + "engines": { 65 + "node": ">=6.9.0" 66 + }, 67 + "funding": { 68 + "type": "opencollective", 69 + "url": "https://opencollective.com/babel" 70 + } 71 + }, 72 + "node_modules/@babel/core/node_modules/debug": { 73 + "version": "4.4.3", 74 + "license": "MIT", 75 + "dependencies": { 76 + "ms": "^2.1.3" 77 + }, 78 + "engines": { 79 + "node": ">=6.0" 80 + }, 81 + "peerDependenciesMeta": { 82 + "supports-color": { 83 + "optional": true 84 + } 85 + } 86 + }, 87 + "node_modules/@babel/core/node_modules/debug/node_modules/ms": { 88 + "version": "2.1.3", 89 + "license": "MIT" 90 + }, 91 + "node_modules/@babel/generator": { 92 + "version": "7.28.5", 93 + "license": "MIT", 94 + "dependencies": { 95 + "@babel/parser": "^7.28.5", 96 + "@babel/types": "^7.28.5", 97 + "@jridgewell/gen-mapping": "^0.3.12", 98 + "@jridgewell/trace-mapping": "^0.3.28", 99 + "jsesc": "^3.0.2" 100 + }, 101 + "engines": { 102 + "node": ">=6.9.0" 103 + } 104 + }, 105 + "node_modules/@babel/helper-compilation-targets": { 106 + "version": "7.27.2", 107 + "license": "MIT", 108 + "dependencies": { 109 + "@babel/compat-data": "^7.27.2", 110 + "@babel/helper-validator-option": "^7.27.1", 111 + "browserslist": "^4.24.0", 112 + "lru-cache": "^5.1.1", 113 + "semver": "^6.3.1" 114 + }, 115 + "engines": { 116 + "node": ">=6.9.0" 117 + } 118 + }, 119 + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { 120 + "version": "5.1.1", 121 + "license": "ISC", 122 + "dependencies": { 123 + "yallist": "^3.0.2" 124 + } 125 + }, 126 + "node_modules/@babel/helper-globals": { 127 + "version": "7.28.0", 128 + "license": "MIT", 129 + "engines": { 130 + "node": ">=6.9.0" 131 + } 132 + }, 133 + "node_modules/@babel/helper-module-imports": { 134 + "version": "7.27.1", 135 + "license": "MIT", 136 + "dependencies": { 137 + "@babel/traverse": "^7.27.1", 138 + "@babel/types": "^7.27.1" 139 + }, 140 + "engines": { 141 + "node": ">=6.9.0" 142 + } 143 + }, 144 + "node_modules/@babel/helper-module-transforms": { 145 + "version": "7.28.3", 146 + "license": "MIT", 147 + "dependencies": { 148 + "@babel/helper-module-imports": "^7.27.1", 149 + "@babel/helper-validator-identifier": "^7.27.1", 150 + "@babel/traverse": "^7.28.3" 151 + }, 152 + "engines": { 153 + "node": ">=6.9.0" 154 + }, 155 + "peerDependencies": { 156 + "@babel/core": "^7.0.0" 157 + } 158 + }, 159 + "node_modules/@babel/helper-plugin-utils": { 160 + "version": "7.27.1", 161 + "license": "MIT", 162 + "engines": { 163 + "node": ">=6.9.0" 164 + } 165 + }, 166 + "node_modules/@babel/helper-string-parser": { 167 + "version": "7.27.1", 168 + "license": "MIT", 169 + "engines": { 170 + "node": ">=6.9.0" 171 + } 172 + }, 173 + "node_modules/@babel/helper-validator-identifier": { 174 + "version": "7.28.5", 175 + "license": "MIT", 176 + "engines": { 177 + "node": ">=6.9.0" 178 + } 179 + }, 180 + "node_modules/@babel/helper-validator-option": { 181 + "version": "7.27.1", 182 + "license": "MIT", 183 + "engines": { 184 + "node": ">=6.9.0" 185 + } 186 + }, 187 + "node_modules/@babel/helpers": { 188 + "version": "7.28.4", 189 + "license": "MIT", 190 + "dependencies": { 191 + "@babel/template": "^7.27.2", 192 + "@babel/types": "^7.28.4" 193 + }, 194 + "engines": { 195 + "node": ">=6.9.0" 196 + } 197 + }, 198 + "node_modules/@babel/parser": { 199 + "version": "7.28.5", 200 + "license": "MIT", 201 + "dependencies": { 202 + "@babel/types": "^7.28.5" 203 + }, 204 + "bin": { 205 + "parser": "bin/babel-parser.js" 206 + }, 207 + "engines": { 208 + "node": ">=6.0.0" 209 + } 210 + }, 211 + "node_modules/@babel/plugin-transform-react-jsx-self": { 212 + "version": "7.27.1", 213 + "license": "MIT", 214 + "dependencies": { 215 + "@babel/helper-plugin-utils": "^7.27.1" 216 + }, 217 + "engines": { 218 + "node": ">=6.9.0" 219 + }, 220 + "peerDependencies": { 221 + "@babel/core": "^7.0.0-0" 222 + } 223 + }, 224 + "node_modules/@babel/plugin-transform-react-jsx-source": { 225 + "version": "7.27.1", 226 + "license": "MIT", 227 + "dependencies": { 228 + "@babel/helper-plugin-utils": "^7.27.1" 229 + }, 230 + "engines": { 231 + "node": ">=6.9.0" 232 + }, 233 + "peerDependencies": { 234 + "@babel/core": "^7.0.0-0" 235 + } 236 + }, 237 + "node_modules/@babel/template": { 238 + "version": "7.27.2", 239 + "license": "MIT", 240 + "dependencies": { 241 + "@babel/code-frame": "^7.27.1", 242 + "@babel/parser": "^7.27.2", 243 + "@babel/types": "^7.27.1" 244 + }, 245 + "engines": { 246 + "node": ">=6.9.0" 247 + } 248 + }, 249 + "node_modules/@babel/traverse": { 250 + "version": "7.28.5", 251 + "license": "MIT", 252 + "dependencies": { 253 + "@babel/code-frame": "^7.27.1", 254 + "@babel/generator": "^7.28.5", 255 + "@babel/helper-globals": "^7.28.0", 256 + "@babel/parser": "^7.28.5", 257 + "@babel/template": "^7.27.2", 258 + "@babel/types": "^7.28.5", 259 + "debug": "^4.3.1" 260 + }, 261 + "engines": { 262 + "node": ">=6.9.0" 263 + } 264 + }, 265 + "node_modules/@babel/traverse/node_modules/debug": { 266 + "version": "4.4.3", 267 + "license": "MIT", 268 + "dependencies": { 269 + "ms": "^2.1.3" 270 + }, 271 + "engines": { 272 + "node": ">=6.0" 273 + }, 274 + "peerDependenciesMeta": { 275 + "supports-color": { 276 + "optional": true 277 + } 278 + } 279 + }, 280 + "node_modules/@babel/traverse/node_modules/debug/node_modules/ms": { 281 + "version": "2.1.3", 282 + "license": "MIT" 283 + }, 284 + "node_modules/@babel/types": { 285 + "version": "7.28.5", 286 + "license": "MIT", 287 + "dependencies": { 288 + "@babel/helper-string-parser": "^7.27.1", 289 + "@babel/helper-validator-identifier": "^7.28.5" 290 + }, 291 + "engines": { 292 + "node": ">=6.9.0" 293 + } 294 + }, 295 + "node_modules/@esbuild/aix-ppc64": { 296 + "version": "0.21.5", 297 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", 298 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", 299 + "cpu": [ 300 + "ppc64" 301 + ], 302 + "license": "MIT", 303 + "optional": true, 304 + "os": [ 305 + "aix" 306 + ], 307 + "engines": { 308 + "node": ">=12" 309 + } 310 + }, 311 + "node_modules/@esbuild/android-arm": { 312 + "version": "0.21.5", 313 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", 314 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", 315 + "cpu": [ 316 + "arm" 317 + ], 318 + "license": "MIT", 319 + "optional": true, 320 + "os": [ 321 + "android" 322 + ], 323 + "engines": { 324 + "node": ">=12" 325 + } 326 + }, 327 + "node_modules/@esbuild/android-arm64": { 328 + "version": "0.21.5", 329 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", 330 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", 331 + "cpu": [ 332 + "arm64" 333 + ], 334 + "license": "MIT", 335 + "optional": true, 336 + "os": [ 337 + "android" 338 + ], 339 + "engines": { 340 + "node": ">=12" 341 + } 342 + }, 343 + "node_modules/@esbuild/android-x64": { 344 + "version": "0.21.5", 345 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", 346 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", 347 + "cpu": [ 348 + "x64" 349 + ], 350 + "license": "MIT", 351 + "optional": true, 352 + "os": [ 353 + "android" 354 + ], 355 + "engines": { 356 + "node": ">=12" 357 + } 358 + }, 359 + "node_modules/@esbuild/darwin-arm64": { 360 + "version": "0.21.5", 361 + "cpu": [ 362 + "arm64" 363 + ], 364 + "license": "MIT", 365 + "optional": true, 366 + "os": [ 367 + "darwin" 368 + ], 369 + "engines": { 370 + "node": ">=12" 371 + } 372 + }, 373 + "node_modules/@esbuild/darwin-x64": { 374 + "version": "0.21.5", 375 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", 376 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", 377 + "cpu": [ 378 + "x64" 379 + ], 380 + "license": "MIT", 381 + "optional": true, 382 + "os": [ 383 + "darwin" 384 + ], 385 + "engines": { 386 + "node": ">=12" 387 + } 388 + }, 389 + "node_modules/@esbuild/freebsd-arm64": { 390 + "version": "0.21.5", 391 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", 392 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", 393 + "cpu": [ 394 + "arm64" 395 + ], 396 + "license": "MIT", 397 + "optional": true, 398 + "os": [ 399 + "freebsd" 400 + ], 401 + "engines": { 402 + "node": ">=12" 403 + } 404 + }, 405 + "node_modules/@esbuild/freebsd-x64": { 406 + "version": "0.21.5", 407 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", 408 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", 409 + "cpu": [ 410 + "x64" 411 + ], 412 + "license": "MIT", 413 + "optional": true, 414 + "os": [ 415 + "freebsd" 416 + ], 417 + "engines": { 418 + "node": ">=12" 419 + } 420 + }, 421 + "node_modules/@esbuild/linux-arm": { 422 + "version": "0.21.5", 423 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", 424 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", 425 + "cpu": [ 426 + "arm" 427 + ], 428 + "license": "MIT", 429 + "optional": true, 430 + "os": [ 431 + "linux" 432 + ], 433 + "engines": { 434 + "node": ">=12" 435 + } 436 + }, 437 + "node_modules/@esbuild/linux-arm64": { 438 + "version": "0.21.5", 439 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", 440 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", 441 + "cpu": [ 442 + "arm64" 443 + ], 444 + "license": "MIT", 445 + "optional": true, 446 + "os": [ 447 + "linux" 448 + ], 449 + "engines": { 450 + "node": ">=12" 451 + } 452 + }, 453 + "node_modules/@esbuild/linux-ia32": { 454 + "version": "0.21.5", 455 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", 456 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", 457 + "cpu": [ 458 + "ia32" 459 + ], 460 + "license": "MIT", 461 + "optional": true, 462 + "os": [ 463 + "linux" 464 + ], 465 + "engines": { 466 + "node": ">=12" 467 + } 468 + }, 469 + "node_modules/@esbuild/linux-loong64": { 470 + "version": "0.21.5", 471 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", 472 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", 473 + "cpu": [ 474 + "loong64" 475 + ], 476 + "license": "MIT", 477 + "optional": true, 478 + "os": [ 479 + "linux" 480 + ], 481 + "engines": { 482 + "node": ">=12" 483 + } 484 + }, 485 + "node_modules/@esbuild/linux-mips64el": { 486 + "version": "0.21.5", 487 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", 488 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", 489 + "cpu": [ 490 + "mips64el" 491 + ], 492 + "license": "MIT", 493 + "optional": true, 494 + "os": [ 495 + "linux" 496 + ], 497 + "engines": { 498 + "node": ">=12" 499 + } 500 + }, 501 + "node_modules/@esbuild/linux-ppc64": { 502 + "version": "0.21.5", 503 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", 504 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", 505 + "cpu": [ 506 + "ppc64" 507 + ], 508 + "license": "MIT", 509 + "optional": true, 510 + "os": [ 511 + "linux" 512 + ], 513 + "engines": { 514 + "node": ">=12" 515 + } 516 + }, 517 + "node_modules/@esbuild/linux-riscv64": { 518 + "version": "0.21.5", 519 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", 520 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", 521 + "cpu": [ 522 + "riscv64" 523 + ], 524 + "license": "MIT", 525 + "optional": true, 526 + "os": [ 527 + "linux" 528 + ], 529 + "engines": { 530 + "node": ">=12" 531 + } 532 + }, 533 + "node_modules/@esbuild/linux-s390x": { 534 + "version": "0.21.5", 535 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", 536 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", 537 + "cpu": [ 538 + "s390x" 539 + ], 540 + "license": "MIT", 541 + "optional": true, 542 + "os": [ 543 + "linux" 544 + ], 545 + "engines": { 546 + "node": ">=12" 547 + } 548 + }, 549 + "node_modules/@esbuild/linux-x64": { 550 + "version": "0.21.5", 551 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", 552 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", 553 + "cpu": [ 554 + "x64" 555 + ], 556 + "license": "MIT", 557 + "optional": true, 558 + "os": [ 559 + "linux" 560 + ], 561 + "engines": { 562 + "node": ">=12" 563 + } 564 + }, 565 + "node_modules/@esbuild/netbsd-x64": { 566 + "version": "0.21.5", 567 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", 568 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", 569 + "cpu": [ 570 + "x64" 571 + ], 572 + "license": "MIT", 573 + "optional": true, 574 + "os": [ 575 + "netbsd" 576 + ], 577 + "engines": { 578 + "node": ">=12" 579 + } 580 + }, 581 + "node_modules/@esbuild/openbsd-x64": { 582 + "version": "0.21.5", 583 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", 584 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", 585 + "cpu": [ 586 + "x64" 587 + ], 588 + "license": "MIT", 589 + "optional": true, 590 + "os": [ 591 + "openbsd" 592 + ], 593 + "engines": { 594 + "node": ">=12" 595 + } 596 + }, 597 + "node_modules/@esbuild/sunos-x64": { 598 + "version": "0.21.5", 599 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", 600 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", 601 + "cpu": [ 602 + "x64" 603 + ], 604 + "license": "MIT", 605 + "optional": true, 606 + "os": [ 607 + "sunos" 608 + ], 609 + "engines": { 610 + "node": ">=12" 611 + } 612 + }, 613 + "node_modules/@esbuild/win32-arm64": { 614 + "version": "0.21.5", 615 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", 616 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", 617 + "cpu": [ 618 + "arm64" 619 + ], 620 + "license": "MIT", 621 + "optional": true, 622 + "os": [ 623 + "win32" 624 + ], 625 + "engines": { 626 + "node": ">=12" 627 + } 628 + }, 629 + "node_modules/@esbuild/win32-ia32": { 630 + "version": "0.21.5", 631 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", 632 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", 633 + "cpu": [ 634 + "ia32" 635 + ], 636 + "license": "MIT", 637 + "optional": true, 638 + "os": [ 639 + "win32" 640 + ], 641 + "engines": { 642 + "node": ">=12" 643 + } 644 + }, 645 + "node_modules/@esbuild/win32-x64": { 646 + "version": "0.21.5", 647 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", 648 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", 649 + "cpu": [ 650 + "x64" 651 + ], 652 + "license": "MIT", 653 + "optional": true, 654 + "os": [ 655 + "win32" 656 + ], 657 + "engines": { 658 + "node": ">=12" 659 + } 660 + }, 661 + "node_modules/@jridgewell/gen-mapping": { 662 + "version": "0.3.13", 663 + "license": "MIT", 664 + "dependencies": { 665 + "@jridgewell/sourcemap-codec": "^1.5.0", 666 + "@jridgewell/trace-mapping": "^0.3.24" 667 + } 668 + }, 669 + "node_modules/@jridgewell/remapping": { 670 + "version": "2.3.5", 671 + "license": "MIT", 672 + "dependencies": { 673 + "@jridgewell/gen-mapping": "^0.3.5", 674 + "@jridgewell/trace-mapping": "^0.3.24" 675 + } 676 + }, 677 + "node_modules/@jridgewell/resolve-uri": { 678 + "version": "3.1.2", 679 + "license": "MIT", 680 + "engines": { 681 + "node": ">=6.0.0" 682 + } 683 + }, 684 + "node_modules/@jridgewell/sourcemap-codec": { 685 + "version": "1.5.5", 686 + "license": "MIT" 687 + }, 688 + "node_modules/@jridgewell/trace-mapping": { 689 + "version": "0.3.31", 690 + "license": "MIT", 691 + "dependencies": { 692 + "@jridgewell/resolve-uri": "^3.1.0", 693 + "@jridgewell/sourcemap-codec": "^1.4.14" 694 + } 695 + }, 696 + "node_modules/@remix-run/router": { 697 + "version": "1.23.1", 698 + "license": "MIT", 699 + "engines": { 700 + "node": ">=14.0.0" 701 + } 702 + }, 703 + "node_modules/@rolldown/pluginutils": { 704 + "version": "1.0.0-beta.27", 705 + "license": "MIT" 706 + }, 707 + "node_modules/@rollup/rollup-android-arm-eabi": { 708 + "version": "4.53.5", 709 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", 710 + "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", 711 + "cpu": [ 712 + "arm" 713 + ], 714 + "license": "MIT", 715 + "optional": true, 716 + "os": [ 717 + "android" 718 + ] 719 + }, 720 + "node_modules/@rollup/rollup-android-arm64": { 721 + "version": "4.53.5", 722 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", 723 + "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", 724 + "cpu": [ 725 + "arm64" 726 + ], 727 + "license": "MIT", 728 + "optional": true, 729 + "os": [ 730 + "android" 731 + ] 732 + }, 733 + "node_modules/@rollup/rollup-darwin-arm64": { 734 + "version": "4.53.5", 735 + "cpu": [ 736 + "arm64" 737 + ], 738 + "license": "MIT", 739 + "optional": true, 740 + "os": [ 741 + "darwin" 742 + ] 743 + }, 744 + "node_modules/@rollup/rollup-darwin-x64": { 745 + "version": "4.53.5", 746 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", 747 + "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", 748 + "cpu": [ 749 + "x64" 750 + ], 751 + "license": "MIT", 752 + "optional": true, 753 + "os": [ 754 + "darwin" 755 + ] 756 + }, 757 + "node_modules/@rollup/rollup-freebsd-arm64": { 758 + "version": "4.53.5", 759 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", 760 + "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", 761 + "cpu": [ 762 + "arm64" 763 + ], 764 + "license": "MIT", 765 + "optional": true, 766 + "os": [ 767 + "freebsd" 768 + ] 769 + }, 770 + "node_modules/@rollup/rollup-freebsd-x64": { 771 + "version": "4.53.5", 772 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", 773 + "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", 774 + "cpu": [ 775 + "x64" 776 + ], 777 + "license": "MIT", 778 + "optional": true, 779 + "os": [ 780 + "freebsd" 781 + ] 782 + }, 783 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 784 + "version": "4.53.5", 785 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", 786 + "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", 787 + "cpu": [ 788 + "arm" 789 + ], 790 + "license": "MIT", 791 + "optional": true, 792 + "os": [ 793 + "linux" 794 + ] 795 + }, 796 + "node_modules/@rollup/rollup-linux-arm-musleabihf": { 797 + "version": "4.53.5", 798 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", 799 + "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", 800 + "cpu": [ 801 + "arm" 802 + ], 803 + "license": "MIT", 804 + "optional": true, 805 + "os": [ 806 + "linux" 807 + ] 808 + }, 809 + "node_modules/@rollup/rollup-linux-arm64-gnu": { 810 + "version": "4.53.5", 811 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", 812 + "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", 813 + "cpu": [ 814 + "arm64" 815 + ], 816 + "license": "MIT", 817 + "optional": true, 818 + "os": [ 819 + "linux" 820 + ] 821 + }, 822 + "node_modules/@rollup/rollup-linux-arm64-musl": { 823 + "version": "4.53.5", 824 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", 825 + "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", 826 + "cpu": [ 827 + "arm64" 828 + ], 829 + "license": "MIT", 830 + "optional": true, 831 + "os": [ 832 + "linux" 833 + ] 834 + }, 835 + "node_modules/@rollup/rollup-linux-loong64-gnu": { 836 + "version": "4.53.5", 837 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", 838 + "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", 839 + "cpu": [ 840 + "loong64" 841 + ], 842 + "license": "MIT", 843 + "optional": true, 844 + "os": [ 845 + "linux" 846 + ] 847 + }, 848 + "node_modules/@rollup/rollup-linux-ppc64-gnu": { 849 + "version": "4.53.5", 850 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", 851 + "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", 852 + "cpu": [ 853 + "ppc64" 854 + ], 855 + "license": "MIT", 856 + "optional": true, 857 + "os": [ 858 + "linux" 859 + ] 860 + }, 861 + "node_modules/@rollup/rollup-linux-riscv64-gnu": { 862 + "version": "4.53.5", 863 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", 864 + "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", 865 + "cpu": [ 866 + "riscv64" 867 + ], 868 + "license": "MIT", 869 + "optional": true, 870 + "os": [ 871 + "linux" 872 + ] 873 + }, 874 + "node_modules/@rollup/rollup-linux-riscv64-musl": { 875 + "version": "4.53.5", 876 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", 877 + "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", 878 + "cpu": [ 879 + "riscv64" 880 + ], 881 + "license": "MIT", 882 + "optional": true, 883 + "os": [ 884 + "linux" 885 + ] 886 + }, 887 + "node_modules/@rollup/rollup-linux-s390x-gnu": { 888 + "version": "4.53.5", 889 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", 890 + "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", 891 + "cpu": [ 892 + "s390x" 893 + ], 894 + "license": "MIT", 895 + "optional": true, 896 + "os": [ 897 + "linux" 898 + ] 899 + }, 900 + "node_modules/@rollup/rollup-linux-x64-gnu": { 901 + "version": "4.53.5", 902 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", 903 + "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", 904 + "cpu": [ 905 + "x64" 906 + ], 907 + "license": "MIT", 908 + "optional": true, 909 + "os": [ 910 + "linux" 911 + ] 912 + }, 913 + "node_modules/@rollup/rollup-linux-x64-musl": { 914 + "version": "4.53.5", 915 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", 916 + "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", 917 + "cpu": [ 918 + "x64" 919 + ], 920 + "license": "MIT", 921 + "optional": true, 922 + "os": [ 923 + "linux" 924 + ] 925 + }, 926 + "node_modules/@rollup/rollup-openharmony-arm64": { 927 + "version": "4.53.5", 928 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", 929 + "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", 930 + "cpu": [ 931 + "arm64" 932 + ], 933 + "license": "MIT", 934 + "optional": true, 935 + "os": [ 936 + "openharmony" 937 + ] 938 + }, 939 + "node_modules/@rollup/rollup-win32-arm64-msvc": { 940 + "version": "4.53.5", 941 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", 942 + "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", 943 + "cpu": [ 944 + "arm64" 945 + ], 946 + "license": "MIT", 947 + "optional": true, 948 + "os": [ 949 + "win32" 950 + ] 951 + }, 952 + "node_modules/@rollup/rollup-win32-ia32-msvc": { 953 + "version": "4.53.5", 954 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", 955 + "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", 956 + "cpu": [ 957 + "ia32" 958 + ], 959 + "license": "MIT", 960 + "optional": true, 961 + "os": [ 962 + "win32" 963 + ] 964 + }, 965 + "node_modules/@rollup/rollup-win32-x64-gnu": { 966 + "version": "4.53.5", 967 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", 968 + "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", 969 + "cpu": [ 970 + "x64" 971 + ], 972 + "license": "MIT", 973 + "optional": true, 974 + "os": [ 975 + "win32" 976 + ] 977 + }, 978 + "node_modules/@rollup/rollup-win32-x64-msvc": { 979 + "version": "4.53.5", 980 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", 981 + "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", 982 + "cpu": [ 983 + "x64" 984 + ], 985 + "license": "MIT", 986 + "optional": true, 987 + "os": [ 988 + "win32" 989 + ] 990 + }, 991 + "node_modules/@types/babel__core": { 992 + "version": "7.20.5", 993 + "license": "MIT", 994 + "dependencies": { 995 + "@babel/parser": "^7.20.7", 996 + "@babel/types": "^7.20.7", 997 + "@types/babel__generator": "*", 998 + "@types/babel__template": "*", 999 + "@types/babel__traverse": "*" 1000 + } 1001 + }, 1002 + "node_modules/@types/babel__generator": { 1003 + "version": "7.27.0", 1004 + "license": "MIT", 1005 + "dependencies": { 1006 + "@babel/types": "^7.0.0" 1007 + } 1008 + }, 1009 + "node_modules/@types/babel__template": { 1010 + "version": "7.4.4", 1011 + "license": "MIT", 1012 + "dependencies": { 1013 + "@babel/parser": "^7.1.0", 1014 + "@babel/types": "^7.0.0" 1015 + } 1016 + }, 1017 + "node_modules/@types/babel__traverse": { 1018 + "version": "7.28.0", 1019 + "license": "MIT", 1020 + "dependencies": { 1021 + "@babel/types": "^7.28.2" 1022 + } 1023 + }, 1024 + "node_modules/@types/estree": { 1025 + "version": "1.0.8", 1026 + "license": "MIT" 1027 + }, 1028 + "node_modules/@vitejs/plugin-react": { 1029 + "version": "4.7.0", 1030 + "license": "MIT", 1031 + "dependencies": { 1032 + "@babel/core": "^7.28.0", 1033 + "@babel/plugin-transform-react-jsx-self": "^7.27.1", 1034 + "@babel/plugin-transform-react-jsx-source": "^7.27.1", 1035 + "@rolldown/pluginutils": "1.0.0-beta.27", 1036 + "@types/babel__core": "^7.20.5", 1037 + "react-refresh": "^0.17.0" 1038 + }, 1039 + "engines": { 1040 + "node": "^14.18.0 || >=16.0.0" 1041 + }, 1042 + "peerDependencies": { 1043 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" 1044 + } 1045 + }, 1046 + "node_modules/baseline-browser-mapping": { 1047 + "version": "2.9.9", 1048 + "license": "Apache-2.0", 1049 + "bin": { 1050 + "baseline-browser-mapping": "dist/cli.js" 1051 + } 1052 + }, 1053 + "node_modules/blueimp-md5": { 1054 + "version": "2.19.0", 1055 + "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", 1056 + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", 1057 + "license": "MIT" 1058 + }, 1059 + "node_modules/browserslist": { 1060 + "version": "4.28.1", 1061 + "funding": [ 1062 + { 1063 + "type": "opencollective", 1064 + "url": "https://opencollective.com/browserslist" 1065 + }, 1066 + { 1067 + "type": "tidelift", 1068 + "url": "https://tidelift.com/funding/github/npm/browserslist" 1069 + }, 1070 + { 1071 + "type": "github", 1072 + "url": "https://github.com/sponsors/ai" 1073 + } 1074 + ], 1075 + "license": "MIT", 1076 + "peer": true, 1077 + "dependencies": { 1078 + "baseline-browser-mapping": "^2.9.0", 1079 + "caniuse-lite": "^1.0.30001759", 1080 + "electron-to-chromium": "^1.5.263", 1081 + "node-releases": "^2.0.27", 1082 + "update-browserslist-db": "^1.2.0" 1083 + }, 1084 + "bin": { 1085 + "browserslist": "cli.js" 1086 + }, 1087 + "engines": { 1088 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" 1089 + } 1090 + }, 1091 + "node_modules/caniuse-lite": { 1092 + "version": "1.0.30001760", 1093 + "funding": [ 1094 + { 1095 + "type": "opencollective", 1096 + "url": "https://opencollective.com/browserslist" 1097 + }, 1098 + { 1099 + "type": "tidelift", 1100 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" 1101 + }, 1102 + { 1103 + "type": "github", 1104 + "url": "https://github.com/sponsors/ai" 1105 + } 1106 + ], 1107 + "license": "CC-BY-4.0" 1108 + }, 1109 + "node_modules/convert-source-map": { 1110 + "version": "2.0.0", 1111 + "license": "MIT" 1112 + }, 1113 + "node_modules/electron-to-chromium": { 1114 + "version": "1.5.267", 1115 + "license": "ISC" 1116 + }, 1117 + "node_modules/esbuild": { 1118 + "version": "0.21.5", 1119 + "hasInstallScript": true, 1120 + "license": "MIT", 1121 + "bin": { 1122 + "esbuild": "bin/esbuild" 1123 + }, 1124 + "engines": { 1125 + "node": ">=12" 1126 + }, 1127 + "optionalDependencies": { 1128 + "@esbuild/aix-ppc64": "0.21.5", 1129 + "@esbuild/android-arm": "0.21.5", 1130 + "@esbuild/android-arm64": "0.21.5", 1131 + "@esbuild/android-x64": "0.21.5", 1132 + "@esbuild/darwin-arm64": "0.21.5", 1133 + "@esbuild/darwin-x64": "0.21.5", 1134 + "@esbuild/freebsd-arm64": "0.21.5", 1135 + "@esbuild/freebsd-x64": "0.21.5", 1136 + "@esbuild/linux-arm": "0.21.5", 1137 + "@esbuild/linux-arm64": "0.21.5", 1138 + "@esbuild/linux-ia32": "0.21.5", 1139 + "@esbuild/linux-loong64": "0.21.5", 1140 + "@esbuild/linux-mips64el": "0.21.5", 1141 + "@esbuild/linux-ppc64": "0.21.5", 1142 + "@esbuild/linux-riscv64": "0.21.5", 1143 + "@esbuild/linux-s390x": "0.21.5", 1144 + "@esbuild/linux-x64": "0.21.5", 1145 + "@esbuild/netbsd-x64": "0.21.5", 1146 + "@esbuild/openbsd-x64": "0.21.5", 1147 + "@esbuild/sunos-x64": "0.21.5", 1148 + "@esbuild/win32-arm64": "0.21.5", 1149 + "@esbuild/win32-ia32": "0.21.5", 1150 + "@esbuild/win32-x64": "0.21.5" 1151 + } 1152 + }, 1153 + "node_modules/escalade": { 1154 + "version": "3.2.0", 1155 + "license": "MIT", 1156 + "engines": { 1157 + "node": ">=6" 1158 + } 1159 + }, 1160 + "node_modules/fsevents": { 1161 + "version": "2.3.3", 1162 + "license": "MIT", 1163 + "optional": true, 1164 + "os": [ 1165 + "darwin" 1166 + ], 1167 + "engines": { 1168 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1169 + } 1170 + }, 1171 + "node_modules/gensync": { 1172 + "version": "1.0.0-beta.2", 1173 + "license": "MIT", 1174 + "engines": { 1175 + "node": ">=6.9.0" 1176 + } 1177 + }, 1178 + "node_modules/js-tokens": { 1179 + "version": "4.0.0", 1180 + "license": "MIT" 1181 + }, 1182 + "node_modules/jsesc": { 1183 + "version": "3.1.0", 1184 + "license": "MIT", 1185 + "bin": { 1186 + "jsesc": "bin/jsesc" 1187 + }, 1188 + "engines": { 1189 + "node": ">=6" 1190 + } 1191 + }, 1192 + "node_modules/json5": { 1193 + "version": "2.2.3", 1194 + "license": "MIT", 1195 + "bin": { 1196 + "json5": "lib/cli.js" 1197 + }, 1198 + "engines": { 1199 + "node": ">=6" 1200 + } 1201 + }, 1202 + "node_modules/loose-envify": { 1203 + "version": "1.4.0", 1204 + "license": "MIT", 1205 + "dependencies": { 1206 + "js-tokens": "^3.0.0 || ^4.0.0" 1207 + }, 1208 + "bin": { 1209 + "loose-envify": "cli.js" 1210 + } 1211 + }, 1212 + "node_modules/lucide-react": { 1213 + "version": "0.561.0", 1214 + "license": "ISC", 1215 + "peerDependencies": { 1216 + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" 1217 + } 1218 + }, 1219 + "node_modules/node-releases": { 1220 + "version": "2.0.27", 1221 + "license": "MIT" 1222 + }, 1223 + "node_modules/picocolors": { 1224 + "version": "1.1.1", 1225 + "license": "ISC" 1226 + }, 1227 + "node_modules/postcss": { 1228 + "version": "8.5.6", 1229 + "funding": [ 1230 + { 1231 + "type": "opencollective", 1232 + "url": "https://opencollective.com/postcss/" 1233 + }, 1234 + { 1235 + "type": "tidelift", 1236 + "url": "https://tidelift.com/funding/github/npm/postcss" 1237 + }, 1238 + { 1239 + "type": "github", 1240 + "url": "https://github.com/sponsors/ai" 1241 + } 1242 + ], 1243 + "license": "MIT", 1244 + "dependencies": { 1245 + "nanoid": "^3.3.11", 1246 + "picocolors": "^1.1.1", 1247 + "source-map-js": "^1.2.1" 1248 + }, 1249 + "engines": { 1250 + "node": "^10 || ^12 || >=14" 1251 + } 1252 + }, 1253 + "node_modules/postcss/node_modules/nanoid": { 1254 + "version": "3.3.11", 1255 + "funding": [ 1256 + { 1257 + "type": "github", 1258 + "url": "https://github.com/sponsors/ai" 1259 + } 1260 + ], 1261 + "license": "MIT", 1262 + "bin": { 1263 + "nanoid": "bin/nanoid.cjs" 1264 + }, 1265 + "engines": { 1266 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1267 + } 1268 + }, 1269 + "node_modules/prettier": { 1270 + "version": "3.7.4", 1271 + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", 1272 + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", 1273 + "dev": true, 1274 + "license": "MIT", 1275 + "bin": { 1276 + "prettier": "bin/prettier.cjs" 1277 + }, 1278 + "engines": { 1279 + "node": ">=14" 1280 + }, 1281 + "funding": { 1282 + "url": "https://github.com/prettier/prettier?sponsor=1" 1283 + } 1284 + }, 1285 + "node_modules/react": { 1286 + "version": "18.3.1", 1287 + "license": "MIT", 1288 + "peer": true, 1289 + "dependencies": { 1290 + "loose-envify": "^1.1.0" 1291 + }, 1292 + "engines": { 1293 + "node": ">=0.10.0" 1294 + } 1295 + }, 1296 + "node_modules/react-dom": { 1297 + "version": "18.3.1", 1298 + "license": "MIT", 1299 + "peer": true, 1300 + "dependencies": { 1301 + "loose-envify": "^1.1.0", 1302 + "scheduler": "^0.23.2" 1303 + }, 1304 + "peerDependencies": { 1305 + "react": "^18.3.1" 1306 + } 1307 + }, 1308 + "node_modules/react-refresh": { 1309 + "version": "0.17.0", 1310 + "license": "MIT", 1311 + "engines": { 1312 + "node": ">=0.10.0" 1313 + } 1314 + }, 1315 + "node_modules/react-router": { 1316 + "version": "6.30.2", 1317 + "license": "MIT", 1318 + "dependencies": { 1319 + "@remix-run/router": "1.23.1" 1320 + }, 1321 + "engines": { 1322 + "node": ">=14.0.0" 1323 + }, 1324 + "peerDependencies": { 1325 + "react": ">=16.8" 1326 + } 1327 + }, 1328 + "node_modules/react-router-dom": { 1329 + "version": "6.30.2", 1330 + "license": "MIT", 1331 + "dependencies": { 1332 + "@remix-run/router": "1.23.1", 1333 + "react-router": "6.30.2" 1334 + }, 1335 + "engines": { 1336 + "node": ">=14.0.0" 1337 + }, 1338 + "peerDependencies": { 1339 + "react": ">=16.8", 1340 + "react-dom": ">=16.8" 1341 + } 1342 + }, 1343 + "node_modules/rollup": { 1344 + "version": "4.53.5", 1345 + "license": "MIT", 1346 + "dependencies": { 1347 + "@types/estree": "1.0.8" 1348 + }, 1349 + "bin": { 1350 + "rollup": "dist/bin/rollup" 1351 + }, 1352 + "engines": { 1353 + "node": ">=18.0.0", 1354 + "npm": ">=8.0.0" 1355 + }, 1356 + "optionalDependencies": { 1357 + "@rollup/rollup-android-arm-eabi": "4.53.5", 1358 + "@rollup/rollup-android-arm64": "4.53.5", 1359 + "@rollup/rollup-darwin-arm64": "4.53.5", 1360 + "@rollup/rollup-darwin-x64": "4.53.5", 1361 + "@rollup/rollup-freebsd-arm64": "4.53.5", 1362 + "@rollup/rollup-freebsd-x64": "4.53.5", 1363 + "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", 1364 + "@rollup/rollup-linux-arm-musleabihf": "4.53.5", 1365 + "@rollup/rollup-linux-arm64-gnu": "4.53.5", 1366 + "@rollup/rollup-linux-arm64-musl": "4.53.5", 1367 + "@rollup/rollup-linux-loong64-gnu": "4.53.5", 1368 + "@rollup/rollup-linux-ppc64-gnu": "4.53.5", 1369 + "@rollup/rollup-linux-riscv64-gnu": "4.53.5", 1370 + "@rollup/rollup-linux-riscv64-musl": "4.53.5", 1371 + "@rollup/rollup-linux-s390x-gnu": "4.53.5", 1372 + "@rollup/rollup-linux-x64-gnu": "4.53.5", 1373 + "@rollup/rollup-linux-x64-musl": "4.53.5", 1374 + "@rollup/rollup-openharmony-arm64": "4.53.5", 1375 + "@rollup/rollup-win32-arm64-msvc": "4.53.5", 1376 + "@rollup/rollup-win32-ia32-msvc": "4.53.5", 1377 + "@rollup/rollup-win32-x64-gnu": "4.53.5", 1378 + "@rollup/rollup-win32-x64-msvc": "4.53.5", 1379 + "fsevents": "~2.3.2" 1380 + } 1381 + }, 1382 + "node_modules/scheduler": { 1383 + "version": "0.23.2", 1384 + "license": "MIT", 1385 + "dependencies": { 1386 + "loose-envify": "^1.1.0" 1387 + } 1388 + }, 1389 + "node_modules/semver": { 1390 + "version": "6.3.1", 1391 + "license": "ISC", 1392 + "bin": { 1393 + "semver": "bin/semver.js" 1394 + } 1395 + }, 1396 + "node_modules/source-map-js": { 1397 + "version": "1.2.1", 1398 + "license": "BSD-3-Clause", 1399 + "engines": { 1400 + "node": ">=0.10.0" 1401 + } 1402 + }, 1403 + "node_modules/update-browserslist-db": { 1404 + "version": "1.2.3", 1405 + "funding": [ 1406 + { 1407 + "type": "opencollective", 1408 + "url": "https://opencollective.com/browserslist" 1409 + }, 1410 + { 1411 + "type": "tidelift", 1412 + "url": "https://tidelift.com/funding/github/npm/browserslist" 1413 + }, 1414 + { 1415 + "type": "github", 1416 + "url": "https://github.com/sponsors/ai" 1417 + } 1418 + ], 1419 + "license": "MIT", 1420 + "dependencies": { 1421 + "escalade": "^3.2.0", 1422 + "picocolors": "^1.1.1" 1423 + }, 1424 + "bin": { 1425 + "update-browserslist-db": "cli.js" 1426 + }, 1427 + "peerDependencies": { 1428 + "browserslist": ">= 4.21.0" 1429 + } 1430 + }, 1431 + "node_modules/vite": { 1432 + "version": "5.4.21", 1433 + "license": "MIT", 1434 + "peer": true, 1435 + "dependencies": { 1436 + "esbuild": "^0.21.3", 1437 + "postcss": "^8.4.43", 1438 + "rollup": "^4.20.0" 1439 + }, 1440 + "bin": { 1441 + "vite": "bin/vite.js" 1442 + }, 1443 + "engines": { 1444 + "node": "^18.0.0 || >=20.0.0" 1445 + }, 1446 + "funding": { 1447 + "url": "https://github.com/vitejs/vite?sponsor=1" 1448 + }, 1449 + "optionalDependencies": { 1450 + "fsevents": "~2.3.3" 1451 + }, 1452 + "peerDependencies": { 1453 + "@types/node": "^18.0.0 || >=20.0.0", 1454 + "less": "*", 1455 + "lightningcss": "^1.21.0", 1456 + "sass": "*", 1457 + "sass-embedded": "*", 1458 + "stylus": "*", 1459 + "sugarss": "*", 1460 + "terser": "^5.4.0" 1461 + }, 1462 + "peerDependenciesMeta": { 1463 + "@types/node": { 1464 + "optional": true 1465 + }, 1466 + "less": { 1467 + "optional": true 1468 + }, 1469 + "lightningcss": { 1470 + "optional": true 1471 + }, 1472 + "sass": { 1473 + "optional": true 1474 + }, 1475 + "sass-embedded": { 1476 + "optional": true 1477 + }, 1478 + "stylus": { 1479 + "optional": true 1480 + }, 1481 + "sugarss": { 1482 + "optional": true 1483 + }, 1484 + "terser": { 1485 + "optional": true 1486 + } 1487 + } 1488 + }, 1489 + "node_modules/yallist": { 1490 + "version": "3.1.1", 1491 + "license": "ISC" 1492 + } 1493 + } 1494 + }
+23
package.json
··· 1 + { 2 + "name": "free-static-host", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "license": "MIT", 7 + "scripts": { 8 + "build": "vite build --config client/vite.config.js", 9 + "format": "prettier --write . && gofmt -w backend-go/" 10 + }, 11 + "dependencies": { 12 + "@vitejs/plugin-react": "^4.3.4", 13 + "blueimp-md5": "^2.19.0", 14 + "lucide-react": "^0.561.0", 15 + "react": "^18.3.1", 16 + "react-dom": "^18.3.1", 17 + "react-router-dom": "^6.28.0", 18 + "vite": "^5.4.11" 19 + }, 20 + "devDependencies": { 21 + "prettier": "^3.7.4" 22 + } 23 + }