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

feat: pds account requests and creation (#12)

* feat: pds invite code request/generation

* feat: create account page

* feat: improve styling of create/join pages

* fix: mobile ui improvements

authored by

Patrick Dewey and committed by
GitHub
bcd5fada d7f40e5b

+1188 -60
+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
+1 -1
default.nix
··· 4 4 pname = "arabica"; 5 5 version = "0.1.0"; 6 6 src = ./.; 7 - vendorHash = "sha256-bJ+EdUabR8WR98tJ2d8F2TmHkswfC/bk7eyZ3udIZko="; 7 + vendorHash = "sha256-CD6i6qocJ2E5TK7Xprw4bBYmAfZKcy0vMi/krKaMTS8="; 8 8 9 9 nativeBuildInputs = [ templ tailwindcss ]; 10 10
-34
docs/indigo-research.md
··· 1 - # AT Protocol Integration 2 - 3 - ## Overview 4 - 5 - Arabica uses the Bluesky indigo SDK for AT Protocol integration. 6 - 7 - **Package:** `github.com/bluesky-social/indigo` 8 - 9 - ## Key Components 10 - 11 - ### OAuth Authentication 12 - 13 - - Public OAuth client with PKCE 14 - - DPOP-bound access tokens 15 - - Scopes: `atproto`, `transition:generic` 16 - - Session persistence via BoltDB 17 - 18 - ### Record Operations 19 - 20 - Standard AT Protocol record CRUD operations: 21 - - `com.atproto.repo.createRecord` 22 - - `com.atproto.repo.getRecord` 23 - - `com.atproto.repo.listRecords` 24 - - `com.atproto.repo.putRecord` 25 - - `com.atproto.repo.deleteRecord` 26 - 27 - ### Client Implementation 28 - 29 - See `internal/atproto/client.go` for the XRPC client wrapper. 30 - 31 - ## References 32 - 33 - - indigo SDK: https://github.com/bluesky-social/indigo 34 - - AT Protocol docs: https://atproto.com
+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=
+74
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 + Message string `json:"message,omitempty"` 16 + CreatedAt time.Time `json:"created_at"` 17 + IP string `json:"ip"` 18 + } 19 + 20 + // JoinStore provides persistent storage for join requests. 21 + type JoinStore struct { 22 + db *bolt.DB 23 + } 24 + 25 + // SaveRequest stores a join request in BoltDB. 26 + func (s *JoinStore) SaveRequest(req *JoinRequest) error { 27 + return s.db.Update(func(tx *bolt.Tx) error { 28 + bucket := tx.Bucket(BucketJoinRequests) 29 + if bucket == nil { 30 + return fmt.Errorf("bucket not found: %s", BucketJoinRequests) 31 + } 32 + 33 + data, err := json.Marshal(req) 34 + if err != nil { 35 + return fmt.Errorf("failed to marshal join request: %w", err) 36 + } 37 + 38 + return bucket.Put([]byte(req.ID), data) 39 + }) 40 + } 41 + 42 + // DeleteRequest removes a join request by ID. 43 + func (s *JoinStore) DeleteRequest(id string) error { 44 + return s.db.Update(func(tx *bolt.Tx) error { 45 + bucket := tx.Bucket(BucketJoinRequests) 46 + if bucket == nil { 47 + return nil 48 + } 49 + return bucket.Delete([]byte(id)) 50 + }) 51 + } 52 + 53 + // ListRequests returns all stored join requests. 54 + func (s *JoinStore) ListRequests() ([]*JoinRequest, error) { 55 + var requests []*JoinRequest 56 + 57 + err := s.db.View(func(tx *bolt.Tx) error { 58 + bucket := tx.Bucket(BucketJoinRequests) 59 + if bucket == nil { 60 + return nil 61 + } 62 + 63 + return bucket.ForEach(func(k, v []byte) error { 64 + var req JoinRequest 65 + if err := json.Unmarshal(v, &req); err != nil { 66 + return fmt.Errorf("failed to unmarshal join request: %w", err) 67 + } 68 + requests = append(requests, &req) 69 + return nil 70 + }) 71 + }) 72 + 73 + return requests, err 74 + }
+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.
+137
internal/email/email.go
··· 1 + package email 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/tls" 6 + "fmt" 7 + "net" 8 + "net/smtp" 9 + "strconv" 10 + "strings" 11 + "time" 12 + ) 13 + 14 + // Config holds SMTP configuration for sending email. 15 + type Config struct { 16 + Host string 17 + Port int 18 + User string 19 + Pass string 20 + From string 21 + AdminEmail string 22 + } 23 + 24 + // Sender sends email via SMTP. 25 + type Sender struct { 26 + cfg Config 27 + } 28 + 29 + // NewSender creates a new email Sender. 30 + func NewSender(cfg Config) *Sender { 31 + return &Sender{cfg: cfg} 32 + } 33 + 34 + // Enabled returns true if SMTP is configured. 35 + func (s *Sender) Enabled() bool { 36 + return s.cfg.Host != "" 37 + } 38 + 39 + // AdminEmail returns the configured admin email address. 40 + func (s *Sender) AdminEmail() string { 41 + return s.cfg.AdminEmail 42 + } 43 + 44 + // Send sends an email to the given recipient. 45 + func (s *Sender) Send(to, subject, body string) error { 46 + if !s.Enabled() { 47 + return nil 48 + } 49 + 50 + addr := net.JoinHostPort(s.cfg.Host, strconv.Itoa(s.cfg.Port)) 51 + 52 + // Extract domain from From address for Message-ID 53 + domain := s.cfg.Host 54 + if parts := strings.SplitN(s.cfg.From, "@", 2); len(parts) == 2 { 55 + domain = parts[1] 56 + } 57 + 58 + // Generate a random Message-ID 59 + randBytes := make([]byte, 16) 60 + rand.Read(randBytes) 61 + messageID := fmt.Sprintf("<%x.%d@%s>", randBytes, time.Now().UnixNano(), domain) 62 + 63 + msg := fmt.Sprintf("From: Arabica <%s>\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", 64 + s.cfg.From, to, subject, time.Now().UTC().Format(time.RFC1123Z), messageID, body) 65 + 66 + var auth smtp.Auth 67 + if s.cfg.User != "" { 68 + auth = smtp.PlainAuth("", s.cfg.User, s.cfg.Pass, s.cfg.Host) 69 + } 70 + 71 + if s.cfg.Port == 465 { 72 + return s.sendImplicitTLS(addr, auth, msg, to) 73 + } 74 + return s.sendSTARTTLS(addr, auth, msg, to) 75 + } 76 + 77 + // sendImplicitTLS connects over TLS directly (port 465). 78 + func (s *Sender) sendImplicitTLS(addr string, auth smtp.Auth, msg, to string) error { 79 + tlsConfig := &tls.Config{ServerName: s.cfg.Host} 80 + conn, err := tls.Dial("tcp", addr, tlsConfig) 81 + if err != nil { 82 + return fmt.Errorf("tls dial: %w", err) 83 + } 84 + 85 + client, err := smtp.NewClient(conn, s.cfg.Host) 86 + if err != nil { 87 + conn.Close() 88 + return fmt.Errorf("smtp new client: %w", err) 89 + } 90 + defer client.Close() 91 + 92 + return s.sendWithClient(client, auth, msg, to) 93 + } 94 + 95 + // sendSTARTTLS connects in plaintext then upgrades via STARTTLS. 96 + func (s *Sender) sendSTARTTLS(addr string, auth smtp.Auth, msg, to string) error { 97 + client, err := smtp.Dial(addr) 98 + if err != nil { 99 + return fmt.Errorf("smtp dial: %w", err) 100 + } 101 + defer client.Close() 102 + 103 + if ok, _ := client.Extension("STARTTLS"); ok { 104 + tlsConfig := &tls.Config{ServerName: s.cfg.Host} 105 + if err := client.StartTLS(tlsConfig); err != nil { 106 + return fmt.Errorf("starttls: %w", err) 107 + } 108 + } 109 + 110 + return s.sendWithClient(client, auth, msg, to) 111 + } 112 + 113 + // sendWithClient performs the SMTP conversation on an established client. 114 + func (s *Sender) sendWithClient(client *smtp.Client, auth smtp.Auth, msg, to string) error { 115 + if auth != nil { 116 + if err := client.Auth(auth); err != nil { 117 + return fmt.Errorf("smtp auth: %w", err) 118 + } 119 + } 120 + if err := client.Mail(s.cfg.From); err != nil { 121 + return fmt.Errorf("smtp mail: %w", err) 122 + } 123 + if err := client.Rcpt(to); err != nil { 124 + return fmt.Errorf("smtp rcpt: %w", err) 125 + } 126 + w, err := client.Data() 127 + if err != nil { 128 + return fmt.Errorf("smtp data: %w", err) 129 + } 130 + if _, err := w.Write([]byte(msg)); err != nil { 131 + return fmt.Errorf("smtp write: %w", err) 132 + } 133 + if err := w.Close(); err != nil { 134 + return fmt.Errorf("smtp close data: %w", err) 135 + } 136 + return client.Quit() 137 + }
+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
+345
internal/handlers/handlers.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "net/http" 8 9 "sort" ··· 13 14 "arabica/internal/atproto" 14 15 "arabica/internal/database" 15 16 "arabica/internal/database/boltstore" 17 + "arabica/internal/email" 16 18 "arabica/internal/feed" 17 19 "arabica/internal/firehose" 18 20 "arabica/internal/middleware" ··· 22 24 "arabica/internal/web/components" 23 25 "arabica/internal/web/pages" 24 26 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + "github.com/bluesky-social/indigo/xrpc" 25 29 "github.com/rs/zerolog/log" 26 30 "golang.org/x/sync/errgroup" 27 31 ) ··· 51 55 // Moderation dependencies (optional) 52 56 moderationService *moderation.Service 53 57 moderationStore *boltstore.ModerationStore 58 + 59 + // Join request dependencies (optional) 60 + emailSender *email.Sender 61 + joinStore *boltstore.JoinStore 62 + pdsAdminURL string 63 + pdsAdminToken string 54 64 } 55 65 56 66 // NewHandler creates a new Handler with all required dependencies. ··· 82 92 func (h *Handler) SetModeration(svc *moderation.Service, store *boltstore.ModerationStore) { 83 93 h.moderationService = svc 84 94 h.moderationStore = store 95 + } 96 + 97 + // SetJoin configures the handler with email sender and join request store 98 + func (h *Handler) SetJoin(sender *email.Sender, store *boltstore.JoinStore, pdsURL, pdsAdminToken string) { 99 + h.emailSender = sender 100 + h.joinStore = store 101 + h.pdsAdminURL = pdsURL 102 + h.pdsAdminToken = pdsAdminToken 85 103 } 86 104 87 105 // validateRKey validates and returns an rkey from a path parameter. ··· 1919 1937 if err := pages.Terms(layoutData).Render(r.Context(), w); err != nil { 1920 1938 http.Error(w, "Failed to render page", http.StatusInternalServerError) 1921 1939 log.Error().Err(err).Msg("Failed to render terms page") 1940 + } 1941 + } 1942 + 1943 + // HandleJoin renders the join request page. 1944 + func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) { 1945 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 1946 + isAuthenticated := err == nil && didStr != "" 1947 + 1948 + var userProfile *bff.UserProfile 1949 + if isAuthenticated { 1950 + userProfile = h.getUserProfile(r.Context(), didStr) 1951 + } 1952 + 1953 + layoutData := h.buildLayoutData(r, "Join Arabica", isAuthenticated, didStr, userProfile) 1954 + 1955 + if err := pages.Join(layoutData).Render(r.Context(), w); err != nil { 1956 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 1957 + log.Error().Err(err).Msg("Failed to render join page") 1958 + } 1959 + } 1960 + 1961 + // HandleJoinSubmit processes a join request form submission. 1962 + func (h *Handler) HandleJoinSubmit(w http.ResponseWriter, r *http.Request) { 1963 + if err := r.ParseForm(); err != nil { 1964 + http.Error(w, "Invalid form data", http.StatusBadRequest) 1965 + return 1966 + } 1967 + 1968 + // Honeypot check — if the hidden field is filled, silently reject 1969 + if r.FormValue("website") != "" { 1970 + // Show success page anyway so bots don't know they were caught 1971 + h.renderJoinSuccess(w, r) 1972 + return 1973 + } 1974 + 1975 + emailAddr := strings.TrimSpace(r.FormValue("email")) 1976 + handle := strings.TrimSpace(r.FormValue("handle")) 1977 + message := strings.TrimSpace(r.FormValue("message")) 1978 + 1979 + // Basic email validation 1980 + if emailAddr == "" || !strings.Contains(emailAddr, "@") || !strings.Contains(emailAddr, ".") { 1981 + http.Error(w, "A valid email address is required", http.StatusBadRequest) 1982 + return 1983 + } 1984 + 1985 + // Create and save the join request 1986 + req := &boltstore.JoinRequest{ 1987 + ID: fmt.Sprintf("%d", time.Now().UnixNano()), 1988 + Email: emailAddr, 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\nMessage: %s\nIP: %s\nTime: %s\n", 2008 + req.Email, 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 + // TODO: this should probably use the env var rather than hard coded 2084 + 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) 2085 + if err := h.emailSender.Send(reqEmail, subject, body); err != nil { 2086 + log.Error().Err(err).Str("email", reqEmail).Msg("Failed to send invite email") 2087 + http.Error(w, "Invite created but failed to send email. Code: "+out.Code, http.StatusInternalServerError) 2088 + return 2089 + } 2090 + log.Info().Str("email", reqEmail).Msg("Invite code emailed") 2091 + } 2092 + 2093 + // Remove the join request 2094 + if h.joinStore != nil { 2095 + if err := h.joinStore.DeleteRequest(reqID); err != nil { 2096 + log.Error().Err(err).Str("id", reqID).Msg("Failed to delete join request") 2097 + } 2098 + } 2099 + 2100 + w.Header().Set("HX-Trigger", "mod-action") 2101 + w.WriteHeader(http.StatusOK) 2102 + } 2103 + 2104 + // HandleDismissJoinRequest removes a join request without sending an invite. 2105 + func (h *Handler) HandleDismissJoinRequest(w http.ResponseWriter, r *http.Request) { 2106 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 2107 + if err != nil || userDID == "" { 2108 + http.Error(w, "Authentication required", http.StatusUnauthorized) 2109 + return 2110 + } 2111 + if h.moderationService == nil || !h.moderationService.IsAdmin(userDID) { 2112 + http.Error(w, "Access denied", http.StatusForbidden) 2113 + return 2114 + } 2115 + 2116 + if err := r.ParseForm(); err != nil { 2117 + http.Error(w, "Invalid request", http.StatusBadRequest) 2118 + return 2119 + } 2120 + reqID := r.FormValue("id") 2121 + if reqID == "" { 2122 + http.Error(w, "Missing request ID", http.StatusBadRequest) 2123 + return 2124 + } 2125 + 2126 + if h.joinStore != nil { 2127 + if err := h.joinStore.DeleteRequest(reqID); err != nil { 2128 + log.Error().Err(err).Str("id", reqID).Msg("Failed to delete join request") 2129 + http.Error(w, "Failed to dismiss request", http.StatusInternalServerError) 2130 + return 2131 + } 2132 + } 2133 + 2134 + log.Info().Str("id", reqID).Str("by", userDID).Msg("Join request dismissed") 2135 + 2136 + w.Header().Set("HX-Trigger", "mod-action") 2137 + w.WriteHeader(http.StatusOK) 2138 + } 2139 + 2140 + // HandleCreateAccount renders the account creation form (GET /join/create). 2141 + func (h *Handler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) { 2142 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 2143 + isAuthenticated := err == nil && didStr != "" 2144 + 2145 + var userProfile *bff.UserProfile 2146 + if isAuthenticated { 2147 + userProfile = h.getUserProfile(r.Context(), didStr) 2148 + } 2149 + 2150 + layoutData := h.buildLayoutData(r, "Create Account", isAuthenticated, didStr, userProfile) 2151 + 2152 + props := pages.CreateAccountProps{ 2153 + InviteCode: r.URL.Query().Get("code"), 2154 + HandleDomain: "arabica.systems", 2155 + } 2156 + 2157 + if err := pages.CreateAccount(layoutData, props).Render(r.Context(), w); err != nil { 2158 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 2159 + log.Error().Err(err).Msg("Failed to render create account page") 2160 + } 2161 + } 2162 + 2163 + // HandleCreateAccountSubmit processes the account creation form (POST /join/create). 2164 + func (h *Handler) HandleCreateAccountSubmit(w http.ResponseWriter, r *http.Request) { 2165 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 2166 + isAuthenticated := err == nil && didStr != "" 2167 + 2168 + var userProfile *bff.UserProfile 2169 + if isAuthenticated { 2170 + userProfile = h.getUserProfile(r.Context(), didStr) 2171 + } 2172 + 2173 + if err := r.ParseForm(); err != nil { 2174 + http.Error(w, "Invalid request", http.StatusBadRequest) 2175 + return 2176 + } 2177 + 2178 + inviteCode := strings.TrimSpace(r.FormValue("invite_code")) 2179 + handle := strings.TrimSpace(r.FormValue("handle")) 2180 + emailAddr := strings.TrimSpace(r.FormValue("email")) 2181 + password := r.FormValue("password") 2182 + passwordConfirm := r.FormValue("password_confirm") 2183 + honeypot := r.FormValue("website") 2184 + 2185 + // Honeypot check — bots fill hidden fields; show fake success 2186 + if honeypot != "" { 2187 + layoutData := h.buildLayoutData(r, "Account Created", isAuthenticated, didStr, userProfile) 2188 + _ = pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: "user.arabica.systems"}).Render(r.Context(), w) 2189 + return 2190 + } 2191 + 2192 + handleDomain := "arabica.systems" 2193 + 2194 + // Render form with error helper 2195 + renderError := func(msg string) { 2196 + layoutData := h.buildLayoutData(r, "Create Account", isAuthenticated, didStr, userProfile) 2197 + props := pages.CreateAccountProps{ 2198 + Error: msg, 2199 + InviteCode: inviteCode, 2200 + Handle: handle, 2201 + Email: emailAddr, 2202 + HandleDomain: handleDomain, 2203 + } 2204 + if err := pages.CreateAccount(layoutData, props).Render(r.Context(), w); err != nil { 2205 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 2206 + } 2207 + } 2208 + 2209 + // Validate required fields 2210 + if inviteCode == "" || handle == "" || emailAddr == "" || password == "" { 2211 + renderError("All fields are required.") 2212 + return 2213 + } 2214 + if password != passwordConfirm { 2215 + renderError("Passwords do not match.") 2216 + return 2217 + } 2218 + 2219 + // Build full handle 2220 + fullHandle := handle + "." + handleDomain 2221 + 2222 + if h.pdsAdminURL == "" { 2223 + renderError("Account creation is not available at this time.") 2224 + log.Error().Msg("PDS admin URL not configured for account creation") 2225 + return 2226 + } 2227 + 2228 + // Call PDS createAccount (public endpoint, no admin token needed) 2229 + client := &xrpc.Client{Host: h.pdsAdminURL} 2230 + out, err := comatproto.ServerCreateAccount(r.Context(), client, &comatproto.ServerCreateAccount_Input{ 2231 + Handle: fullHandle, 2232 + Email: &emailAddr, 2233 + Password: &password, 2234 + InviteCode: &inviteCode, 2235 + }) 2236 + if err != nil { 2237 + errMsg := "Account creation failed. Please try again." 2238 + var xrpcErr *xrpc.Error 2239 + if errors.As(err, &xrpcErr) { 2240 + var inner *xrpc.XRPCError 2241 + if errors.As(xrpcErr.Wrapped, &inner) { 2242 + switch inner.ErrStr { 2243 + case "InvalidInviteCode": 2244 + errMsg = "Invalid or expired invite code." 2245 + case "HandleNotAvailable": 2246 + errMsg = "This handle is already taken." 2247 + case "InvalidHandle": 2248 + errMsg = "Invalid handle format. Use only letters, numbers, and hyphens." 2249 + default: 2250 + if inner.Message != "" { 2251 + errMsg = inner.Message 2252 + } 2253 + } 2254 + } 2255 + } 2256 + log.Error().Err(err).Str("handle", fullHandle).Msg("Failed to create account") 2257 + renderError(errMsg) 2258 + return 2259 + } 2260 + 2261 + log.Info().Str("handle", out.Handle).Str("did", out.Did).Msg("Account created") 2262 + 2263 + layoutData := h.buildLayoutData(r, "Account Created", isAuthenticated, didStr, userProfile) 2264 + if err := pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: out.Handle}).Render(r.Context(), w); err != nil { 2265 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 2266 + log.Error().Err(err).Msg("Failed to render create account success page") 1922 2267 } 1923 2268 } 1924 2269
+6
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))) 58 + mux.HandleFunc("GET /join/create", h.HandleCreateAccount) 59 + mux.Handle("POST /join/create", cop.Handler(http.HandlerFunc(h.HandleCreateAccountSubmit))) 56 60 mux.HandleFunc("GET /atproto", h.HandleATProto) 57 61 mux.HandleFunc("GET /manage", h.HandleManage) 58 62 mux.HandleFunc("GET /brews", h.HandleBrewList) ··· 92 96 mux.Handle("POST /_mod/dismiss-report", cop.Handler(http.HandlerFunc(h.HandleDismissReport))) 93 97 mux.Handle("POST /_mod/block", cop.Handler(http.HandlerFunc(h.HandleBlockUser))) 94 98 mux.Handle("POST /_mod/unblock", cop.Handler(http.HandlerFunc(h.HandleUnblockUser))) 99 + mux.Handle("POST /_mod/invite", cop.Handler(http.HandlerFunc(h.HandleCreateInvite))) 100 + mux.Handle("POST /_mod/dismiss-join", cop.Handler(http.HandlerFunc(h.HandleDismissJoinRequest))) 95 101 96 102 // Modal routes for entity management (return dialog HTML) 97 103 mux.HandleFunc("GET /api/modals/bean/new", h.HandleBeanModalNew)
+1 -1
internal/web/components/footer.templ
··· 10 10 <nav class="flex flex-wrap justify-center gap-6 text-sm"> 11 11 <a href="/about" class="text-brown-700 hover:text-brown-900 transition-colors">About</a> 12 12 <a href="/terms" class="text-brown-700 hover:text-brown-900 transition-colors">Terms of Service</a> 13 - <a href="https://github.com/ptdewey/arabica" target="_blank" rel="noopener noreferrer" class="text-brown-700 hover:text-brown-900 transition-colors">Source Code</a> 13 + <a href="https://github.com/arabica-social/arabica" target="_blank" rel="noopener noreferrer" class="text-brown-700 hover:text-brown-900 transition-colors">Source Code</a> 14 14 <a href="/atproto" class="text-brown-700 hover:text-brown-900 transition-colors">AT Protocol</a> 15 15 </nav> 16 16 </div>
+1 -1
internal/web/components/layout.templ
··· 138 138 UserDID: data.UserDID, 139 139 IsModerator: data.IsModerator, 140 140 }) 141 - <main class="flex-grow container mx-auto px-4 py-8" data-transition> 141 + <main class="flex-grow container mx-auto py-8" data-transition> 142 142 @content 143 143 </main> 144 144 @Footer()
+84 -18
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" ··· 8 9 9 10 // EnrichedReport wraps a report with resolved profile info 10 11 type EnrichedReport struct { 11 - Report moderation.Report 12 - OwnerHandle string 12 + Report moderation.Report 13 + OwnerHandle string 13 14 ReporterHandle string 14 - PostContent string // Summary of the reported content 15 + PostContent string // Summary of the reported content 15 16 } 16 17 17 18 type AdminProps struct { ··· 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 - 121 138 <!-- Hidden Records Tab --> 122 139 if props.CanHide || props.CanUnhide { 123 140 <div x-show="activeTab === 'hidden'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> ··· 137 154 </div> 138 155 </div> 139 156 } 140 - 141 157 <!-- Blocked Users Tab --> 142 158 if props.CanBlock || props.CanUnblock { 143 159 <div x-show="activeTab === 'blocked'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> ··· 157 173 </div> 158 174 </div> 159 175 } 160 - 161 176 <!-- Reports Tab --> 162 177 if props.CanViewReports { 163 178 <div x-show="activeTab === 'reports'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> ··· 177 192 </div> 178 193 </div> 179 194 } 180 - 181 195 <!-- Activity Log Tab --> 182 196 if props.CanViewLogs { 183 197 <div x-show="activeTab === 'activity'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> ··· 196 210 } 197 211 </div> 198 212 </div> 213 + <!-- Join Requests Tab --> 214 + if props.IsAdmin { 215 + <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"> 216 + <div class="card card-inner"> 217 + <h2 class="section-title">Join Requests</h2> 218 + if len(props.JoinRequests) == 0 { 219 + <div class="bg-brown-50 rounded-lg p-4 text-center text-brown-600"> 220 + <p>No join requests.</p> 221 + </div> 222 + } else { 223 + <div class="space-y-3"> 224 + for _, req := range props.JoinRequests { 225 + @JoinRequestCard(req) 226 + } 227 + </div> 228 + } 229 + </div> 230 + </div> 231 + } 199 232 } 200 233 </div> 201 234 } ··· 226 259 </button> 227 260 </div> 228 261 </div> 229 - 230 262 <!-- Meta info row --> 231 263 <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 232 264 <div> ··· 247 279 </div> 248 280 } 249 281 </div> 250 - 251 282 <!-- Actions --> 252 283 if canUnhide { 253 284 <div class="pt-2 border-t border-brown-200"> ··· 292 323 </button> 293 324 </div> 294 325 </div> 295 - 296 326 <!-- Meta info row --> 297 327 <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 298 328 <div> ··· 310 340 </div> 311 341 } 312 342 </div> 313 - 314 343 <!-- Actions --> 315 344 if canUnblock { 316 345 <div class="pt-2 border-t border-brown-200"> ··· 337 366 @ReportStatusBadge(report.Report.Status) 338 367 <span class="text-sm text-brown-500">{ report.Report.CreatedAt.Format("Jan 2, 2006 15:04") }</span> 339 368 </div> 340 - 341 369 <!-- AT-URI with copy button --> 342 370 <div> 343 371 <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Record URI</span> ··· 361 389 </button> 362 390 </div> 363 391 </div> 364 - 365 392 <!-- Owner info --> 366 393 <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> 367 394 <div> ··· 427 454 </div> 428 455 </div> 429 456 </div> 430 - 431 457 <!-- Post content preview --> 432 458 if report.PostContent != "" { 433 459 <div> ··· 437 463 </div> 438 464 </div> 439 465 } 440 - 441 466 <!-- Report reason --> 442 467 if report.Report.Reason != "" { 443 468 <div> ··· 445 470 <p class="mt-1 text-sm text-brown-700">{ report.Report.Reason }</p> 446 471 </div> 447 472 } 448 - 449 473 <!-- Actions --> 450 474 if report.Report.Status == moderation.ReportStatusPending { 451 475 <div class="pt-3 border-t border-brown-200 flex flex-wrap gap-3"> ··· 515 539 @AuditActionBadge(entry.Action) 516 540 <span class="text-sm text-brown-500">{ entry.Timestamp.Format("Jan 2, 2006 15:04") }</span> 517 541 </div> 518 - 519 542 <!-- Target URI with copy button --> 520 543 if entry.TargetURI != "" { 521 544 <div> ··· 541 564 </div> 542 565 </div> 543 566 } 544 - 545 567 <!-- Actor info --> 546 568 <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 547 569 <div> ··· 586 608 </span> 587 609 } 588 610 } 611 + 612 + templ JoinRequestCard(req *boltstore.JoinRequest) { 613 + <div class="bg-brown-50 border border-brown-200 rounded-lg p-4"> 614 + <div class="flex flex-col gap-3"> 615 + <div class="flex items-center justify-between"> 616 + <span class="font-medium text-brown-900">{ req.Email }</span> 617 + <span class="text-sm text-brown-500">{ req.CreatedAt.Format("Jan 2, 2006 15:04") }</span> 618 + </div> 619 + <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 620 + <div> 621 + <span class="text-brown-500">IP:</span> 622 + <code class="text-brown-700 ml-1 text-xs">{ req.IP }</code> 623 + </div> 624 + </div> 625 + if req.Message != "" { 626 + <div> 627 + <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Message</span> 628 + <p class="mt-1 text-sm text-brown-700">{ req.Message }</p> 629 + </div> 630 + } 631 + <div class="pt-2 border-t border-brown-200 flex flex-wrap gap-3"> 632 + <button 633 + class="text-sm bg-green-100 text-green-700 hover:bg-green-200 px-3 py-1.5 rounded font-medium transition-colors" 634 + hx-post="/_mod/invite" 635 + hx-vals={ fmt.Sprintf(`{"id": "%s", "email": "%s"}`, req.ID, req.Email) } 636 + hx-swap="none" 637 + hx-disabled-elt="this" 638 + hx-confirm={ fmt.Sprintf("Create invite code and send to %s?", req.Email) } 639 + > 640 + Send Invite 641 + </button> 642 + <button 643 + class="text-sm text-brown-600 hover:text-brown-800 px-3 py-1.5 rounded font-medium transition-colors" 644 + hx-post="/_mod/dismiss-join" 645 + hx-vals={ fmt.Sprintf(`{"id": "%s"}`, req.ID) } 646 + hx-swap="none" 647 + hx-confirm="Dismiss this join request?" 648 + > 649 + Dismiss 650 + </button> 651 + </div> 652 + </div> 653 + </div> 654 + }
+142
internal/web/pages/create_account.templ
··· 1 + package pages 2 + 3 + import "arabica/internal/web/components" 4 + 5 + // CreateAccountProps holds form state for the account creation page. 6 + type CreateAccountProps struct { 7 + Error string // Validation or XRPC error message 8 + InviteCode string // Pre-filled or re-populated invite code 9 + Handle string // Re-populated handle on error 10 + Email string // Re-populated email on error 11 + HandleDomain string // e.g. "arabica.systems" 12 + } 13 + 14 + // CreateAccount renders the account creation page with layout. 15 + templ CreateAccount(layout *components.LayoutData, props CreateAccountProps) { 16 + @components.Layout(layout, CreateAccountContent(props)) 17 + } 18 + 19 + // CreateAccountContent renders the account creation form. 20 + templ CreateAccountContent(props CreateAccountProps) { 21 + <div class="page-container-md"> 22 + <div class="flex items-center gap-3 mb-8"> 23 + @components.BackButton() 24 + <h1 class="text-4xl font-bold text-brown-900">Create Account</h1> 25 + </div> 26 + <p class="text-brown-800 leading-relaxed mb-6"> 27 + Create your account on <strong>{ props.HandleDomain }</strong> using your invite code. 28 + </p> 29 + if props.Error != "" { 30 + <div class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4 text-red-800 text-sm"> 31 + { props.Error } 32 + </div> 33 + } 34 + <div class="card card-inner"> 35 + <form method="POST" action="/join/create" class="space-y-5"> 36 + @components.FormField(components.FormFieldProps{ 37 + Label: "Invite Code", 38 + Required: true, 39 + }, components.TextInput(components.TextInputProps{ 40 + Name: "invite_code", 41 + Value: props.InviteCode, 42 + Placeholder: "arabica-systems-xxxxx-xxxxx", 43 + Required: true, 44 + Class: "w-full", 45 + })) 46 + @components.FormField(components.FormFieldProps{ 47 + Label: "Handle", 48 + Required: true, 49 + HelperText: "Your @handle." + props.HandleDomain + " username", 50 + }, components.TextInput(components.TextInputProps{ 51 + Name: "handle", 52 + Value: props.Handle, 53 + Placeholder: "yourname", 54 + Required: true, 55 + Class: "w-full", 56 + })) 57 + @components.FormField(components.FormFieldProps{ 58 + Label: "Email", 59 + Required: true, 60 + }, components.TextInput(components.TextInputProps{ 61 + Name: "email", 62 + Type: "email", 63 + Value: props.Email, 64 + Placeholder: "you@example.com", 65 + Required: true, 66 + Class: "w-full", 67 + })) 68 + @components.FormField(components.FormFieldProps{ 69 + Label: "Password", 70 + Required: true, 71 + }, components.TextInput(components.TextInputProps{ 72 + Name: "password", 73 + Type: "password", 74 + Placeholder: "Choose a strong password", 75 + Required: true, 76 + Class: "w-full", 77 + })) 78 + @components.FormField(components.FormFieldProps{ 79 + Label: "Confirm Password", 80 + Required: true, 81 + }, components.TextInput(components.TextInputProps{ 82 + Name: "password_confirm", 83 + Type: "password", 84 + Placeholder: "Confirm your password", 85 + Required: true, 86 + Class: "w-full", 87 + })) 88 + <!-- Honeypot field — hidden from real users --> 89 + <div style="display:none" aria-hidden="true"> 90 + <label for="website">Website</label> 91 + <input type="text" name="website" id="website" tabindex="-1" autocomplete="off"/> 92 + </div> 93 + <div class="pt-2 text-center"> 94 + @components.PrimaryButton(components.ButtonProps{ 95 + Text: "Create Account", 96 + Type: "submit", 97 + Class: "w-full", 98 + }) 99 + </div> 100 + </form> 101 + </div> 102 + <p class="text-sm text-brown-600 mt-6 text-center"> 103 + Already have an account? 104 + <a href="/login" class="link-bold">Log in here</a>. 105 + </p> 106 + </div> 107 + } 108 + 109 + // CreateAccountSuccessProps holds data for the success page. 110 + type CreateAccountSuccessProps struct { 111 + Handle string // The created handle (e.g. "yourname.arabica.systems") 112 + } 113 + 114 + // CreateAccountSuccess renders the success page after account creation. 115 + templ CreateAccountSuccess(layout *components.LayoutData, props CreateAccountSuccessProps) { 116 + @components.Layout(layout, CreateAccountSuccessContent(props)) 117 + } 118 + 119 + // CreateAccountSuccessContent renders the success message. 120 + templ CreateAccountSuccessContent(props CreateAccountSuccessProps) { 121 + <div class="page-container-md"> 122 + <div class="flex items-center gap-3 mb-8"> 123 + @components.BackButton() 124 + <h1 class="text-4xl font-bold text-brown-900">Account Created</h1> 125 + </div> 126 + <div class="card card-inner text-center py-12"> 127 + <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"> 128 + <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> 129 + </svg> 130 + <h2 class="text-2xl font-semibold text-brown-900 mb-4">Welcome to Arabica!</h2> 131 + <p class="text-brown-800 leading-relaxed mb-2"> 132 + Your account <strong>{ props.Handle }</strong> has been created. 133 + </p> 134 + <p class="text-brown-700 text-sm mb-8"> 135 + You can now log in and start tracking your brews. 136 + </p> 137 + <a href="/login" class="btn-primary px-8 py-3 font-semibold shadow-lg hover:shadow-xl"> 138 + Log In 139 + </a> 140 + </div> 141 + </div> 142 + }
+1 -1
internal/web/pages/home.templ
··· 33 33 // TODO: add pagination and "load more" button to feed 34 34 // - this is probably mostly a backend change 35 35 templ CommunityFeedSection() { 36 - <div class="card p-6 mb-8"> 36 + <div class="card p-2 sm:p-6 mb-8"> 37 37 <h3 class="text-xl font-bold text-brown-900 mb-4">☕ Community Feed</h3> 38 38 <div hx-get="/api/feed" hx-trigger="load" hx-swap="innerHTML"> 39 39 @FeedLoadingSkeleton()
+91
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 + Class: "w-full", 32 + })) 33 + @components.FormField(components.FormFieldProps{ 34 + Label: "Why do you want to join?", 35 + HelperText: "Optional: a short note helps us prioritize requests", 36 + }, components.TextArea(components.TextAreaProps{ 37 + Name: "message", 38 + Placeholder: "I love coffee and want to track my brews...", 39 + Rows: 3, 40 + Class: "w-full", 41 + })) 42 + <!-- Honeypot field — hidden from real users --> 43 + <div style="display:none" aria-hidden="true"> 44 + <label for="website">Website</label> 45 + <input type="text" name="website" id="website" tabindex="-1" autocomplete="off"/> 46 + </div> 47 + <div class="pt-2 text-center"> 48 + @components.PrimaryButton(components.ButtonProps{ 49 + Text: "Request Account", 50 + Type: "submit", 51 + Class: "w-full", 52 + }) 53 + </div> 54 + </form> 55 + </div> 56 + <p class="text-sm text-brown-600 mt-6 text-center"> 57 + Already have an AT Protocol account? 58 + <a href="/login" class="link-bold">Log in here</a>. 59 + </p> 60 + </div> 61 + } 62 + 63 + // JoinSuccess renders the confirmation page after a successful submission. 64 + templ JoinSuccess(layout *components.LayoutData) { 65 + @components.Layout(layout, JoinSuccessContent()) 66 + } 67 + 68 + // JoinSuccessContent renders the success message. 69 + templ JoinSuccessContent() { 70 + <div class="page-container-md"> 71 + <div class="flex items-center gap-3 mb-8"> 72 + @components.BackButton() 73 + <h1 class="text-4xl font-bold text-brown-900">Request Received</h1> 74 + </div> 75 + <div class="card card-inner text-center py-12"> 76 + <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"> 77 + <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> 78 + </svg> 79 + <h2 class="text-2xl font-semibold text-brown-900 mb-4">Thanks for your interest!</h2> 80 + <p class="text-brown-800 leading-relaxed mb-2"> 81 + We've received your request and will review it shortly. 82 + </p> 83 + <p class="text-brown-700 text-sm mb-8"> 84 + You'll receive an invite code by email once your account is ready. 85 + </p> 86 + <a href="/" class="btn-primary px-8 py-3 font-semibold shadow-lg hover:shadow-xl"> 87 + Back to Home 88 + </a> 89 + </div> 90 + </div> 91 + }
+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"
+51
module.nix
··· 132 132 }; 133 133 }; 134 134 135 + smtp = { 136 + enable = lib.mkOption { 137 + type = lib.types.bool; 138 + default = false; 139 + description = '' 140 + Enable SMTP email notifications for join requests. 141 + SMTP credentials (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM) 142 + can be provided via environmentFiles. 143 + ''; 144 + }; 145 + 146 + host = lib.mkOption { 147 + type = lib.types.str; 148 + default = ""; 149 + description = "SMTP server hostname. Can also be set via SMTP_HOST in an environment file."; 150 + example = "smtp.example.com"; 151 + }; 152 + 153 + port = lib.mkOption { 154 + type = lib.types.nullOr lib.types.port; 155 + default = null; 156 + description = "SMTP server port. Can also be set via SMTP_PORT in an environment file. Defaults to 587 if unset."; 157 + }; 158 + 159 + from = lib.mkOption { 160 + type = lib.types.str; 161 + default = ""; 162 + description = "Sender address for outgoing email. Can also be set via SMTP_FROM in an environment file."; 163 + example = "noreply@arabica.example.com"; 164 + }; 165 + }; 166 + 167 + environmentFiles = lib.mkOption { 168 + type = lib.types.listOf lib.types.path; 169 + default = [ ]; 170 + description = '' 171 + List of environment files to load into the systemd service. 172 + Useful for secrets like SMTP_USER and SMTP_PASS that should 173 + not be stored in the Nix store. 174 + ''; 175 + example = lib.literalExpression ''[ "/run/secrets/arabica.env" ]''; 176 + }; 177 + 135 178 oauth = { 136 179 clientId = lib.mkOption { 137 180 type = lib.types.str; ··· 202 245 Restart = "on-failure"; 203 246 RestartSec = "10s"; 204 247 248 + EnvironmentFile = cfg.environmentFiles; 249 + 205 250 # Security hardening 206 251 NoNewPrivileges = true; 207 252 PrivateTmp = true; ··· 231 276 ARABICA_DB_PATH = "${cfg.dataDir}/arabica.db"; 232 277 } // lib.optionalAttrs (effectiveConfigPath != null) { 233 278 ARABICA_MODERATORS_CONFIG = toString effectiveConfigPath; 279 + } // lib.optionalAttrs (cfg.smtp.enable && cfg.smtp.host != "") { 280 + SMTP_HOST = cfg.smtp.host; 281 + } // lib.optionalAttrs (cfg.smtp.enable && cfg.smtp.port != null) { 282 + SMTP_PORT = toString cfg.smtp.port; 283 + } // lib.optionalAttrs (cfg.smtp.enable && cfg.smtp.from != "") { 284 + SMTP_FROM = cfg.smtp.from; 234 285 }; 235 286 }; 236 287
+4 -4
static/css/app.css
··· 24 24 @layer components { 25 25 /* Page Containers */ 26 26 .page-container { 27 - @apply mx-auto px-4; 27 + @apply mx-auto px-2 sm:px-4; 28 28 } 29 29 30 30 .page-container-sm { ··· 174 174 175 175 /* Feed Components */ 176 176 .feed-card { 177 - @apply bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow; 177 + @apply bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-3 sm:p-4 hover:shadow-lg transition-shadow; 178 178 } 179 179 180 180 .feed-content-box { 181 - @apply bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200; 181 + @apply bg-white/60 backdrop-blur rounded-lg p-3 sm:p-4 border border-brown-200; 182 182 } 183 183 184 184 .feed-content-box-sm { 185 - @apply bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200; 185 + @apply bg-white/60 backdrop-blur rounded-lg p-2 sm:p-3 border border-brown-200; 186 186 } 187 187 188 188 /* Avatar - base styles */