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

Configure Feed

Select the types of activity you want to include in your feed.

feat: pds invite code request/generation

+847
+2
.cells/cells.jsonl
··· 50 50 {"id":"01KGGK6V4WMC3X67E72JTE27A7","title":"Fix 'failed to convert record to feed item' warning for likes","description":"The firehose index logs a warning 'failed to convert record to feed item' when processing like records. This is expected behavior since likes are not displayed as standalone feed items - they're indexed for like counts but shouldn't appear in the feed.\n\nThe warning appears in internal/firehose/index.go:433 when recordToFeedItem returns an error for NSIDLike records (line 582-584).\n\nFix: Handle the like case silently without logging a warning, since this is expected behavior. Either:\n1. Skip like records before calling recordToFeedItem\n2. Return a special sentinel error from recordToFeedItem that indicates 'skip without warning'\n3. Check the collection type at the call site and skip likes there\n\nThis applies to both authenticated and unauthenticated users.","status":"completed","priority":"normal","labels":["bug"],"created_at":"2026-02-03T01:52:24.220349056Z","updated_at":"2026-02-03T01:54:54.698106017Z","completed_at":"2026-02-03T01:54:54.686934901Z"} 51 51 {"id":"01KGGNPG25M2AJ619P65FPMEH4","title":"Add OpenGraph metadata to brew pages","description":"Add dynamic OpenGraph metadata support for brew pages to enable rich social sharing previews.\n\n## Background\n\nCurrently, the site uses static OpenGraph tags in layout.templ:\n- og:title: \"Arabica - Coffee Brew Tracker\" (static)\n- og:description: \"Track your coffee brewing journey...\" (static) \n- og:type: \"website\" (static)\n- og:image: NOT PRESENT\n\nWhen users share brew links on social media, all previews look identical and provide no context about the specific brew.\n\n## Requirements\n\n### 1. Extend LayoutData struct\n\nAdd optional OpenGraph fields to `internal/web/components/layout.templ`:\n\n```go\ntype LayoutData struct {\n Title string\n IsAuthenticated bool\n UserDID string\n UserProfile *bff.UserProfile\n CSPNonce string\n \n // OpenGraph metadata (optional, falls back to defaults)\n OGTitle string\n OGDescription string\n OGImage string\n OGType string // \"website\", \"article\"\n OGUrl string // Canonical URL\n}\n```\n\n### 2. Update layout.templ head section\n\nModify meta tag rendering to use dynamic values with fallbacks:\n- If OGTitle set, use it; otherwise use site default\n- If OGDescription set, use it; otherwise use site default\n- If OGImage set, render og:image tag (currently missing entirely)\n- If OGUrl set, render og:url tag\n- Support og:type (default \"website\", brew pages use \"article\")\n\n### 3. Update buildLayoutData helper\n\nExtend `handlers.go` buildLayoutData() to accept optional OG parameters, or create a new helper `buildLayoutDataWithOG()`.\n\n### 4. Update HandleBrewView handler\n\nConstruct descriptive OG metadata from brew data:\n- **og:title**: \"{Bean Name} from {Origin} - Arabica\" or similar\n- **og:description**: \"{Rating}/10 - {Tasting Notes preview}\" or brew summary\n- **og:type**: \"article\"\n- **og:url**: The share URL (already computed as `shareURL`)\n- **og:image**: Static default for now (e.g., /static/og-brew-default.png)\n\nAvailable data for description construction:\n- brew.Rating, brew.TastingNotes\n- brew.Bean.Name, brew.Bean.Origin, brew.Bean.RoastLevel\n- brew.Bean.Roaster.Name\n- brew.CoffeeAmount, brew.WaterAmount, brew.Temperature\n\n### 5. Add Twitter Card support\n\nAdd Twitter-specific meta tags:\n- twitter:card = \"summary\" (or \"summary_large_image\" if we have images)\n- twitter:title, twitter:description (same as OG values)\n\n### 6. Static fallback image\n\nCreate or source a default OG image for brews:\n- Location: /static/og-brew-default.png or similar\n- Size: 1200x630px (recommended OG image size)\n- Design: Coffee-themed, includes Arabica branding\n\n## Acceptance Criteria\n\n- [ ] LayoutData extended with optional OG fields\n- [ ] layout.templ renders dynamic OG tags with fallbacks\n- [ ] Brew view pages include descriptive OG metadata\n- [ ] Twitter Card tags included\n- [ ] Static default OG image created and served\n- [ ] Existing pages (home, about, etc.) continue working with defaults\n- [ ] templ generate runs successfully\n- [ ] Manual testing: share brew URL to social platform preview tool\n\n## Implementation Notes\n\n- Follow existing patterns in handlers.go for data flow\n- Keep backwards compatible - pages not setting OG fields should use defaults\n- Consider creating a helper function to build OG description from brew data\n- The shareURL is already constructed in HandleBrewView (lines 695-701)\n\n## Future Enhancements (out of scope)\n\n- Dynamic image generation showing brew stats\n- Profile page OG metadata\n- Feed page OG metadata with recent brew preview","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","social"],"created_at":"2026-02-03T02:35:54.309356038Z","updated_at":"2026-02-03T02:53:43.170537883Z","completed_at":"2026-02-03T02:53:43.15817599Z","notes":[{"timestamp":"2026-02-03T02:53:36.100130152Z","author":"patrick","message":"Implementation complete:\n- Extended LayoutData with OG fields (OGTitle, OGDescription, OGImage, OGType, OGUrl)\n- Added helper methods ogTitle(), ogDescription(), ogType() with fallbacks\n- Updated layout.templ to render dynamic OG and Twitter Card meta tags\n- Added populateBrewOGMetadata() helper in handlers.go\n- Updated HandleBrewView to populate OG metadata from brew data\n- Added PublicURL to handler Config for absolute URLs\n- Added comprehensive tests for OG metadata generation\n- All tests pass"}]} 52 52 {"id":"01KGM9QB86N5RX50042DT1YN1N","title":"Consolidate Tailwind CSS usage","description":"Reduce Tailwind class duplication by leveraging existing CSS abstractions and adding missing utility classes.\n\n## Changes Required\n\n### 1. Add new utility classes to app.css\n- `.page-container` variants (sm, md, lg, xl) for max-width containers\n- `.avatar-sm`, `.avatar-md`, `.avatar-lg` size classes\n- `.section-box` for bg-brown-50 rounded sections\n\n### 2. Update templ components to use existing abstractions\n- WelcomeCard (shared.templ) - use `.card` instead of inline gradient\n- ProfileHeader, ProfileStat, ProfileTabs (profile.templ) - use `.card`\n- Header dropdown (header.templ) - use `.action-menu`\n- Login/CTA buttons (shared.templ) - use `.btn-primary`\n- Avatar component (shared.templ) - use new CSS classes instead of templ.KV\n\n### 3. Replace inline page containers\n- Replace `max-w-4xl mx-auto` etc. with `.page-container-*` classes across all pages\n\n## Acceptance Criteria\n- No visual changes to the site\n- Reduced class verbosity in templ files\n- Avatar component simplified\n- Consistent use of existing abstractions","status":"completed","priority":"normal","assignee":"patrick","labels":["css","refactor"],"created_at":"2026-02-04T12:23:36.966151046Z","updated_at":"2026-02-04T12:27:59.86186814Z","completed_at":"2026-02-04T12:27:59.850499942Z"} 53 + {"id":"01KHABKRB6RHR2AVE8PR28R66D","title":"Account request page (/join)","description":"Create a /join page where prospective users can request an arabica.systems PDS account.\n\n## Requirements\n- Simple form collecting: email address, preferred handle (optional), brief reason/message (optional)\n- On submit, sends an email notification to the admin (arabica.systems address TBD) with the request details\n- Stores the request in BoltDB for record-keeping\n- Shows a confirmation page after submission (\"Thanks, we'll review your request and email you an invite code\")\n- Rate limiting / basic spam prevention (e.g. honeypot field)\n- Matches the existing arabica.social design system (templ components, Tailwind, brown color palette)\n\n## Technical Details\n- Add SMTP support to the application (Go stdlib net/smtp or similar)\n- New env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, ADMIN_EMAIL\n- New BoltDB bucket for account requests\n- New handler + templ page + route\n- No authentication required (public page)\n\n## Acceptance Criteria\n- Form renders correctly and matches site design\n- Email is sent to admin on submission\n- Request is persisted in BoltDB\n- Confirmation shown to user\n- Basic input validation (valid email format)","status":"in_progress","priority":"normal","assignee":"patrick","labels":["frontend","backend"],"created_at":"2026-02-13T01:59:53.958852961Z","updated_at":"2026-02-13T02:05:46.199584438Z","notes":[{"timestamp":"2026-02-13T02:03:54.190326645Z","author":"patrick","message":"SMTP approach decided: use Go stdlib net/smtp. Support both implicit TLS (port 465 via tls.Dial + smtp.NewClient) and STARTTLS (port 587/2525). VPS may block ports 25/587, so port 465 and 2525 must work. No external email SaaS dependency needed."}]} 54 + {"id":"01KHABM3J6XPXFTCPW6JX378W7","title":"Account creation page (/join/create)","description":"Create a /join/create page where users with an invite code can create their arabica.systems PDS account.\n\n## Requirements\n- Form collecting: invite code, desired handle (with @arabica.systems suffix shown), email, password (with confirmation)\n- Calls describeServer on arabica.systems PDS first to get available domains and requirements\n- Calls com.atproto.server.createAccount on the PDS (arabica.systems) via XRPC\n- Shows success page with next steps (e.g. log in to arabica.social, set up your profile)\n- Proper error handling for: invalid invite code, handle taken, invalid password, etc.\n- Password strength indicator or requirements display\n- Matches existing arabica.social design system\n\n## Technical Details\n- The PDS is at arabica.systems - XRPC calls go to https://arabica.systems/xrpc/...\n- createAccount params: handle, email, password, inviteCode\n- describeServer tells us available handle domains and whether invites are required\n- New env var: PDS_SERVICE_URL (e.g. https://arabica.systems)\n- New handler + templ page + route\n- No arabica.social authentication required (public page)\n- Do NOT store the user's password - it goes directly to the PDS\n\n## Security Considerations \n- Password goes directly to PDS via server-side XRPC call (not client-side JS)\n- HTTPS only for the XRPC call\n- No password logging\n\n## Acceptance Criteria\n- Form renders with all required fields\n- Valid invite code + details creates account on PDS\n- Appropriate error messages for each failure mode (InvalidInviteCode, HandleNotAvailable, etc.)\n- Success page with clear next steps\n- Matches site design system","status":"open","priority":"normal","labels":["frontend","backend"],"created_at":"2026-02-13T02:00:05.446933524Z","updated_at":"2026-02-13T02:43:36.680372523Z"}
+27
cmd/server/main.go
··· 9 9 "os" 10 10 "os/signal" 11 11 "path/filepath" 12 + "strconv" 12 13 "strings" 13 14 "syscall" 14 15 "time" 15 16 16 17 "arabica/internal/atproto" 17 18 "arabica/internal/database/boltstore" 19 + "arabica/internal/email" 18 20 "arabica/internal/feed" 19 21 "arabica/internal/firehose" 20 22 "arabica/internal/handlers" ··· 308 310 log.Warn().Err(err).Msg("Failed to initialize moderation service, moderation disabled") 309 311 } else { 310 312 h.SetModeration(moderationSvc, moderationStore) 313 + } 314 + 315 + // Initialize join request handling 316 + smtpPort := 587 317 + if portStr := os.Getenv("SMTP_PORT"); portStr != "" { 318 + if p, err := strconv.Atoi(portStr); err == nil { 319 + smtpPort = p 320 + } 321 + } 322 + emailSender := email.NewSender(email.Config{ 323 + Host: os.Getenv("SMTP_HOST"), 324 + Port: smtpPort, 325 + User: os.Getenv("SMTP_USER"), 326 + Pass: os.Getenv("SMTP_PASS"), 327 + From: os.Getenv("SMTP_FROM"), 328 + AdminEmail: os.Getenv("ADMIN_EMAIL"), 329 + }) 330 + joinStore := store.JoinStore() 331 + pdsAdminURL := os.Getenv("PDS_ADMIN_URL") 332 + pdsAdminToken := os.Getenv("PDS_ADMIN_PASSWORD") 333 + h.SetJoin(emailSender, joinStore, pdsAdminURL, pdsAdminToken) 334 + if emailSender.Enabled() { 335 + log.Info().Str("host", os.Getenv("SMTP_HOST")).Msg("Email notifications enabled for join requests") 336 + } else { 337 + log.Info().Msg("Email notifications disabled (SMTP_HOST not set), join requests will be saved to database only") 311 338 } 312 339 313 340 // Setup router with middleware
+41
go.mod
··· 18 18 github.com/cespare/xxhash/v2 v2.2.0 // indirect 19 19 github.com/davecgh/go-spew v1.1.1 // indirect 20 20 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 21 + github.com/felixge/httpsnoop v1.0.4 // indirect 22 + github.com/go-logr/logr v1.4.1 // indirect 23 + github.com/go-logr/stdr v1.2.2 // indirect 24 + github.com/gogo/protobuf v1.3.2 // indirect 21 25 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 22 26 github.com/google/go-querystring v1.1.0 // indirect 27 + github.com/google/uuid v1.4.0 // indirect 28 + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 29 + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 30 + github.com/hashicorp/golang-lru v1.0.2 // indirect 23 31 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 32 + github.com/ipfs/bbloom v0.0.4 // indirect 33 + github.com/ipfs/go-block-format v0.2.0 // indirect 34 + github.com/ipfs/go-cid v0.4.1 // indirect 35 + github.com/ipfs/go-datastore v0.6.0 // indirect 36 + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 37 + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 38 + github.com/ipfs/go-ipfs-util v0.0.3 // indirect 39 + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 40 + github.com/ipfs/go-ipld-format v0.6.0 // indirect 41 + github.com/ipfs/go-log v1.0.5 // indirect 42 + github.com/ipfs/go-log/v2 v2.5.1 // indirect 43 + github.com/ipfs/go-metrics-interface v0.0.1 // indirect 44 + github.com/jbenet/goprocess v0.1.4 // indirect 45 + github.com/klauspost/cpuid/v2 v2.2.7 // indirect 24 46 github.com/mattn/go-colorable v0.1.13 // indirect 25 47 github.com/mattn/go-isatty v0.0.20 // indirect 26 48 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 49 + github.com/minio/sha256-simd v1.0.1 // indirect 27 50 github.com/mr-tron/base58 v1.2.0 // indirect 51 + github.com/multiformats/go-base32 v0.1.0 // indirect 52 + github.com/multiformats/go-base36 v0.2.0 // indirect 53 + github.com/multiformats/go-multibase v0.2.0 // indirect 54 + github.com/multiformats/go-multihash v0.2.3 // indirect 55 + github.com/multiformats/go-varint v0.0.7 // indirect 56 + github.com/opentracing/opentracing-go v1.2.0 // indirect 28 57 github.com/pmezard/go-difflib v1.0.0 // indirect 58 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 29 59 github.com/prometheus/client_golang v1.17.0 // indirect 30 60 github.com/prometheus/client_model v0.5.0 // indirect 31 61 github.com/prometheus/common v0.45.0 // indirect 32 62 github.com/prometheus/procfs v0.12.0 // indirect 63 + github.com/spaolacci/murmur3 v1.1.0 // indirect 64 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 33 65 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 34 66 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 67 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 68 + go.opentelemetry.io/otel v1.21.0 // indirect 69 + go.opentelemetry.io/otel/metric v1.21.0 // indirect 70 + go.opentelemetry.io/otel/trace v1.21.0 // indirect 71 + go.uber.org/atomic v1.11.0 // indirect 72 + go.uber.org/multierr v1.11.0 // indirect 73 + go.uber.org/zap v1.26.0 // indirect 35 74 golang.org/x/crypto v0.40.0 // indirect 36 75 golang.org/x/sys v0.36.0 // indirect 37 76 golang.org/x/time v0.3.0 // indirect 77 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 38 78 google.golang.org/protobuf v1.33.0 // indirect 39 79 gopkg.in/yaml.v3 v3.0.1 // indirect 80 + lukechampine.com/blake3 v1.2.1 // indirect 40 81 )
+160
go.sum
··· 1 + github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 1 2 github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= 2 3 github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= 4 + github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 3 5 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 6 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 7 github.com/bluesky-social/indigo v0.0.0-20260106221649-6fcd9317e725 h1:gfrLAhE6PHun4MDypO/5hpnaHPd9Dbe9+JxZL0gC4ic= ··· 7 9 github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 8 10 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 11 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 12 + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 13 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 14 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 15 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 16 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 13 17 github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 18 + github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 19 + github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 20 + github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 21 + github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 22 + github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 23 + github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 24 + github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 25 + github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 14 26 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 27 + github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 28 + github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 15 29 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 16 30 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 17 31 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= ··· 19 33 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 20 34 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 21 35 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 36 + github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 37 + github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= 38 + github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 39 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 40 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 22 41 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 23 42 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 43 + github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 44 + github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 45 + github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 46 + github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 47 + github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 48 + github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 49 + github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 50 + github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 24 51 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 25 52 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 53 + github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 54 + github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 55 + github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 56 + github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 26 57 github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 27 58 github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 59 + github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 60 + github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 61 + github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 62 + github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 63 + github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 64 + github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 65 + github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 66 + github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 67 + github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 68 + github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 69 + github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= 70 + github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= 71 + github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= 72 + github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= 73 + github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 74 + github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 75 + github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 76 + github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= 77 + github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= 78 + github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= 79 + github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 80 + github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 81 + github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 82 + github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 83 + github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 84 + github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 85 + github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 86 + github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 28 87 github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= 29 88 github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 30 89 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 31 90 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 91 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 32 92 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 33 93 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 94 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 95 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 34 96 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 35 97 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 36 98 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 37 99 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 100 + github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 38 101 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 39 102 github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 40 103 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= ··· 55 118 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 56 119 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 57 120 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 121 + github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 122 + github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 123 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 58 124 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 59 125 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 126 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 127 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 128 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 61 129 github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= 62 130 github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= 63 131 github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= ··· 66 134 github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 67 135 github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 68 136 github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 137 + github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 69 138 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 70 139 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 71 140 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 72 141 github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 73 142 github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 143 + github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 144 + github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 145 + github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 146 + github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 147 + github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= 148 + github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 74 149 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 75 150 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 151 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 152 + github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 153 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 154 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 155 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 76 156 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 77 157 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 158 + github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 159 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 160 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 78 161 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 79 162 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 163 + github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 164 + github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 165 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 80 166 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 81 167 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 82 168 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 83 169 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 84 170 go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA= 85 171 go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= 172 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 173 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 174 + go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= 175 + go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= 176 + go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= 177 + go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= 178 + go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= 179 + go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= 180 + go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 181 + go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 182 + go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 183 + go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 184 + go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 185 + go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 186 + go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 187 + go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 188 + go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 189 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 190 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 191 + go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 192 + go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 193 + go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 194 + go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 195 + go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 196 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 197 + golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 198 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 199 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 86 200 golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 87 201 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 202 + golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 203 + golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 204 + golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 205 + golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 206 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 207 + golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 208 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 209 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 210 + golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 211 + golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 212 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 213 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 214 + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 215 + golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 216 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 88 217 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 89 218 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 219 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 220 + golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 221 + golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 222 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 223 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 224 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 225 + golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 90 226 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 227 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 228 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 229 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 93 230 golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 94 231 golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 232 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 233 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 234 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 95 235 golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 96 236 golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 237 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 238 + golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 239 + golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 240 + golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 241 + golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 242 + golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 243 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 244 + golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 245 + golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 246 + golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 247 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 248 + golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 97 249 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 250 + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 98 251 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 99 252 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 100 253 google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 101 254 google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 102 255 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 256 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 103 257 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 104 258 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 259 + gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 260 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 261 + gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 262 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 263 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 105 264 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 106 265 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 266 + honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 107 267 lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 108 268 lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
+75
internal/database/boltstore/join_store.go
··· 1 + package boltstore 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "time" 7 + 8 + bolt "go.etcd.io/bbolt" 9 + ) 10 + 11 + // JoinRequest represents a request to join the PDS. 12 + type JoinRequest struct { 13 + ID string `json:"id"` 14 + Email string `json:"email"` 15 + PreferredHandle string `json:"preferred_handle,omitempty"` 16 + Message string `json:"message,omitempty"` 17 + CreatedAt time.Time `json:"created_at"` 18 + IP string `json:"ip"` 19 + } 20 + 21 + // JoinStore provides persistent storage for join requests. 22 + type JoinStore struct { 23 + db *bolt.DB 24 + } 25 + 26 + // SaveRequest stores a join request in BoltDB. 27 + func (s *JoinStore) SaveRequest(req *JoinRequest) error { 28 + return s.db.Update(func(tx *bolt.Tx) error { 29 + bucket := tx.Bucket(BucketJoinRequests) 30 + if bucket == nil { 31 + return fmt.Errorf("bucket not found: %s", BucketJoinRequests) 32 + } 33 + 34 + data, err := json.Marshal(req) 35 + if err != nil { 36 + return fmt.Errorf("failed to marshal join request: %w", err) 37 + } 38 + 39 + return bucket.Put([]byte(req.ID), data) 40 + }) 41 + } 42 + 43 + // DeleteRequest removes a join request by ID. 44 + func (s *JoinStore) DeleteRequest(id string) error { 45 + return s.db.Update(func(tx *bolt.Tx) error { 46 + bucket := tx.Bucket(BucketJoinRequests) 47 + if bucket == nil { 48 + return nil 49 + } 50 + return bucket.Delete([]byte(id)) 51 + }) 52 + } 53 + 54 + // ListRequests returns all stored join requests. 55 + func (s *JoinStore) ListRequests() ([]*JoinRequest, error) { 56 + var requests []*JoinRequest 57 + 58 + err := s.db.View(func(tx *bolt.Tx) error { 59 + bucket := tx.Bucket(BucketJoinRequests) 60 + if bucket == nil { 61 + return nil 62 + } 63 + 64 + return bucket.ForEach(func(k, v []byte) error { 65 + var req JoinRequest 66 + if err := json.Unmarshal(v, &req); err != nil { 67 + return fmt.Errorf("failed to unmarshal join request: %w", err) 68 + } 69 + requests = append(requests, &req) 70 + return nil 71 + }) 72 + }) 73 + 74 + return requests, err 75 + }
+9
internal/database/boltstore/store.go
··· 40 40 41 41 // BucketModerationAuditLog stores moderation action audit trail 42 42 BucketModerationAuditLog = []byte("moderation_audit_log") 43 + 44 + // BucketJoinRequests stores PDS account join requests 45 + BucketJoinRequests = []byte("join_requests") 43 46 ) 44 47 45 48 // Store wraps a BoltDB database and provides access to specialized stores. ··· 111 114 BucketModerationReportsByURI, 112 115 BucketModerationReportsByDID, 113 116 BucketModerationAuditLog, 117 + BucketJoinRequests, 114 118 } 115 119 116 120 for _, bucket := range buckets { ··· 156 160 // ModerationStore returns a moderation store backed by this database. 157 161 func (s *Store) ModerationStore() *ModerationStore { 158 162 return &ModerationStore{db: s.db} 163 + } 164 + 165 + // JoinStore returns a join request store backed by this database. 166 + func (s *Store) JoinStore() *JoinStore { 167 + return &JoinStore{db: s.db} 159 168 } 160 169 161 170 // Stats returns database statistics.
+122
internal/email/email.go
··· 1 + package email 2 + 3 + import ( 4 + "crypto/tls" 5 + "fmt" 6 + "net" 7 + "net/smtp" 8 + "strconv" 9 + ) 10 + 11 + // Config holds SMTP configuration for sending email. 12 + type Config struct { 13 + Host string 14 + Port int 15 + User string 16 + Pass string 17 + From string 18 + AdminEmail string 19 + } 20 + 21 + // Sender sends email via SMTP. 22 + type Sender struct { 23 + cfg Config 24 + } 25 + 26 + // NewSender creates a new email Sender. 27 + func NewSender(cfg Config) *Sender { 28 + return &Sender{cfg: cfg} 29 + } 30 + 31 + // Enabled returns true if SMTP is configured. 32 + func (s *Sender) Enabled() bool { 33 + return s.cfg.Host != "" 34 + } 35 + 36 + // AdminEmail returns the configured admin email address. 37 + func (s *Sender) AdminEmail() string { 38 + return s.cfg.AdminEmail 39 + } 40 + 41 + // Send sends an email to the given recipient. 42 + func (s *Sender) Send(to, subject, body string) error { 43 + if !s.Enabled() { 44 + return nil 45 + } 46 + 47 + addr := net.JoinHostPort(s.cfg.Host, strconv.Itoa(s.cfg.Port)) 48 + msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", 49 + s.cfg.From, to, subject, body) 50 + 51 + var auth smtp.Auth 52 + if s.cfg.User != "" { 53 + auth = smtp.PlainAuth("", s.cfg.User, s.cfg.Pass, s.cfg.Host) 54 + } 55 + 56 + if s.cfg.Port == 465 { 57 + return s.sendImplicitTLS(addr, auth, msg, to) 58 + } 59 + return s.sendSTARTTLS(addr, auth, msg, to) 60 + } 61 + 62 + // sendImplicitTLS connects over TLS directly (port 465). 63 + func (s *Sender) sendImplicitTLS(addr string, auth smtp.Auth, msg, to string) error { 64 + tlsConfig := &tls.Config{ServerName: s.cfg.Host} 65 + conn, err := tls.Dial("tcp", addr, tlsConfig) 66 + if err != nil { 67 + return fmt.Errorf("tls dial: %w", err) 68 + } 69 + 70 + client, err := smtp.NewClient(conn, s.cfg.Host) 71 + if err != nil { 72 + conn.Close() 73 + return fmt.Errorf("smtp new client: %w", err) 74 + } 75 + defer client.Close() 76 + 77 + return s.sendWithClient(client, auth, msg, to) 78 + } 79 + 80 + // sendSTARTTLS connects in plaintext then upgrades via STARTTLS. 81 + func (s *Sender) sendSTARTTLS(addr string, auth smtp.Auth, msg, to string) error { 82 + client, err := smtp.Dial(addr) 83 + if err != nil { 84 + return fmt.Errorf("smtp dial: %w", err) 85 + } 86 + defer client.Close() 87 + 88 + if ok, _ := client.Extension("STARTTLS"); ok { 89 + tlsConfig := &tls.Config{ServerName: s.cfg.Host} 90 + if err := client.StartTLS(tlsConfig); err != nil { 91 + return fmt.Errorf("starttls: %w", err) 92 + } 93 + } 94 + 95 + return s.sendWithClient(client, auth, msg, to) 96 + } 97 + 98 + // sendWithClient performs the SMTP conversation on an established client. 99 + func (s *Sender) sendWithClient(client *smtp.Client, auth smtp.Auth, msg, to string) error { 100 + if auth != nil { 101 + if err := client.Auth(auth); err != nil { 102 + return fmt.Errorf("smtp auth: %w", err) 103 + } 104 + } 105 + if err := client.Mail(s.cfg.From); err != nil { 106 + return fmt.Errorf("smtp mail: %w", err) 107 + } 108 + if err := client.Rcpt(to); err != nil { 109 + return fmt.Errorf("smtp rcpt: %w", err) 110 + } 111 + w, err := client.Data() 112 + if err != nil { 113 + return fmt.Errorf("smtp data: %w", err) 114 + } 115 + if _, err := w.Write([]byte(msg)); err != nil { 116 + return fmt.Errorf("smtp write: %w", err) 117 + } 118 + if err := w.Close(); err != nil { 119 + return fmt.Errorf("smtp close data: %w", err) 120 + } 121 + return client.Quit() 122 + }
+10
internal/handlers/admin.go
··· 7 7 "time" 8 8 9 9 "arabica/internal/atproto" 10 + "arabica/internal/database/boltstore" 10 11 "arabica/internal/middleware" 11 12 "arabica/internal/moderation" 12 13 "arabica/internal/web/components" ··· 204 205 blockedUsers, _ = h.moderationStore.ListBlacklistedUsers(ctx) 205 206 } 206 207 208 + isAdmin := h.moderationService.IsAdmin(userDID) 209 + 210 + var joinRequests []*boltstore.JoinRequest 211 + if isAdmin && h.joinStore != nil { 212 + joinRequests, _ = h.joinStore.ListRequests() 213 + } 214 + 207 215 return pages.AdminProps{ 208 216 HiddenRecords: hiddenRecords, 209 217 AuditLog: auditLog, 210 218 Reports: enrichedReports, 211 219 BlockedUsers: blockedUsers, 220 + JoinRequests: joinRequests, 212 221 CanHide: canHide, 213 222 CanUnhide: canUnhide, 214 223 CanViewLogs: canViewLogs, 215 224 CanViewReports: canViewReports, 216 225 CanBlock: canBlock, 217 226 CanUnblock: canUnblock, 227 + IsAdmin: isAdmin, 218 228 } 219 229 } 220 230
+214
internal/handlers/handlers.go
··· 13 13 "arabica/internal/atproto" 14 14 "arabica/internal/database" 15 15 "arabica/internal/database/boltstore" 16 + "arabica/internal/email" 16 17 "arabica/internal/feed" 17 18 "arabica/internal/firehose" 18 19 "arabica/internal/middleware" ··· 22 23 "arabica/internal/web/components" 23 24 "arabica/internal/web/pages" 24 25 26 + comatproto "github.com/bluesky-social/indigo/api/atproto" 27 + "github.com/bluesky-social/indigo/xrpc" 25 28 "github.com/rs/zerolog/log" 26 29 "golang.org/x/sync/errgroup" 27 30 ) ··· 51 54 // Moderation dependencies (optional) 52 55 moderationService *moderation.Service 53 56 moderationStore *boltstore.ModerationStore 57 + 58 + // Join request dependencies (optional) 59 + emailSender *email.Sender 60 + joinStore *boltstore.JoinStore 61 + pdsAdminURL string 62 + pdsAdminToken string 54 63 } 55 64 56 65 // NewHandler creates a new Handler with all required dependencies. ··· 82 91 func (h *Handler) SetModeration(svc *moderation.Service, store *boltstore.ModerationStore) { 83 92 h.moderationService = svc 84 93 h.moderationStore = store 94 + } 95 + 96 + // SetJoin configures the handler with email sender and join request store 97 + func (h *Handler) SetJoin(sender *email.Sender, store *boltstore.JoinStore, pdsURL, pdsAdminToken string) { 98 + h.emailSender = sender 99 + h.joinStore = store 100 + h.pdsAdminURL = pdsURL 101 + h.pdsAdminToken = pdsAdminToken 85 102 } 86 103 87 104 // validateRKey validates and returns an rkey from a path parameter. ··· 1920 1937 http.Error(w, "Failed to render page", http.StatusInternalServerError) 1921 1938 log.Error().Err(err).Msg("Failed to render terms page") 1922 1939 } 1940 + } 1941 + 1942 + // HandleJoin renders the join request page. 1943 + func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) { 1944 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 1945 + isAuthenticated := err == nil && didStr != "" 1946 + 1947 + var userProfile *bff.UserProfile 1948 + if isAuthenticated { 1949 + userProfile = h.getUserProfile(r.Context(), didStr) 1950 + } 1951 + 1952 + layoutData := h.buildLayoutData(r, "Join Arabica", isAuthenticated, didStr, userProfile) 1953 + 1954 + if err := pages.Join(layoutData).Render(r.Context(), w); err != nil { 1955 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 1956 + log.Error().Err(err).Msg("Failed to render join page") 1957 + } 1958 + } 1959 + 1960 + // HandleJoinSubmit processes a join request form submission. 1961 + func (h *Handler) HandleJoinSubmit(w http.ResponseWriter, r *http.Request) { 1962 + if err := r.ParseForm(); err != nil { 1963 + http.Error(w, "Invalid form data", http.StatusBadRequest) 1964 + return 1965 + } 1966 + 1967 + // Honeypot check — if the hidden field is filled, silently reject 1968 + if r.FormValue("website") != "" { 1969 + // Show success page anyway so bots don't know they were caught 1970 + h.renderJoinSuccess(w, r) 1971 + return 1972 + } 1973 + 1974 + emailAddr := strings.TrimSpace(r.FormValue("email")) 1975 + handle := strings.TrimSpace(r.FormValue("handle")) 1976 + message := strings.TrimSpace(r.FormValue("message")) 1977 + 1978 + // Basic email validation 1979 + if emailAddr == "" || !strings.Contains(emailAddr, "@") || !strings.Contains(emailAddr, ".") { 1980 + http.Error(w, "A valid email address is required", http.StatusBadRequest) 1981 + return 1982 + } 1983 + 1984 + // Create and save the join request 1985 + req := &boltstore.JoinRequest{ 1986 + ID: fmt.Sprintf("%d", time.Now().UnixNano()), 1987 + Email: emailAddr, 1988 + PreferredHandle: handle, 1989 + Message: message, 1990 + CreatedAt: time.Now().UTC(), 1991 + IP: r.RemoteAddr, 1992 + } 1993 + 1994 + if h.joinStore != nil { 1995 + if err := h.joinStore.SaveRequest(req); err != nil { 1996 + log.Error().Err(err).Str("email", emailAddr).Msg("Failed to save join request") 1997 + http.Error(w, "Failed to save request, please try again", http.StatusInternalServerError) 1998 + return 1999 + } 2000 + log.Info().Str("email", emailAddr).Str("handle", handle).Msg("Join request saved") 2001 + } 2002 + 2003 + // Send admin notification email (non-blocking) 2004 + if h.emailSender != nil && h.emailSender.Enabled() { 2005 + go func() { 2006 + subject := "New Arabica Join Request" 2007 + body := fmt.Sprintf("New account request:\n\nEmail: %s\nPreferred Handle: %s\nMessage: %s\nIP: %s\nTime: %s\n", 2008 + req.Email, req.PreferredHandle, req.Message, req.IP, req.CreatedAt.Format(time.RFC3339)) 2009 + 2010 + if err := h.emailSender.Send(h.emailSender.AdminEmail(), subject, body); err != nil { 2011 + log.Error().Err(err).Str("email", emailAddr).Msg("Failed to send admin notification") 2012 + } 2013 + }() 2014 + } 2015 + 2016 + h.renderJoinSuccess(w, r) 2017 + } 2018 + 2019 + func (h *Handler) renderJoinSuccess(w http.ResponseWriter, r *http.Request) { 2020 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 2021 + isAuthenticated := err == nil && didStr != "" 2022 + 2023 + var userProfile *bff.UserProfile 2024 + if isAuthenticated { 2025 + userProfile = h.getUserProfile(r.Context(), didStr) 2026 + } 2027 + 2028 + layoutData := h.buildLayoutData(r, "Request Received", isAuthenticated, didStr, userProfile) 2029 + 2030 + if err := pages.JoinSuccess(layoutData).Render(r.Context(), w); err != nil { 2031 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 2032 + log.Error().Err(err).Msg("Failed to render join success page") 2033 + } 2034 + } 2035 + 2036 + // HandleCreateInvite creates a PDS invite code and emails it to the requester. 2037 + func (h *Handler) HandleCreateInvite(w http.ResponseWriter, r *http.Request) { 2038 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 2039 + if err != nil || userDID == "" { 2040 + http.Error(w, "Authentication required", http.StatusUnauthorized) 2041 + return 2042 + } 2043 + if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 2044 + http.Error(w, "Access denied", http.StatusForbidden) 2045 + return 2046 + } 2047 + 2048 + if err := r.ParseForm(); err != nil { 2049 + http.Error(w, "Invalid request", http.StatusBadRequest) 2050 + return 2051 + } 2052 + reqID := r.FormValue("id") 2053 + reqEmail := r.FormValue("email") 2054 + if reqID == "" || reqEmail == "" { 2055 + http.Error(w, "Missing request ID or email", http.StatusBadRequest) 2056 + return 2057 + } 2058 + 2059 + if h.pdsAdminURL == "" || h.pdsAdminToken == "" { 2060 + http.Error(w, "PDS admin not configured", http.StatusInternalServerError) 2061 + return 2062 + } 2063 + 2064 + // Create invite code via PDS admin API 2065 + client := &xrpc.Client{ 2066 + Host: h.pdsAdminURL, 2067 + AdminToken: &h.pdsAdminToken, 2068 + } 2069 + out, err := comatproto.ServerCreateInviteCode(r.Context(), client, &comatproto.ServerCreateInviteCode_Input{ 2070 + UseCount: 1, 2071 + }) 2072 + if err != nil { 2073 + log.Error().Err(err).Str("email", reqEmail).Msg("Failed to create invite code") 2074 + http.Error(w, "Failed to create invite code", http.StatusInternalServerError) 2075 + return 2076 + } 2077 + 2078 + log.Info().Str("email", reqEmail).Str("code", out.Code).Str("by", userDID).Msg("Invite code created") 2079 + 2080 + // Email the invite code to the requester 2081 + if h.emailSender != nil && h.emailSender.Enabled() { 2082 + subject := "Your Arabica Invite Code" 2083 + body := fmt.Sprintf("Welcome to Arabica!\n\nHere is your invite code to create an account on arabica.systems:\n\n %s\n\nVisit https://arabica.systems to sign up with this code.\n\nHappy brewing!\n", out.Code) 2084 + if err := h.emailSender.Send(reqEmail, subject, body); err != nil { 2085 + log.Error().Err(err).Str("email", reqEmail).Msg("Failed to send invite email") 2086 + http.Error(w, "Invite created but failed to send email. Code: "+out.Code, http.StatusInternalServerError) 2087 + return 2088 + } 2089 + log.Info().Str("email", reqEmail).Msg("Invite code emailed") 2090 + } 2091 + 2092 + // Remove the join request 2093 + if h.joinStore != nil { 2094 + if err := h.joinStore.DeleteRequest(reqID); err != nil { 2095 + log.Error().Err(err).Str("id", reqID).Msg("Failed to delete join request") 2096 + } 2097 + } 2098 + 2099 + w.Header().Set("HX-Trigger", "mod-action") 2100 + w.WriteHeader(http.StatusOK) 2101 + } 2102 + 2103 + // HandleDismissJoinRequest removes a join request without sending an invite. 2104 + func (h *Handler) HandleDismissJoinRequest(w http.ResponseWriter, r *http.Request) { 2105 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 2106 + if err != nil || userDID == "" { 2107 + http.Error(w, "Authentication required", http.StatusUnauthorized) 2108 + return 2109 + } 2110 + if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 2111 + http.Error(w, "Access denied", http.StatusForbidden) 2112 + return 2113 + } 2114 + 2115 + if err := r.ParseForm(); err != nil { 2116 + http.Error(w, "Invalid request", http.StatusBadRequest) 2117 + return 2118 + } 2119 + reqID := r.FormValue("id") 2120 + if reqID == "" { 2121 + http.Error(w, "Missing request ID", http.StatusBadRequest) 2122 + return 2123 + } 2124 + 2125 + if h.joinStore != nil { 2126 + if err := h.joinStore.DeleteRequest(reqID); err != nil { 2127 + log.Error().Err(err).Str("id", reqID).Msg("Failed to delete join request") 2128 + http.Error(w, "Failed to dismiss request", http.StatusInternalServerError) 2129 + return 2130 + } 2131 + } 2132 + 2133 + log.Info().Str("id", reqID).Str("by", userDID).Msg("Join request dismissed") 2134 + 2135 + w.Header().Set("HX-Trigger", "mod-action") 2136 + w.WriteHeader(http.StatusOK) 1923 2137 } 1924 2138 1925 2139 func (h *Handler) HandleATProto(w http.ResponseWriter, r *http.Request) {
+4
internal/routing/routing.go
··· 53 53 mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match 54 54 mux.HandleFunc("GET /about", h.HandleAbout) 55 55 mux.HandleFunc("GET /terms", h.HandleTerms) 56 + mux.HandleFunc("GET /join", h.HandleJoin) 57 + mux.Handle("POST /join", cop.Handler(http.HandlerFunc(h.HandleJoinSubmit))) 56 58 mux.HandleFunc("GET /atproto", h.HandleATProto) 57 59 mux.HandleFunc("GET /manage", h.HandleManage) 58 60 mux.HandleFunc("GET /brews", h.HandleBrewList) ··· 92 94 mux.Handle("POST /_mod/dismiss-report", cop.Handler(http.HandlerFunc(h.HandleDismissReport))) 93 95 mux.Handle("POST /_mod/block", cop.Handler(http.HandlerFunc(h.HandleBlockUser))) 94 96 mux.Handle("POST /_mod/unblock", cop.Handler(http.HandlerFunc(h.HandleUnblockUser))) 97 + mux.Handle("POST /_mod/invite", cop.Handler(http.HandlerFunc(h.HandleCreateInvite))) 98 + mux.Handle("POST /_mod/dismiss-join", cop.Handler(http.HandlerFunc(h.HandleDismissJoinRequest))) 95 99 96 100 // Modal routes for entity management (return dialog HTML) 97 101 mux.HandleFunc("GET /api/modals/bean/new", h.HandleBeanModalNew)
+87
internal/web/pages/admin.templ
··· 1 1 package pages 2 2 3 3 import ( 4 + "arabica/internal/database/boltstore" 4 5 "arabica/internal/moderation" 5 6 "arabica/internal/web/components" 6 7 "fmt" ··· 19 20 AuditLog []moderation.AuditEntry 20 21 Reports []EnrichedReport 21 22 BlockedUsers []moderation.BlacklistedUser 23 + JoinRequests []*boltstore.JoinRequest 22 24 CanHide bool 23 25 CanUnhide bool 24 26 CanViewLogs bool 25 27 CanViewReports bool 26 28 CanBlock bool 27 29 CanUnblock bool 30 + IsAdmin bool 28 31 } 29 32 30 33 templ Admin(layout *components.LayoutData, props AdminProps) { ··· 115 118 Activity Log 116 119 </button> 117 120 } 121 + if props.IsAdmin { 122 + <button 123 + type="button" 124 + @click="activeTab = 'join'" 125 + :class="activeTab === 'join' ? 'border-amber-500 text-amber-600' : 'border-transparent text-brown-500 hover:text-brown-700 hover:border-brown-300'" 126 + class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors" 127 + > 128 + Join Requests 129 + if len(props.JoinRequests) > 0 { 130 + <span class="ml-2 bg-blue-100 text-blue-700 py-0.5 px-2 rounded-full text-xs"> 131 + { fmt.Sprintf("%d", len(props.JoinRequests)) } 132 + </span> 133 + } 134 + </button> 135 + } 118 136 </nav> 119 137 </div> 120 138 ··· 196 214 } 197 215 </div> 198 216 </div> 217 + 218 + <!-- Join Requests Tab --> 219 + if props.IsAdmin { 220 + <div x-show="activeTab === 'join'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 221 + <div class="card card-inner"> 222 + <h2 class="section-title">Join Requests</h2> 223 + if len(props.JoinRequests) == 0 { 224 + <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 225 + <p>No join requests.</p> 226 + </div> 227 + } else { 228 + <div class="space-y-3"> 229 + for _, req := range props.JoinRequests { 230 + @JoinRequestCard(req) 231 + } 232 + </div> 233 + } 234 + </div> 235 + </div> 236 + } 199 237 } 200 238 </div> 201 239 } ··· 586 624 </span> 587 625 } 588 626 } 627 + 628 + templ JoinRequestCard(req *boltstore.JoinRequest) { 629 + <div class="bg-brown-50 border border-brown-200 rounded-lg p-4"> 630 + <div class="flex flex-col gap-3"> 631 + <div class="flex items-center justify-between"> 632 + <span class="font-medium text-brown-900">{ req.Email }</span> 633 + <span class="text-sm text-brown-500">{ req.CreatedAt.Format("Jan 2, 2006 15:04") }</span> 634 + </div> 635 + <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 636 + if req.PreferredHandle != "" { 637 + <div> 638 + <span class="text-brown-500">Handle:</span> 639 + <span class="text-brown-700 ml-1">{ req.PreferredHandle }</span> 640 + </div> 641 + } 642 + <div> 643 + <span class="text-brown-500">IP:</span> 644 + <code class="text-brown-700 ml-1 text-xs">{ req.IP }</code> 645 + </div> 646 + </div> 647 + if req.Message != "" { 648 + <div> 649 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Message</span> 650 + <p class="mt-1 text-sm text-brown-700">{ req.Message }</p> 651 + </div> 652 + } 653 + <div class="pt-2 border-t border-brown-200 flex flex-wrap gap-3"> 654 + <button 655 + class="text-sm bg-green-100 text-green-700 hover:bg-green-200 px-3 py-1.5 rounded font-medium transition-colors" 656 + hx-post="/_mod/invite" 657 + hx-vals={ fmt.Sprintf(`{"id": "%s", "email": "%s"}`, req.ID, req.Email) } 658 + hx-swap="none" 659 + hx-confirm={ fmt.Sprintf("Create invite code and send to %s?", req.Email) } 660 + > 661 + Send Invite 662 + </button> 663 + <button 664 + class="text-sm text-brown-600 hover:text-brown-800 px-3 py-1.5 rounded font-medium transition-colors" 665 + hx-post="/_mod/dismiss-join" 666 + hx-vals={ fmt.Sprintf(`{"id": "%s"}`, req.ID) } 667 + hx-swap="none" 668 + hx-confirm="Dismiss this join request?" 669 + > 670 + Dismiss 671 + </button> 672 + </div> 673 + </div> 674 + </div> 675 + }
+95
internal/web/pages/join.templ
··· 1 + package pages 2 + 3 + import "arabica/internal/web/components" 4 + 5 + // Join renders the join request page with layout. 6 + templ Join(layout *components.LayoutData) { 7 + @components.Layout(layout, JoinContent()) 8 + } 9 + 10 + // JoinContent renders the join request form. 11 + templ JoinContent() { 12 + <div class="page-container-md"> 13 + <div class="flex items-center gap-3 mb-8"> 14 + @components.BackButton() 15 + <h1 class="text-4xl font-bold text-brown-900">Join Arabica</h1> 16 + </div> 17 + <p class="text-brown-800 leading-relaxed mb-6"> 18 + Arabica uses a Personal Data Server (PDS) at <strong>arabica.systems</strong> to store your brew data. 19 + Request an account below and we'll send you an invite code once approved. 20 + </p> 21 + <div class="card card-inner"> 22 + <form method="POST" action="/join" class="space-y-5"> 23 + @components.FormField(components.FormFieldProps{ 24 + Label: "Email", 25 + Required: true, 26 + }, components.TextInput(components.TextInputProps{ 27 + Name: "email", 28 + Type: "email", 29 + Placeholder: "you@example.com", 30 + Required: true, 31 + })) 32 + @components.FormField(components.FormFieldProps{ 33 + Label: "Preferred Handle", 34 + HelperText: "Your desired @handle.arabica.systems username", 35 + }, components.TextInput(components.TextInputProps{ 36 + Name: "handle", 37 + Placeholder: "yourname", 38 + })) 39 + @components.FormField(components.FormFieldProps{ 40 + Label: "Why do you want to join?", 41 + HelperText: "Optional — a short note helps us prioritize requests", 42 + }, components.TextArea(components.TextAreaProps{ 43 + Name: "message", 44 + Placeholder: "I love coffee and want to track my brews...", 45 + Rows: 3, 46 + })) 47 + <!-- Honeypot field — hidden from real users --> 48 + <div style="display:none" aria-hidden="true"> 49 + <label for="website">Website</label> 50 + <input type="text" name="website" id="website" tabindex="-1" autocomplete="off"/> 51 + </div> 52 + <div class="pt-2"> 53 + @components.PrimaryButton(components.ButtonProps{ 54 + Text: "Request Account", 55 + Type: "submit", 56 + }) 57 + </div> 58 + </form> 59 + </div> 60 + <p class="text-sm text-brown-600 mt-6 text-center"> 61 + Already have an AT Protocol account? 62 + <a href="/login" class="link-bold">Log in here</a>. 63 + </p> 64 + </div> 65 + } 66 + 67 + // JoinSuccess renders the confirmation page after a successful submission. 68 + templ JoinSuccess(layout *components.LayoutData) { 69 + @components.Layout(layout, JoinSuccessContent()) 70 + } 71 + 72 + // JoinSuccessContent renders the success message. 73 + templ JoinSuccessContent() { 74 + <div class="page-container-md"> 75 + <div class="flex items-center gap-3 mb-8"> 76 + @components.BackButton() 77 + <h1 class="text-4xl font-bold text-brown-900">Request Received</h1> 78 + </div> 79 + <div class="card card-inner text-center py-12"> 80 + <svg class="w-16 h-16 mx-auto mb-6 text-green-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 81 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path> 82 + </svg> 83 + <h2 class="text-2xl font-semibold text-brown-900 mb-4">Thanks for your interest!</h2> 84 + <p class="text-brown-800 leading-relaxed mb-2"> 85 + We've received your request and will review it shortly. 86 + </p> 87 + <p class="text-brown-700 text-sm mb-8"> 88 + You'll receive an invite code by email once your account is ready. 89 + </p> 90 + <a href="/" class="btn-primary px-8 py-3 font-semibold shadow-lg hover:shadow-xl"> 91 + Back to Home 92 + </a> 93 + </div> 94 + </div> 95 + }
+1
justfile
··· 3 3 4 4 run: 5 5 @LOG_LEVEL=debug LOG_FORMAT=console ARABICA_MODERATORS_CONFIG=roles.json go run cmd/server/main.go -known-dids known-dids.txt 6 + # @bash scripts/run.sh 6 7 7 8 templ-watch: 8 9 @templ generate --watch --proxy="http://localhost:18080" --cmd="go run ./cmd/server -known-dids known-dids.txt"