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: improve styling of create/join pages

+116 -121
+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
+5 -6
internal/database/boltstore/join_store.go
··· 10 10 11 11 // JoinRequest represents a request to join the PDS. 12 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"` 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"` 19 18 } 20 19 21 20 // JoinStore provides persistent storage for join requests.
+7 -8
internal/handlers/handlers.go
··· 1984 1984 1985 1985 // Create and save the join request 1986 1986 req := &boltstore.JoinRequest{ 1987 - ID: fmt.Sprintf("%d", time.Now().UnixNano()), 1988 - Email: emailAddr, 1989 - PreferredHandle: handle, 1990 - Message: message, 1991 - CreatedAt: time.Now().UTC(), 1992 - IP: r.RemoteAddr, 1987 + ID: fmt.Sprintf("%d", time.Now().UnixNano()), 1988 + Email: emailAddr, 1989 + Message: message, 1990 + CreatedAt: time.Now().UTC(), 1991 + IP: r.RemoteAddr, 1993 1992 } 1994 1993 1995 1994 if h.joinStore != nil { ··· 2005 2004 if h.emailSender != nil && h.emailSender.Enabled() { 2006 2005 go func() { 2007 2006 subject := "New Arabica Join Request" 2008 - body := fmt.Sprintf("New account request:\n\nEmail: %s\nPreferred Handle: %s\nMessage: %s\nIP: %s\nTime: %s\n", 2009 - req.Email, req.PreferredHandle, req.Message, req.IP, req.CreatedAt.Format(time.RFC3339)) 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)) 2010 2009 2011 2010 if err := h.emailSender.Send(h.emailSender.AdminEmail(), subject, body); err != nil { 2012 2011 log.Error().Err(err).Str("email", emailAddr).Msg("Failed to send admin notification")
+36 -58
internal/web/pages/admin.templ
··· 9 9 10 10 // EnrichedReport wraps a report with resolved profile info 11 11 type EnrichedReport struct { 12 - Report moderation.Report 13 - OwnerHandle string 12 + Report moderation.Report 13 + OwnerHandle string 14 14 ReporterHandle string 15 - PostContent string // Summary of the reported content 15 + PostContent string // Summary of the reported content 16 16 } 17 17 18 18 type AdminProps struct { ··· 118 118 Activity Log 119 119 </button> 120 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 - } 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 + } 136 136 </nav> 137 137 </div> 138 - 139 138 <!-- Hidden Records Tab --> 140 139 if props.CanHide || props.CanUnhide { 141 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"> ··· 155 154 </div> 156 155 </div> 157 156 } 158 - 159 157 <!-- Blocked Users Tab --> 160 158 if props.CanBlock || props.CanUnblock { 161 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"> ··· 175 173 </div> 176 174 </div> 177 175 } 178 - 179 176 <!-- Reports Tab --> 180 177 if props.CanViewReports { 181 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"> ··· 195 192 </div> 196 193 </div> 197 194 } 198 - 199 195 <!-- Activity Log Tab --> 200 196 if props.CanViewLogs { 201 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"> ··· 214 210 } 215 211 </div> 216 212 </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 - } 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> 234 230 </div> 235 - </div> 236 - } 231 + } 237 232 } 238 233 </div> 239 234 } ··· 264 259 </button> 265 260 </div> 266 261 </div> 267 - 268 262 <!-- Meta info row --> 269 263 <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 270 264 <div> ··· 285 279 </div> 286 280 } 287 281 </div> 288 - 289 282 <!-- Actions --> 290 283 if canUnhide { 291 284 <div class="pt-2 border-t border-brown-200"> ··· 330 323 </button> 331 324 </div> 332 325 </div> 333 - 334 326 <!-- Meta info row --> 335 327 <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 336 328 <div> ··· 348 340 </div> 349 341 } 350 342 </div> 351 - 352 343 <!-- Actions --> 353 344 if canUnblock { 354 345 <div class="pt-2 border-t border-brown-200"> ··· 375 366 @ReportStatusBadge(report.Report.Status) 376 367 <span class="text-sm text-brown-500">{ report.Report.CreatedAt.Format("Jan 2, 2006 15:04") }</span> 377 368 </div> 378 - 379 369 <!-- AT-URI with copy button --> 380 370 <div> 381 371 <span class="text-xs font-medium text-brown-500 uppercase tracking-wide">Record URI</span> ··· 399 389 </button> 400 390 </div> 401 391 </div> 402 - 403 392 <!-- Owner info --> 404 393 <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> 405 394 <div> ··· 465 454 </div> 466 455 </div> 467 456 </div> 468 - 469 457 <!-- Post content preview --> 470 458 if report.PostContent != "" { 471 459 <div> ··· 475 463 </div> 476 464 </div> 477 465 } 478 - 479 466 <!-- Report reason --> 480 467 if report.Report.Reason != "" { 481 468 <div> ··· 483 470 <p class="mt-1 text-sm text-brown-700">{ report.Report.Reason }</p> 484 471 </div> 485 472 } 486 - 487 473 <!-- Actions --> 488 474 if report.Report.Status == moderation.ReportStatusPending { 489 475 <div class="pt-3 border-t border-brown-200 flex flex-wrap gap-3"> ··· 553 539 @AuditActionBadge(entry.Action) 554 540 <span class="text-sm text-brown-500">{ entry.Timestamp.Format("Jan 2, 2006 15:04") }</span> 555 541 </div> 556 - 557 542 <!-- Target URI with copy button --> 558 543 if entry.TargetURI != "" { 559 544 <div> ··· 579 564 </div> 580 565 </div> 581 566 } 582 - 583 567 <!-- Actor info --> 584 568 <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm"> 585 569 <div> ··· 633 617 <span class="text-sm text-brown-500">{ req.CreatedAt.Format("Jan 2, 2006 15:04") }</span> 634 618 </div> 635 619 <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 620 <div> 643 621 <span class="text-brown-500">IP:</span> 644 622 <code class="text-brown-700 ml-1 text-xs">{ req.IP }</code>
+9 -3
internal/web/pages/create_account.templ
··· 41 41 Value: props.InviteCode, 42 42 Placeholder: "arabica-systems-xxxxx-xxxxx", 43 43 Required: true, 44 + Class: "w-full", 44 45 })) 45 46 @components.FormField(components.FormFieldProps{ 46 47 Label: "Handle", ··· 51 52 Value: props.Handle, 52 53 Placeholder: "yourname", 53 54 Required: true, 55 + Class: "w-full", 54 56 })) 55 57 @components.FormField(components.FormFieldProps{ 56 58 Label: "Email", ··· 61 63 Value: props.Email, 62 64 Placeholder: "you@example.com", 63 65 Required: true, 66 + Class: "w-full", 64 67 })) 65 68 @components.FormField(components.FormFieldProps{ 66 69 Label: "Password", ··· 70 73 Type: "password", 71 74 Placeholder: "Choose a strong password", 72 75 Required: true, 76 + Class: "w-full", 73 77 })) 74 78 @components.FormField(components.FormFieldProps{ 75 79 Label: "Confirm Password", ··· 79 83 Type: "password", 80 84 Placeholder: "Confirm your password", 81 85 Required: true, 86 + Class: "w-full", 82 87 })) 83 88 <!-- Honeypot field — hidden from real users --> 84 89 <div style="display:none" aria-hidden="true"> 85 90 <label for="website">Website</label> 86 91 <input type="text" name="website" id="website" tabindex="-1" autocomplete="off"/> 87 92 </div> 88 - <div class="pt-2"> 93 + <div class="pt-2 text-center"> 89 94 @components.PrimaryButton(components.ButtonProps{ 90 - Text: "Create Account", 91 - Type: "submit", 95 + Text: "Create Account", 96 + Type: "submit", 97 + Class: "w-full", 92 98 }) 93 99 </div> 94 100 </form>
+7 -11
internal/web/pages/join.templ
··· 28 28 Type: "email", 29 29 Placeholder: "you@example.com", 30 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", 31 + Class: "w-full", 38 32 })) 39 33 @components.FormField(components.FormFieldProps{ 40 34 Label: "Why do you want to join?", 41 - HelperText: "Optional — a short note helps us prioritize requests", 35 + HelperText: "Optional: a short note helps us prioritize requests", 42 36 }, components.TextArea(components.TextAreaProps{ 43 37 Name: "message", 44 38 Placeholder: "I love coffee and want to track my brews...", 45 39 Rows: 3, 40 + Class: "w-full", 46 41 })) 47 42 <!-- Honeypot field — hidden from real users --> 48 43 <div style="display:none" aria-hidden="true"> 49 44 <label for="website">Website</label> 50 45 <input type="text" name="website" id="website" tabindex="-1" autocomplete="off"/> 51 46 </div> 52 - <div class="pt-2"> 47 + <div class="pt-2 text-center"> 53 48 @components.PrimaryButton(components.ButtonProps{ 54 - Text: "Request Account", 55 - Type: "submit", 49 + Text: "Request Account", 50 + Type: "submit", 51 + Class: "w-full", 56 52 }) 57 53 </div> 58 54 </form>
+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