Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: snapshot test fixes

pdewey.com d00f856d 918a7566

verified
+907 -41
+11
.env.example
··· 1 + # Arabica Production Configuration 2 + # Copy this file to .env and update with your values 3 + 4 + # Your domain name (required for production) 5 + DOMAIN=arabica.example.com 6 + ACME_EMAIL=admin@example.com 7 + 8 + LOG_LEVEL=info 9 + LOG_FORMAT=json 10 + SERVER_PUBLIC_URL=https://${DOMAIN} 11 + SECURE_COOKIES=true
+90
.github/DEVELOPMENT_WORKFLOW.md
··· 1 + # Development Workflow 2 + 3 + ## Setting Up Firehose Feed with Known DIDs 4 + 5 + For development and testing, you can populate your local feed with known Arabica users: 6 + 7 + ### 1. Create a Known DIDs File 8 + 9 + Create `known-dids.txt` in the project root: 10 + 11 + ```bash 12 + cat > known-dids.txt << 'EOF' 13 + # Known Arabica users for development 14 + # Add one DID per line 15 + 16 + # Example (replace with real DIDs): 17 + # did:plc:abc123xyz 18 + # did:plc:def456uvw 19 + 20 + EOF 21 + ``` 22 + 23 + ### 2. Find DIDs to Add 24 + 25 + You can find DIDs of Arabica users in several ways: 26 + 27 + **From Bluesky profiles:** 28 + - Visit a user's profile on Bluesky 29 + - Check the URL or profile metadata for their DID 30 + 31 + **From authenticated sessions:** 32 + - After logging into Arabica, check your browser cookies 33 + - The `did` cookie contains your DID 34 + 35 + **From AT Protocol explorer tools:** 36 + - Use tools like `atproto.blue` to search for users 37 + 38 + ### 3. Run Server with Backfill 39 + 40 + ```bash 41 + # Start server with firehose and backfill 42 + go run cmd/server/main.go --firehose --known-dids known-dids.txt 43 + 44 + # Or with nix (requires adding flags to flake.nix) 45 + nix run -- --firehose --known-dids known-dids.txt 46 + ``` 47 + 48 + ### 4. Monitor Backfill Progress 49 + 50 + Watch the logs for backfill activity: 51 + 52 + ``` 53 + {"level":"info","count":3,"file":"known-dids.txt","message":"Loaded known DIDs from file"} 54 + {"level":"info","did":"did:plc:abc123xyz","message":"backfilling user records"} 55 + {"level":"info","total":5,"success":5,"message":"Backfill complete"} 56 + ``` 57 + 58 + ### 5. Verify Feed Data 59 + 60 + Once backfilled, check: 61 + - Home page feed should show brews from backfilled users 62 + - `/feed` endpoint should return feed items 63 + - Database should contain indexed records 64 + 65 + ## File Format Notes 66 + 67 + The `known-dids.txt` file supports: 68 + 69 + - **Comments**: Lines starting with `#` 70 + - **Empty lines**: Ignored 71 + - **Whitespace**: Automatically trimmed 72 + - **Validation**: Non-DID lines logged as warnings 73 + 74 + Example valid file: 75 + 76 + ``` 77 + # Coffee enthusiasts to follow 78 + did:plc:user1abc 79 + 80 + # Another user 81 + did:plc:user2def 82 + 83 + did:web:coffee.example.com # Web DID example 84 + ``` 85 + 86 + ## Security Note 87 + 88 + ⚠️ **Important**: The `known-dids.txt` file is gitignored by default. Do not commit DIDs unless you have permission from the users. 89 + 90 + For production deployments, rely on organic discovery via firehose rather than manual DID lists.
+3
.gitignore
··· 45 45 46 46 # Other 47 47 *.bak 48 + 49 + # Development files 50 + known-dids.txt
+82
.skills/htmx-alpine-integration.md
··· 1 + # HTMX + Alpine.js Integration Pattern 2 + 3 + ## Problem: "Alpine Expression Error: [variable] is not defined" 4 + 5 + When HTMX swaps in content containing Alpine.js directives (like `x-show`, `x-if`, `@click`), Alpine may not automatically process the new DOM elements, resulting in console errors like: 6 + 7 + ``` 8 + Alpine Expression Error: activeTab is not defined 9 + Expression: "activeTab === 'brews'" 10 + ``` 11 + 12 + ## Root Cause 13 + 14 + HTMX loads and swaps content into the DOM after Alpine has already initialized. The new elements contain Alpine directives that reference variables in a parent Alpine component's scope, but Alpine doesn't automatically bind these new elements to the existing component. 15 + 16 + ## Solution 17 + 18 + Use HTMX's `hx-on::after-swap` event to manually tell Alpine to initialize the new DOM tree: 19 + 20 + ```html 21 + <div id="content" 22 + hx-get="/api/data" 23 + hx-trigger="load" 24 + hx-swap="innerHTML" 25 + hx-on::after-swap="Alpine.initTree($el)"> 26 + </div> 27 + ``` 28 + 29 + ### Key Points 30 + 31 + - `hx-on::after-swap` - HTMX event that fires after content swap completes 32 + - `Alpine.initTree($el)` - Tells Alpine to process all directives in the swapped element 33 + - `$el` - HTMX provides this as the target element that received the swap 34 + 35 + ## Common Scenario 36 + 37 + **Parent template** (defines Alpine scope): 38 + ```html 39 + <div x-data="{ activeTab: 'brews' }"> 40 + <!-- Static content with tab buttons --> 41 + <button @click="activeTab = 'brews'">Brews</button> 42 + 43 + <!-- HTMX loads dynamic content here --> 44 + <div id="content" 45 + hx-get="/api/tabs" 46 + hx-trigger="load" 47 + hx-swap="innerHTML" 48 + hx-on::after-swap="Alpine.initTree($el)"> 49 + </div> 50 + </div> 51 + ``` 52 + 53 + **Loaded partial** (uses parent scope): 54 + ```html 55 + <div x-show="activeTab === 'brews'"> 56 + <!-- Brew content --> 57 + </div> 58 + <div x-show="activeTab === 'beans'"> 59 + <!-- Bean content --> 60 + </div> 61 + ``` 62 + 63 + Without `Alpine.initTree($el)`, the `x-show` directives won't be bound to the parent's `activeTab` variable. 64 + 65 + ## Alternative: Alpine Morph Plugin 66 + 67 + For more complex scenarios with nested Alpine components, use the Alpine Morph plugin: 68 + 69 + ```html 70 + <script src="https://cdn.jsdelivr.net/npm/@alpinejs/morph@3.x.x/dist/cdn.min.js"></script> 71 + <div hx-swap="morph"></div> 72 + ``` 73 + 74 + This preserves Alpine state during swaps but requires the plugin. 75 + 76 + ## When to Use 77 + 78 + Apply this pattern whenever: 79 + 1. HTMX loads content containing Alpine directives 80 + 2. The loaded content references variables from a parent Alpine component 81 + 3. You see "Expression Error: [variable] is not defined" in console 82 + 4. Alpine directives in HTMX-loaded content don't work (no reactivity, clicks ignored, etc.)
+8 -5
BACKLOG.md
··· 24 24 - If adding mobile apps, third-party API consumers, or microservices architecture, revisit this 25 25 - For now, monolithic approach is appropriate for HTMX-based web app with decentralized storage 26 26 27 + - Backfill seems to be called when user hits homepage, probably only needs to be done on startup 28 + 27 29 ## Fixes 28 30 29 - - [Future work]: adjust timing of caching in feed, maybe use firehose and a sqlite database since we are only storing a few anyway 30 - - Goal: reduce pings to server when idling 31 + - After adding a bean via add brew, that bean does not show up in the drop down until after a refresh 32 + - Happens with grinders and likely brewers also 31 33 32 - - Non-authed home page feed shows different from what the firehose version shows (should be cached and show same db contents I think) 33 - 34 - - After adding a bean via add brew, that bean does not show up in the drop down until after a refresh 34 + - Adding a grinder via the new brew page does not populate fields correctly other than the name 35 + - Also seems to happen to brewers 36 + - To solve this issue and the above, we likely should consolidate creation to use the same popup as the manage page uses, 37 + since that one works, and should already be a template partial.
+32 -9
CLAUDE.md
··· 100 100 ### Run Development Server 101 101 102 102 ```bash 103 + # Basic mode (polling-based feed) 103 104 go run cmd/server/main.go 104 - # or 105 + 106 + # With firehose (real-time AT Protocol feed) 107 + go run cmd/server/main.go --firehose 108 + 109 + # With firehose + backfill known DIDs 110 + go run cmd/server/main.go --firehose --known-dids known-dids.txt 111 + 112 + # Using nix 105 113 nix run 106 114 ``` 107 115 ··· 117 125 go build -o arabica cmd/server/main.go 118 126 ``` 119 127 128 + ## Command-Line Flags 129 + 130 + | Flag | Type | Default | Description | 131 + | --------------- | ------ | ------- | ----------------------------------------------------- | 132 + | `--firehose` | bool | false | Enable real-time firehose feed via Jetstream | 133 + | `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) | 134 + 135 + **Known DIDs File Format:** 136 + - One DID per line (e.g., `did:plc:abc123xyz`) 137 + - Lines starting with `#` are comments 138 + - Empty lines are ignored 139 + - See `known-dids.txt.example` for reference 140 + 120 141 ## Environment Variables 121 142 122 - | Variable | Default | Description | 123 - | ------------------- | --------------------------------- | ---------------------------- | 124 - | `PORT` | 18910 | HTTP server port | 125 - | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy | 126 - | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path | 127 - | `SECURE_COOKIES` | false | Set true for HTTPS | 128 - | `LOG_LEVEL` | info | debug/info/warn/error | 129 - | `LOG_FORMAT` | console | console/json | 143 + | Variable | Default | Description | 144 + | --------------------------- | --------------------------------- | ---------------------------------- | 145 + | `PORT` | 18910 | HTTP server port | 146 + | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy | 147 + | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) | 148 + | `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | 149 + | `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | 150 + | `SECURE_COOKIES` | false | Set true for HTTPS | 151 + | `LOG_LEVEL` | info | debug/info/warn/error | 152 + | `LOG_FORMAT` | console | console/json | 130 153 131 154 ## Code Patterns 132 155
+31
Caddyfile
··· 1 + {$DOMAIN:localhost} { 2 + reverse_proxy arabica:18910 { 3 + header_up X-Real-IP {remote_host} 4 + header_up X-Forwarded-For {remote_host} 5 + header_up X-Forwarded-Proto {scheme} 6 + header_up X-Forwarded-Host {host} 7 + } 8 + 9 + header { 10 + X-Content-Type-Options "nosniff" 11 + X-Frame-Options "SAMEORIGIN" 12 + Referrer-Policy "strict-origin-when-cross-origin" 13 + -Server 14 + } 15 + 16 + log { 17 + output stdout 18 + format json 19 + level INFO 20 + } 21 + 22 + encode gzip 23 + 24 + @static { 25 + path /static/* 26 + } 27 + handle @static { 28 + header Cache-Control "public, max-age=31536000, immutable" 29 + reverse_proxy arabica:18910 30 + } 31 + }
+138
README.deploy.md
··· 1 + # Arabica Deployment Guide 2 + 3 + Quick guide to deploy Arabica to a VPS with Docker and automatic HTTPS. 4 + 5 + ## Prerequisites 6 + 7 + - VPS with Docker and Docker Compose installed 8 + - Domain name pointing to your VPS IP address (A record) 9 + - Ports 80 and 443 open in firewall 10 + 11 + ## Quick Start (Production) 12 + 13 + 1. **Clone the repository:** 14 + ```bash 15 + git clone <repository-url> 16 + cd arabica 17 + ``` 18 + 19 + 2. **Configure your domain:** 20 + ```bash 21 + cp .env.example .env 22 + nano .env 23 + ``` 24 + 25 + Update `.env` with your domain: 26 + ```env 27 + DOMAIN=arabica.yourdomain.com 28 + ACME_EMAIL=your-email@example.com 29 + ``` 30 + 31 + 3. **Deploy:** 32 + ```bash 33 + docker compose up -d 34 + ``` 35 + 36 + That's it! Caddy will automatically: 37 + - Obtain SSL certificates from Let's Encrypt 38 + - Renew certificates before expiry 39 + - Redirect HTTP to HTTPS 40 + - Proxy requests to Arabica 41 + 42 + 4. **Check logs:** 43 + ```bash 44 + docker compose logs -f 45 + ``` 46 + 47 + 5. **Visit your site:** 48 + ``` 49 + https://arabica.yourdomain.com 50 + ``` 51 + 52 + ## Local Development 53 + 54 + To run locally without a domain: 55 + 56 + ```bash 57 + docker compose up 58 + ``` 59 + 60 + Then visit `http://localhost` (Caddy will serve on port 80). 61 + 62 + ## Updating 63 + 64 + ```bash 65 + git pull 66 + docker compose down 67 + docker compose build 68 + docker compose up -d 69 + ``` 70 + 71 + ## Troubleshooting 72 + 73 + ### Certificate Issues 74 + 75 + If Let's Encrypt can't issue a certificate: 76 + - Ensure your domain DNS is pointing to your VPS 77 + - Check ports 80 and 443 are accessible 78 + - Check logs: `docker compose logs caddy` 79 + 80 + ### View Arabica logs 81 + 82 + ```bash 83 + docker compose logs -f arabica 84 + ``` 85 + 86 + ### Reset everything 87 + 88 + ```bash 89 + docker compose down -v # Warning: deletes all data 90 + docker compose up -d 91 + ``` 92 + 93 + ## Production Checklist 94 + 95 + - [ ] Domain DNS pointing to VPS 96 + - [ ] Ports 80 and 443 open in firewall 97 + - [ ] `.env` file configured with your domain 98 + - [ ] Valid email set for Let's Encrypt notifications 99 + - [ ] Regular backups of `arabica-data` volume 100 + 101 + ## Backup 102 + 103 + To backup user data: 104 + 105 + ```bash 106 + docker compose exec arabica cp /data/arabica.db /data/arabica-backup.db 107 + docker cp $(docker compose ps -q arabica):/data/arabica-backup.db ./backup-$(date +%Y%m%d).db 108 + ``` 109 + 110 + ## Advanced Configuration 111 + 112 + ### Custom Caddyfile 113 + 114 + Edit `Caddyfile` directly for advanced options like: 115 + - Rate limiting 116 + - Custom headers 117 + - IP whitelisting 118 + - Multiple domains 119 + 120 + ### Environment Variables 121 + 122 + All available environment variables in `.env`: 123 + 124 + | Variable | Default | Description | 125 + | ------------------- | ------------------------------------ | ------------------------------- | 126 + | `DOMAIN` | localhost | Your domain name | 127 + | `ACME_EMAIL` | (empty) | Email for Let's Encrypt | 128 + | `LOG_LEVEL` | info | debug/info/warn/error | 129 + | `LOG_FORMAT` | json | console/json | 130 + | `SERVER_PUBLIC_URL` | https://${DOMAIN} | Override public URL | 131 + | `SECURE_COOKIES` | true | Use secure cookies | 132 + 133 + ## Support 134 + 135 + For issues, check the logs first: 136 + ```bash 137 + docker compose logs 138 + ```
+30 -2
README.md
··· 43 43 44 44 ## Configuration 45 45 46 - Environment variables: 46 + ### Command-Line Flags 47 + 48 + - `--firehose` - Enable real-time feed via AT Protocol Jetstream (default: false) 49 + - `--known-dids <file>` - Path to file with DIDs to backfill on startup (one per line) 50 + 51 + ### Environment Variables 47 52 48 53 - `PORT` - Server port (default: 18910) 49 54 - `SERVER_PUBLIC_URL` - Public URL for reverse proxy deployments (e.g., https://arabica.example.com) 50 55 - `ARABICA_DB_PATH` - BoltDB path (default: ~/.local/share/arabica/arabica.db) 56 + - `ARABICA_FEED_INDEX_PATH` - Firehose index BoltDB path (default: ~/.local/share/arabica/feed-index.db) 57 + - `ARABICA_PROFILE_CACHE_TTL` - Profile cache duration (default: 1h) 51 58 - `OAUTH_CLIENT_ID` - OAuth client ID (optional, uses localhost mode if not set) 52 59 - `OAUTH_REDIRECT_URI` - OAuth redirect URI (optional) 53 60 - `SECURE_COOKIES` - Set to true for HTTPS (default: false) ··· 58 65 59 66 - Track coffee brews with detailed parameters 60 67 - Store data in your AT Protocol Personal Data Server 61 - - Community feed of recent brews from registered users 68 + - Community feed of recent brews from registered users (polling or real-time firehose) 62 69 - Manage beans, roasters, grinders, and brewers 63 70 - Export brew data as JSON 64 71 - Mobile-friendly PWA design 72 + 73 + ### Firehose Mode 74 + 75 + Enable real-time feed updates via AT Protocol's Jetstream: 76 + 77 + ```bash 78 + # Basic firehose mode 79 + go run cmd/server/main.go --firehose 80 + 81 + # With known DIDs for backfill 82 + go run cmd/server/main.go --firehose --known-dids known-dids.txt 83 + ``` 84 + 85 + **Known DIDs file format:** 86 + ``` 87 + # Comments start with # 88 + did:plc:abc123xyz 89 + did:plc:def456uvw 90 + ``` 91 + 92 + The firehose automatically indexes **all** Arabica records across the AT Protocol network. The `--known-dids` flag allows you to backfill historical records from specific users on startup (useful for development/testing). 65 93 66 94 ## Architecture 67 95
+74 -2
cmd/server/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "bufio" 4 5 "context" 5 6 "flag" 6 7 "fmt" ··· 8 9 "os" 9 10 "os/signal" 10 11 "path/filepath" 12 + "strings" 11 13 "syscall" 12 14 "time" 13 15 ··· 25 27 func main() { 26 28 // Parse command-line flags 27 29 useFirehose := flag.Bool("firehose", false, "Enable firehose-based feed (Jetstream consumer)") 30 + knownDIDsFile := flag.String("known-dids", "", "Path to file containing DIDs to backfill on startup (one per line)") 28 31 flag.Parse() 29 32 30 33 // Configure zerolog ··· 186 189 187 190 log.Info().Msg("Firehose consumer started") 188 191 189 - // Backfill registered users in background 192 + // Backfill registered users and known DIDs in background 190 193 go func() { 191 194 time.Sleep(5 * time.Second) // Wait for initial connection 195 + 196 + // Collect all DIDs to backfill 197 + didsToBackfill := make(map[string]struct{}) 198 + 199 + // Add registered users 192 200 for _, did := range feedRegistry.List() { 201 + didsToBackfill[did] = struct{}{} 202 + } 203 + 204 + // Add DIDs from known-dids file if provided 205 + if *knownDIDsFile != "" { 206 + knownDIDs, err := loadKnownDIDs(*knownDIDsFile) 207 + if err != nil { 208 + log.Warn().Err(err).Str("file", *knownDIDsFile).Msg("Failed to load known DIDs file") 209 + } else { 210 + for _, did := range knownDIDs { 211 + didsToBackfill[did] = struct{}{} 212 + } 213 + log.Info().Int("count", len(knownDIDs)).Str("file", *knownDIDsFile).Msg("Loaded known DIDs from file") 214 + } 215 + } 216 + 217 + // Backfill all collected DIDs 218 + successCount := 0 219 + for did := range didsToBackfill { 193 220 if err := firehoseConsumer.BackfillDID(ctx, did); err != nil { 194 221 log.Warn().Err(err).Str("did", did).Msg("Failed to backfill user") 222 + } else { 223 + successCount++ 195 224 } 196 225 } 197 - log.Info().Int("count", feedRegistry.Count()).Msg("Backfill of registered users complete") 226 + log.Info().Int("total", len(didsToBackfill)).Int("success", successCount).Msg("Backfill complete") 198 227 }() 199 228 } 200 229 ··· 299 328 300 329 log.Info().Msg("Server stopped") 301 330 } 331 + 332 + // loadKnownDIDs reads a file containing DIDs (one per line) and returns them as a slice. 333 + // Lines starting with # are treated as comments and ignored. 334 + // Empty lines and whitespace are trimmed. 335 + func loadKnownDIDs(filePath string) ([]string, error) { 336 + file, err := os.Open(filePath) 337 + if err != nil { 338 + return nil, fmt.Errorf("failed to open file: %w", err) 339 + } 340 + defer file.Close() 341 + 342 + var dids []string 343 + scanner := bufio.NewScanner(file) 344 + lineNum := 0 345 + 346 + for scanner.Scan() { 347 + lineNum++ 348 + line := strings.TrimSpace(scanner.Text()) 349 + 350 + // Skip empty lines and comments 351 + if line == "" || strings.HasPrefix(line, "#") { 352 + continue 353 + } 354 + 355 + // Basic DID validation (must start with "did:") 356 + if !strings.HasPrefix(line, "did:") { 357 + log.Warn(). 358 + Str("file", filePath). 359 + Int("line", lineNum). 360 + Str("content", line). 361 + Msg("Skipping invalid DID (must start with 'did:')") 362 + continue 363 + } 364 + 365 + dids = append(dids, line) 366 + } 367 + 368 + if err := scanner.Err(); err != nil { 369 + return nil, fmt.Errorf("error reading file: %w", err) 370 + } 371 + 372 + return dids, nil 373 + }
+110
cmd/server/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + ) 8 + 9 + func TestLoadKnownDIDs(t *testing.T) { 10 + // Create a temporary test file 11 + tmpDir := t.TempDir() 12 + testFile := filepath.Join(tmpDir, "test-dids.txt") 13 + 14 + content := `# This is a comment 15 + did:plc:test123abc 16 + did:web:example.com 17 + 18 + # Another comment 19 + 20 + did:plc:another456def 21 + 22 + # Invalid lines below 23 + not-a-did 24 + just some text 25 + 26 + # Valid DID after invalid ones 27 + did:plc:final789ghi 28 + ` 29 + 30 + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { 31 + t.Fatalf("Failed to create test file: %v", err) 32 + } 33 + 34 + // Test loading DIDs 35 + dids, err := loadKnownDIDs(testFile) 36 + if err != nil { 37 + t.Fatalf("loadKnownDIDs failed: %v", err) 38 + } 39 + 40 + // Expected DIDs 41 + expected := []string{ 42 + "did:plc:test123abc", 43 + "did:web:example.com", 44 + "did:plc:another456def", 45 + "did:plc:final789ghi", 46 + } 47 + 48 + if len(dids) != len(expected) { 49 + t.Errorf("Expected %d DIDs, got %d", len(expected), len(dids)) 50 + } 51 + 52 + for i, expectedDID := range expected { 53 + if i >= len(dids) { 54 + t.Errorf("Missing DID at index %d: %s", i, expectedDID) 55 + continue 56 + } 57 + if dids[i] != expectedDID { 58 + t.Errorf("DID at index %d: expected %s, got %s", i, expectedDID, dids[i]) 59 + } 60 + } 61 + } 62 + 63 + func TestLoadKnownDIDs_EmptyFile(t *testing.T) { 64 + tmpDir := t.TempDir() 65 + testFile := filepath.Join(tmpDir, "empty.txt") 66 + 67 + if err := os.WriteFile(testFile, []byte(""), 0644); err != nil { 68 + t.Fatalf("Failed to create test file: %v", err) 69 + } 70 + 71 + dids, err := loadKnownDIDs(testFile) 72 + if err != nil { 73 + t.Fatalf("loadKnownDIDs failed: %v", err) 74 + } 75 + 76 + if len(dids) != 0 { 77 + t.Errorf("Expected 0 DIDs from empty file, got %d", len(dids)) 78 + } 79 + } 80 + 81 + func TestLoadKnownDIDs_OnlyComments(t *testing.T) { 82 + tmpDir := t.TempDir() 83 + testFile := filepath.Join(tmpDir, "comments.txt") 84 + 85 + content := `# Comment 1 86 + # Comment 2 87 + 88 + # Comment 3 89 + ` 90 + 91 + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { 92 + t.Fatalf("Failed to create test file: %v", err) 93 + } 94 + 95 + dids, err := loadKnownDIDs(testFile) 96 + if err != nil { 97 + t.Fatalf("loadKnownDIDs failed: %v", err) 98 + } 99 + 100 + if len(dids) != 0 { 101 + t.Errorf("Expected 0 DIDs from comments-only file, got %d", len(dids)) 102 + } 103 + } 104 + 105 + func TestLoadKnownDIDs_NonexistentFile(t *testing.T) { 106 + _, err := loadKnownDIDs("/nonexistent/path/file.txt") 107 + if err == nil { 108 + t.Error("Expected error for nonexistent file, got nil") 109 + } 110 + }
+25 -7
compose.yml
··· 1 1 services: 2 + caddy: 3 + image: caddy:2-alpine 4 + ports: 5 + - "80:80" 6 + - "443:443" 7 + - "443:443/udp" 8 + volumes: 9 + - ./Caddyfile:/etc/caddy/Caddyfile:ro 10 + - caddy-data:/data 11 + - caddy-config:/config 12 + environment: 13 + - DOMAIN=${DOMAIN:-localhost} 14 + - ACME_EMAIL=${ACME_EMAIL:-} 15 + restart: unless-stopped 16 + depends_on: 17 + - arabica 18 + 2 19 arabica: 3 20 build: . 4 - ports: 5 - - "18910:18910" 21 + expose: 22 + - "18910" 6 23 volumes: 7 24 - arabica-data:/data 8 25 environment: 9 26 - PORT=18910 10 27 - ARABICA_DB_PATH=/data/arabica.db 11 - - LOG_LEVEL=info 12 - - LOG_FORMAT=json 13 - # Uncomment for production behind reverse proxy: 14 - # - SERVER_PUBLIC_URL=https://arabica.example.com 15 - # - SECURE_COOKIES=true 28 + - LOG_LEVEL=${LOG_LEVEL:-info} 29 + - LOG_FORMAT=${LOG_FORMAT:-json} 30 + - SERVER_PUBLIC_URL=${SERVER_PUBLIC_URL:-https://${DOMAIN}} 31 + - SECURE_COOKIES=true 16 32 restart: unless-stopped 17 33 18 34 volumes: 19 35 arabica-data: 36 + caddy-data: 37 + caddy-config:
+8
internal/bff/__snapshots__/brew_list_with_complete_data.snap
··· 118 118 class="text-brown-700 hover:text-brown-900 font-medium"> 119 119 View 120 120 </a> 121 + <a href="/brews/brew1/edit" 122 + class="text-brown-700 hover:text-brown-900 font-medium"> 123 + Edit 124 + </a> 121 125 <button hx-delete="/brews/brew1" 122 126 hx-confirm="Are you sure you want to delete this brew?" hx-target="closest tr" 123 127 hx-swap="outerHTML swap:1s" class="text-brown-600 hover:text-brown-800 font-medium"> ··· 169 173 <a href="/brews/brew2" 170 174 class="text-brown-700 hover:text-brown-900 font-medium"> 171 175 View 176 + </a> 177 + <a href="/brews/brew2/edit" 178 + class="text-brown-700 hover:text-brown-900 font-medium"> 179 + Edit 172 180 </a> 173 181 <button hx-delete="/brews/brew2" 174 182 hx-confirm="Are you sure you want to delete this brew?" hx-target="closest tr"
+1 -1
internal/bff/__snapshots__/complete_brew_with_all_fields.snap
··· 35 35 </div> 36 36 <div class="text-sm text-brown-700 mt-0.5"> 37 37 <span class="font-medium"> 38 - 🏪 Onyx Coffee Lab 38 + 🏭 Onyx Coffee Lab 39 39 </span> 40 40 </div> 41 41 <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
+1 -1
internal/bff/__snapshots__/mixed_feed_all_types.snap
··· 39 39 </div> 40 40 <div class="text-sm text-brown-700 mt-0.5"> 41 41 <span class="font-medium"> 42 - 🏪 Onyx 42 + 🏭 Onyx 43 43 </span> 44 44 </div> 45 45 <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
+1 -1
internal/bff/__snapshots__/profile_roaster_with_invalid_url_protocol.snap
··· 21 21 <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 22 22 <div> 23 23 <h3 class="text-lg font-semibold text-brown-900 mb-3"> 24 - 🏪 Favorite Roasters 24 + 🏭 Favorite Roasters 25 25 </h3> 26 26 <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 27 27 <table class="min-w-full divide-y divide-brown-300">
+1 -1
internal/bff/__snapshots__/profile_roaster_with_unsafe_website_url.snap
··· 21 21 <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 22 22 <div> 23 23 <h3 class="text-lg font-semibold text-brown-900 mb-3"> 24 - 🏪 Favorite Roasters 24 + 🏭 Favorite Roasters 25 25 </h3> 26 26 <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 27 27 <table class="min-w-full divide-y divide-brown-300">
+1 -1
internal/bff/__snapshots__/profile_with_gear_collection.snap
··· 25 25 <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 26 26 <div> 27 27 <h3 class="text-lg font-semibold text-brown-900 mb-3"> 28 - 🏪 Favorite Roasters 28 + 🏭 Favorite Roasters 29 29 </h3> 30 30 <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 31 31 <table class="min-w-full divide-y divide-brown-300">
+1 -1
internal/bff/__snapshots__/profile_with_unicode_content.snap
··· 106 106 </div> 107 107 <div> 108 108 <h3 class="text-lg font-semibold text-brown-900 mb-3"> 109 - 🏪 Favorite Roasters 109 + 🏭 Favorite Roasters 110 110 </h3> 111 111 <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 112 112 <table class="min-w-full divide-y divide-brown-300">
+33
internal/bff/render.go
··· 240 240 return t.ExecuteTemplate(w, "layout", data) 241 241 } 242 242 243 + // RenderBrewView renders the brew view page 244 + func RenderBrewView(w http.ResponseWriter, brew *models.Brew, isAuthenticated bool, userDID string, userProfile *UserProfile) error { 245 + t, err := parsePageTemplate("brew_view.tmpl") 246 + if err != nil { 247 + return err 248 + } 249 + 250 + // If water amount is not set but pours exist, sum the pours 251 + displayBrew := brew 252 + if brew.WaterAmount == 0 && len(brew.Pours) > 0 { 253 + // Create a copy to avoid modifying the original 254 + brewCopy := *brew 255 + for _, pour := range brew.Pours { 256 + brewCopy.WaterAmount += pour.WaterAmount 257 + } 258 + displayBrew = &brewCopy 259 + } 260 + 261 + brewData := &BrewData{ 262 + Brew: displayBrew, 263 + PoursJSON: PoursToJSON(brew.Pours), 264 + } 265 + 266 + data := &PageData{ 267 + Title: "View Brew", 268 + Brew: brewData, 269 + IsAuthenticated: isAuthenticated, 270 + UserDID: userDID, 271 + UserProfile: userProfile, 272 + } 273 + return t.ExecuteTemplate(w, "layout", data) 274 + } 275 + 243 276 // RenderManage renders the manage page 244 277 func RenderManage(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isAuthenticated bool, userDID string, userProfile *UserProfile) error { 245 278 t, err := parsePageTemplate("manage.tmpl")
+30
internal/handlers/handlers.go
··· 295 295 } 296 296 } 297 297 298 + // Show brew view page 299 + func (h *Handler) HandleBrewView(w http.ResponseWriter, r *http.Request) { 300 + rkey := validateRKey(w, r.PathValue("id")) 301 + if rkey == "" { 302 + return 303 + } 304 + 305 + // Check authentication (optional for view) 306 + store, authenticated := h.getAtprotoStore(r) 307 + if !authenticated { 308 + http.Redirect(w, r, "/login", http.StatusFound) 309 + return 310 + } 311 + 312 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 313 + userProfile := h.getUserProfile(r.Context(), didStr) 314 + 315 + brew, err := store.GetBrewByRKey(r.Context(), rkey) 316 + if err != nil { 317 + http.Error(w, "Brew not found", http.StatusNotFound) 318 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for view") 319 + return 320 + } 321 + 322 + if err := bff.RenderBrewView(w, brew, authenticated, didStr, userProfile); err != nil { 323 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 324 + log.Error().Err(err).Msg("Failed to render brew view") 325 + } 326 + } 327 + 298 328 // Show edit brew form 299 329 func (h *Handler) HandleBrewEdit(w http.ResponseWriter, r *http.Request) { 300 330 rkey := validateRKey(w, r.PathValue("id"))
+2 -1
internal/routing/routing.go
··· 56 56 mux.HandleFunc("GET /manage", h.HandleManage) 57 57 mux.HandleFunc("GET /brews", h.HandleBrewList) 58 58 mux.HandleFunc("GET /brews/new", h.HandleBrewNew) 59 - mux.HandleFunc("GET /brews/{id}", h.HandleBrewEdit) 59 + mux.HandleFunc("GET /brews/{id}", h.HandleBrewView) 60 + mux.HandleFunc("GET /brews/{id}/edit", h.HandleBrewEdit) 60 61 mux.Handle("POST /brews", cop.Handler(http.HandlerFunc(h.HandleBrewCreate))) 61 62 mux.Handle("PUT /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewUpdate))) 62 63 mux.Handle("DELETE /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewDelete)))
+2 -5
justfile
··· 1 1 run: 2 - @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/server/main.go 2 + @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/server/main.go -firehose -known-dids known-dids.txt 3 3 4 4 run-production: 5 - @LOG_FORMAT=json SECURE_COOKIES=true go run cmd/server/main.go 6 - 7 - run-firehose: 8 - @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/server/main.go -firehose 5 + @LOG_FORMAT=json SECURE_COOKIES=true go run cmd/server/main.go -firehose 9 6 10 7 test: 11 8 @go test ./... -cover -coverprofile=cover.out
+18
known-dids.txt.example
··· 1 + # Known DIDs for Development Backfill 2 + # 3 + # This file contains DIDs that should be backfilled on startup when using 4 + # the --known-dids flag. This is useful for development and testing to 5 + # populate the feed with known coffee enthusiasts. 6 + # 7 + # Format: One DID per line 8 + # Lines starting with # are comments 9 + # Empty lines are ignored 10 + # 11 + # Example DIDs (replace with real DIDs): 12 + # did:plc:example1234567890abcdef 13 + # did:plc:another1234567890abcdef 14 + # 15 + # To use this file: 16 + # 1. Copy this file to known-dids.txt 17 + # 2. Add real DIDs (one per line) 18 + # 3. Run: ./arabica --firehose --known-dids known-dids.txt
+167
templates/brew_view.tmpl
··· 1 + {{define "content"}} 2 + <div class="max-w-2xl mx-auto"> 3 + <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 4 + <!-- Header with title and actions --> 5 + <div class="flex justify-between items-start mb-6"> 6 + <div> 7 + <h2 class="text-3xl font-bold text-brown-900">Brew Details</h2> 8 + <p class="text-sm text-brown-600 mt-1">{{.Brew.CreatedAt.Format "January 2, 2006 at 3:04 PM"}}</p> 9 + </div> 10 + <div class="flex gap-2"> 11 + <a href="/brews/{{.Brew.RKey}}/edit" 12 + class="inline-flex items-center bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 13 + Edit 14 + </a> 15 + <button 16 + hx-delete="/brews/{{.Brew.RKey}}" 17 + hx-confirm="Are you sure you want to delete this brew?" 18 + hx-target="body" 19 + class="inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors"> 20 + Delete 21 + </button> 22 + </div> 23 + </div> 24 + 25 + <div class="space-y-6"> 26 + <!-- Rating (prominent at top) --> 27 + {{if hasValue .Brew.Rating}} 28 + <div class="text-center py-4 bg-brown-50 rounded-lg border border-brown-200"> 29 + <div class="text-4xl font-bold text-brown-800"> 30 + {{.Brew.Rating}}/10 31 + </div> 32 + <div class="text-sm text-brown-600 mt-1">Rating</div> 33 + </div> 34 + {{end}} 35 + 36 + <!-- Coffee Bean --> 37 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 38 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Coffee Bean</h3> 39 + {{if .Brew.Bean}} 40 + <div class="font-bold text-lg text-brown-900"> 41 + {{if .Brew.Bean.Name}}{{.Brew.Bean.Name}}{{else}}{{.Brew.Bean.Origin}}{{end}} 42 + </div> 43 + {{if and .Brew.Bean.Roaster .Brew.Bean.Roaster.Name}} 44 + <div class="text-sm text-brown-700 mt-1"> 45 + by {{.Brew.Bean.Roaster.Name}} 46 + </div> 47 + {{end}} 48 + <div class="flex flex-wrap gap-3 mt-2 text-sm text-brown-600"> 49 + {{if .Brew.Bean.Origin}}<span>Origin: {{.Brew.Bean.Origin}}</span>{{end}} 50 + {{if .Brew.Bean.RoastLevel}}<span>Roast: {{.Brew.Bean.RoastLevel}}</span>{{end}} 51 + </div> 52 + {{else}} 53 + <span class="text-brown-400">Not specified</span> 54 + {{end}} 55 + </div> 56 + 57 + <!-- Brew Parameters --> 58 + <div class="grid grid-cols-2 gap-4"> 59 + <!-- Brew Method --> 60 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 61 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Brew Method</h3> 62 + {{if .Brew.BrewerObj}} 63 + <div class="font-semibold text-brown-900">{{.Brew.BrewerObj.Name}}</div> 64 + {{else if .Brew.Method}} 65 + <div class="font-semibold text-brown-900">{{.Brew.Method}}</div> 66 + {{else}} 67 + <span class="text-brown-400">Not specified</span> 68 + {{end}} 69 + </div> 70 + 71 + <!-- Grinder --> 72 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 73 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Grinder</h3> 74 + {{if .Brew.GrinderObj}} 75 + <div class="font-semibold text-brown-900">{{.Brew.GrinderObj.Name}}</div> 76 + {{else}} 77 + <span class="text-brown-400">Not specified</span> 78 + {{end}} 79 + </div> 80 + 81 + <!-- Coffee Amount --> 82 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 83 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Coffee</h3> 84 + {{if hasValue .Brew.CoffeeAmount}} 85 + <div class="font-semibold text-brown-900">{{.Brew.CoffeeAmount}}g</div> 86 + {{else}} 87 + <span class="text-brown-400">Not specified</span> 88 + {{end}} 89 + </div> 90 + 91 + <!-- Water Amount --> 92 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 93 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Water</h3> 94 + {{if hasValue .Brew.WaterAmount}} 95 + <div class="font-semibold text-brown-900">{{.Brew.WaterAmount}}g</div> 96 + {{else}} 97 + <span class="text-brown-400">Not specified</span> 98 + {{end}} 99 + </div> 100 + 101 + <!-- Grind Size --> 102 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 103 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Grind Size</h3> 104 + {{if .Brew.GrindSize}} 105 + <div class="font-semibold text-brown-900">{{.Brew.GrindSize}}</div> 106 + {{else}} 107 + <span class="text-brown-400">Not specified</span> 108 + {{end}} 109 + </div> 110 + 111 + <!-- Temperature --> 112 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 113 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Temperature</h3> 114 + {{if hasTemp .Brew.Temperature}} 115 + <div class="font-semibold text-brown-900">{{formatTemp .Brew.Temperature}}</div> 116 + {{else}} 117 + <span class="text-brown-400">Not specified</span> 118 + {{end}} 119 + </div> 120 + 121 + <!-- Brew Time --> 122 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200 col-span-2"> 123 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Brew Time</h3> 124 + {{if hasValue .Brew.TimeSeconds}} 125 + <div class="font-semibold text-brown-900">{{formatTime .Brew.TimeSeconds}}</div> 126 + {{else}} 127 + <span class="text-brown-400">Not specified</span> 128 + {{end}} 129 + </div> 130 + </div> 131 + 132 + <!-- Pours --> 133 + {{if .Brew.Pours}} 134 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 135 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3">Pours</h3> 136 + <div class="space-y-2"> 137 + {{range .Brew.Pours}} 138 + <div class="flex justify-between items-center bg-white p-3 rounded-lg border border-brown-200"> 139 + <div class="flex gap-4 text-sm"> 140 + <span class="font-semibold text-brown-800">{{.WaterAmount}}g</span> 141 + <span class="text-brown-600">@ {{formatTime .TimeSeconds}}</span> 142 + </div> 143 + </div> 144 + {{end}} 145 + </div> 146 + </div> 147 + {{end}} 148 + 149 + <!-- Tasting Notes --> 150 + {{if .Brew.TastingNotes}} 151 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 152 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Tasting Notes</h3> 153 + <div class="text-brown-900 whitespace-pre-wrap">{{.Brew.TastingNotes}}</div> 154 + </div> 155 + {{end}} 156 + 157 + <!-- Back Button --> 158 + <div class="pt-4"> 159 + <a href="/brews" 160 + class="inline-block text-brown-700 hover:text-brown-900 font-medium"> 161 + &larr; Back to Brews 162 + </a> 163 + </div> 164 + </div> 165 + </div> 166 + </div> 167 + {{end}}
+2
templates/partials/brew_list_content.tmpl
··· 119 119 <a href="/brews/{{.RKey}}" 120 120 class="text-brown-700 hover:text-brown-900 font-medium">View</a> 121 121 {{if $.IsOwnProfile}} 122 + <a href="/brews/{{.RKey}}/edit" 123 + class="text-brown-700 hover:text-brown-900 font-medium">Edit</a> 122 124 <button hx-delete="/brews/{{.RKey}}" 123 125 hx-confirm="Are you sure you want to delete this brew?" hx-target="closest tr" 124 126 hx-swap="outerHTML swap:1s" class="text-brown-600 hover:text-brown-800 font-medium">
+1
templates/partials/cards/_placeholder.tmpl
··· 1 + {{/* Placeholder to ensure the cards directory is not empty for ParseGlob */}}
+1 -1
templates/partials/feed.tmpl
··· 50 50 </div> 51 51 {{if and .Brew.Bean.Roaster .Brew.Bean.Roaster.Name}} 52 52 <div class="text-sm text-brown-700 mt-0.5"> 53 - <span class="font-medium">🏪 {{.Brew.Bean.Roaster.Name}}</span> 53 + <span class="font-medium">🏭 {{.Brew.Bean.Roaster.Name}}</span> 54 54 </div> 55 55 {{end}} 56 56 <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
+1 -1
templates/partials/profile_content.tmpl
··· 75 75 <!-- Roasters --> 76 76 {{if .Roasters}} 77 77 <div> 78 - <h3 class="text-lg font-semibold text-brown-900 mb-3">🏪 Favorite Roasters</h3> 78 + <h3 class="text-lg font-semibold text-brown-900 mb-3">🏭 Favorite Roasters</h3> 79 79 <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 80 80 <table class="min-w-full divide-y divide-brown-300"> 81 81 <thead class="bg-brown-200/80">
+2 -2
templates/profile.tmpl
··· 6 6 <!-- Load profile stats updater --> 7 7 <script src="/static/js/profile-stats.js"></script> 8 8 {{if .IsOwnProfile}} 9 - <div class="max-w-4xl mx-auto" x-data="managePage()"> 9 + <div class="max-w-4xl mx-auto" x-data="managePage()" @htmx:after-swap.window="$nextTick(() => Alpine.initTree($el))"> 10 10 {{else}} 11 - <div class="max-w-4xl mx-auto" x-data="{ activeTab: 'brews' }"> 11 + <div class="max-w-4xl mx-auto" x-data="{ activeTab: 'brews' }" @htmx:after-swap.window="$nextTick(() => Alpine.initTree($el))"> 12 12 {{end}} 13 13 <!-- Profile Header --> 14 14 <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-6 border border-brown-300">