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

refactor: moderation details compoment

pdewey.com 6c3fa527 bea02985

verified
+94 -170
+38 -68
internal/handlers/admin.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "net/http" 7 6 "time" 8 7 ··· 13 12 "arabica/internal/web/components" 14 13 "arabica/internal/web/pages" 15 14 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 16 "github.com/rs/zerolog/log" 17 17 ) 18 18 ··· 33 33 34 34 // Check permission 35 35 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord) { 36 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/hide").Msg("Denied: insufficient permissions") 36 37 http.Error(w, "Permission denied", http.StatusForbidden) 37 38 return 38 39 } 39 40 40 - // Parse request - support both JSON and form data 41 - var req hideRequest 42 - contentType := r.Header.Get("Content-Type") 43 - if contentType == "application/json" { 44 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 45 - http.Error(w, "Invalid request body", http.StatusBadRequest) 46 - return 47 - } 48 - } else { 49 - // Parse as form data (HTMX default) 50 - if err := r.ParseForm(); err != nil { 51 - http.Error(w, "Invalid request body", http.StatusBadRequest) 52 - return 53 - } 54 - req.URI = r.FormValue("uri") 55 - req.Reason = r.FormValue("reason") 41 + // Parse form data only (JSON is rejected to prevent CSRF bypass) 42 + if err := r.ParseForm(); err != nil { 43 + http.Error(w, "Invalid request body", http.StatusBadRequest) 44 + return 56 45 } 46 + var req hideRequest 47 + req.URI = r.FormValue("uri") 48 + req.Reason = r.FormValue("reason") 57 49 58 50 if req.URI == "" { 59 51 http.Error(w, "URI is required", http.StatusBadRequest) ··· 110 102 111 103 // Check permission 112 104 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnhideRecord) { 105 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/unhide").Msg("Denied: insufficient permissions") 113 106 http.Error(w, "Permission denied", http.StatusForbidden) 114 107 return 115 108 } 116 109 117 - // Parse request - support both JSON and form data 110 + // Parse form data only (JSON is rejected to prevent CSRF bypass) 111 + if err := r.ParseForm(); err != nil { 112 + http.Error(w, "Invalid request body", http.StatusBadRequest) 113 + return 114 + } 118 115 var req hideRequest 119 - contentType := r.Header.Get("Content-Type") 120 - if contentType == "application/json" { 121 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 122 - http.Error(w, "Invalid request body", http.StatusBadRequest) 123 - return 124 - } 125 - } else { 126 - // Parse as form data (HTMX default) 127 - if err := r.ParseForm(); err != nil { 128 - http.Error(w, "Invalid request body", http.StatusBadRequest) 129 - return 130 - } 131 - req.URI = r.FormValue("uri") 132 - req.Reason = r.FormValue("reason") 133 - } 116 + req.URI = r.FormValue("uri") 117 + req.Reason = r.FormValue("reason") 134 118 135 119 if req.URI == "" { 136 120 http.Error(w, "URI is required", http.StatusBadRequest) ··· 167 151 w.WriteHeader(http.StatusOK) 168 152 } 169 153 170 - // generateTID generates a TID (timestamp-based identifier) 154 + // generateTID generates a TID (timestamp-based identifier) using the AT Protocol TID format. 171 155 func generateTID() string { 172 - // Simple implementation using unix nano timestamp 173 - // In production, you might want a more sophisticated TID generator 174 - return time.Now().Format("20060102150405.000000000") 156 + return syntax.NewTIDNow(0).String() 175 157 } 176 158 177 159 // buildAdminProps builds the admin dashboard props for the given moderator. ··· 241 223 242 224 // Check if user is a moderator 243 225 if h.moderationService == nil || !h.moderationService.IsModerator(userDID) { 226 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod").Msg("Denied: not a moderator") 244 227 http.Error(w, "Access denied", http.StatusForbidden) 245 228 return 246 229 } ··· 272 255 } 273 256 274 257 if h.moderationService == nil || !h.moderationService.IsModerator(userDID) { 258 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/content").Msg("Denied: not a moderator") 275 259 http.Error(w, "Access denied", http.StatusForbidden) 276 260 return 277 261 } ··· 382 366 383 367 // Check permission 384 368 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) { 369 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/block").Msg("Denied: insufficient permissions") 385 370 http.Error(w, "Permission denied", http.StatusForbidden) 386 371 return 387 372 } 388 373 389 - // Parse request - support both JSON and form data 374 + // Parse form data only (JSON is rejected to prevent CSRF bypass) 375 + if err := r.ParseForm(); err != nil { 376 + http.Error(w, "Invalid request body", http.StatusBadRequest) 377 + return 378 + } 390 379 var req blockRequest 391 - contentType := r.Header.Get("Content-Type") 392 - if contentType == "application/json" { 393 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 394 - http.Error(w, "Invalid request body", http.StatusBadRequest) 395 - return 396 - } 397 - } else { 398 - // Parse as form data (HTMX default) 399 - if err := r.ParseForm(); err != nil { 400 - http.Error(w, "Invalid request body", http.StatusBadRequest) 401 - return 402 - } 403 - req.DID = r.FormValue("did") 404 - req.Reason = r.FormValue("reason") 405 - } 380 + req.DID = r.FormValue("did") 381 + req.Reason = r.FormValue("reason") 406 382 407 383 if req.DID == "" { 408 384 http.Error(w, "DID is required", http.StatusBadRequest) ··· 457 433 458 434 // Check permission 459 435 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) { 436 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/unblock").Msg("Denied: insufficient permissions") 460 437 http.Error(w, "Permission denied", http.StatusForbidden) 461 438 return 462 439 } 463 440 464 - // Parse request - support both JSON and form data 465 - var req blockRequest 466 - contentType := r.Header.Get("Content-Type") 467 - if contentType == "application/json" { 468 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 469 - http.Error(w, "Invalid request body", http.StatusBadRequest) 470 - return 471 - } 472 - } else { 473 - // Parse as form data (HTMX default) 474 - if err := r.ParseForm(); err != nil { 475 - http.Error(w, "Invalid request body", http.StatusBadRequest) 476 - return 477 - } 478 - req.DID = r.FormValue("did") 441 + // Parse form data only (JSON is rejected to prevent CSRF bypass) 442 + if err := r.ParseForm(); err != nil { 443 + http.Error(w, "Invalid request body", http.StatusBadRequest) 444 + return 479 445 } 446 + var req blockRequest 447 + req.DID = r.FormValue("did") 480 448 481 449 if req.DID == "" { 482 450 http.Error(w, "DID is required", http.StatusBadRequest) ··· 522 490 } 523 491 524 492 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionResetAutoHide) { 493 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/reset-autohide").Msg("Denied: insufficient permissions") 525 494 http.Error(w, "Permission denied", http.StatusForbidden) 526 495 return 527 496 } ··· 576 545 577 546 // Check permission 578 547 if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionDismissReport) { 548 + log.Warn().Str("did", userDID).Str("endpoint", "/_mod/dismiss-report").Msg("Denied: insufficient permissions") 579 549 http.Error(w, "Permission denied", http.StatusForbidden) 580 550 return 581 551 }
+7 -14
internal/handlers/report.go
··· 60 60 return 61 61 } 62 62 63 - // Parse request (supports both JSON and form data) 63 + // Parse form data only (JSON is rejected to prevent CSRF bypass) 64 64 var req ReportRequest 65 - if isJSONRequest(r) { 66 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 67 - writeReportError(w, "Invalid JSON", http.StatusBadRequest) 68 - return 69 - } 70 - } else { 71 - if err := r.ParseForm(); err != nil { 72 - writeReportError(w, "Invalid form data", http.StatusBadRequest) 73 - return 74 - } 75 - req.SubjectURI = r.FormValue("subject_uri") 76 - req.SubjectCID = r.FormValue("subject_cid") 77 - req.Reason = r.FormValue("reason") 65 + if err := r.ParseForm(); err != nil { 66 + writeReportError(w, "Invalid form data", http.StatusBadRequest) 67 + return 78 68 } 69 + req.SubjectURI = r.FormValue("subject_uri") 70 + req.SubjectCID = r.FormValue("subject_cid") 71 + req.Reason = r.FormValue("reason") 79 72 80 73 // Validate subject URI 81 74 if req.SubjectURI == "" {
+7 -6
internal/web/components/action_bar.templ
··· 277 277 submitting = true; 278 278 error = ''; 279 279 const dialog = document.getElementById('%s'); 280 + const body = new URLSearchParams({ 281 + subject_uri: '%s', 282 + subject_cid: '%s', 283 + reason: reason 284 + }); 280 285 fetch('/api/report', { 281 286 method: 'POST', 282 - headers: {'Content-Type': 'application/json'}, 283 - body: JSON.stringify({ 284 - subject_uri: '%s', 285 - subject_cid: '%s', 286 - reason: reason 287 - }) 287 + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 288 + body: body 288 289 }) 289 290 .then(r => r.json().then(data => ({ok: r.ok, data}))) 290 291 .then(({ok, data}) => {
+7 -6
internal/web/components/dialog_modals.templ
··· 343 343 submitting = true; 344 344 error = ''; 345 345 const dialog = document.getElementById('report-modal'); 346 + const body = new URLSearchParams({ 347 + subject_uri: $el.querySelector('[name=subject_uri]').value, 348 + subject_cid: $el.querySelector('[name=subject_cid]').value, 349 + reason: reason 350 + }); 346 351 fetch('/api/report', { 347 352 method: 'POST', 348 - headers: {'Content-Type': 'application/json'}, 349 - body: JSON.stringify({ 350 - subject_uri: $el.querySelector('[name=subject_uri]').value, 351 - subject_cid: $el.querySelector('[name=subject_cid]').value, 352 - reason: reason 353 - }) 353 + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 354 + body: body 354 355 }) 355 356 .then(r => r.json().then(data => ({ok: r.ok, data}))) 356 357 .then(({ok, data}) => {
+21
internal/web/components/shared.templ
··· 30 30 } 31 31 } 32 32 33 + // DetailFieldProps defines properties for a labeled detail field 34 + type DetailFieldProps struct { 35 + Label string 36 + Value string 37 + LinkHref string // Optional: wraps value in a link 38 + } 39 + 40 + // DetailField renders a labeled value with a "Not specified" fallback 41 + templ DetailField(props DetailFieldProps) { 42 + <div class="section-box"> 43 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ props.Label }</h3> 44 + if props.Value != "" && props.LinkHref != "" { 45 + <a href={ templ.SafeURL(props.LinkHref) } class="font-semibold text-brown-900 hover:underline">{ props.Value }</a> 46 + } else if props.Value != "" { 47 + <div class="font-semibold text-brown-900">{ props.Value }</div> 48 + } else { 49 + <span class="text-brown-400">Not specified</span> 50 + } 51 + </div> 52 + } 53 + 33 54 type PageHeaderProps struct { 34 55 Title string 35 56 BackURL string
+3 -14
internal/web/pages/bean_view.templ
··· 57 57 </div> 58 58 } 59 59 <div class="grid grid-cols-2 gap-4"> 60 - @BeanDetailField("๐Ÿ“ Origin", props.Bean.Origin) 61 - @BeanDetailField("๐Ÿ”ฅ Roast Level", props.Bean.RoastLevel) 62 - @BeanDetailField("๐ŸŒฑ Process", props.Bean.Process) 60 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ“ Origin", Value: props.Bean.Origin}) 61 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ”ฅ Roast Level", Value: props.Bean.RoastLevel}) 62 + @components.DetailField(components.DetailFieldProps{Label: "๐ŸŒฑ Process", Value: props.Bean.Process}) 63 63 if props.Bean.Closed { 64 64 <div class="section-box"> 65 65 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Status</h3> ··· 125 125 } 126 126 </h2> 127 127 <p class="text-sm text-brown-600 mt-1">{ props.Bean.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 128 - </div> 129 - } 130 - 131 - templ BeanDetailField(label, value string) { 132 - <div class="section-box"> 133 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3> 134 - if value != "" { 135 - <div class="font-semibold text-brown-900">{ value }</div> 136 - } else { 137 - <span class="text-brown-400">Not specified</span> 138 - } 139 128 </div> 140 129 } 141 130
+7 -33
internal/web/pages/brew_view.templ
··· 155 155 // BrewParametersGrid renders the brew parameters in a grid 156 156 templ BrewParametersGrid(brew *models.Brew, owner string) { 157 157 <div class="grid grid-cols-2 gap-4"> 158 - @BrewParameter("โš–๏ธ Coffee", getCoffeeAmountDisplay(brew)) 159 - @BrewLinkedParameter("โ˜• Brew Method", getBrewerName(brew), getBrewerViewURL(brew, owner)) 160 - @BrewLinkedParameter("โš™๏ธ Grinder", getGrinderName(brew), getGrinderViewURL(brew, owner)) 161 - @BrewParameter("๐Ÿ”ฉ Grind Size", getGrindSizeDisplay(brew)) 162 - @BrewParameter("๐Ÿ’ง Water", getWaterAmountDisplay(brew)) 163 - @BrewParameter("๐ŸŒก๏ธ Temperature", getTemperatureDisplay(brew)) 158 + @components.DetailField(components.DetailFieldProps{Label: "โš–๏ธ Coffee", Value: getCoffeeAmountDisplay(brew)}) 159 + @components.DetailField(components.DetailFieldProps{Label: "โ˜• Brew Method", Value: getBrewerName(brew), LinkHref: getBrewerViewURL(brew, owner)}) 160 + @components.DetailField(components.DetailFieldProps{Label: "โš™๏ธ Grinder", Value: getGrinderName(brew), LinkHref: getGrinderViewURL(brew, owner)}) 161 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ”ฉ Grind Size", Value: getGrindSizeDisplay(brew)}) 162 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ’ง Water", Value: getWaterAmountDisplay(brew)}) 163 + @components.DetailField(components.DetailFieldProps{Label: "๐ŸŒก๏ธ Temperature", Value: getTemperatureDisplay(brew)}) 164 164 <div class="col-span-2"> 165 - @BrewParameter("โฑ๏ธ Brew Time", getBrewTimeDisplay(brew)) 165 + @components.DetailField(components.DetailFieldProps{Label: "โฑ๏ธ Brew Time", Value: getBrewTimeDisplay(brew)}) 166 166 </div> 167 - </div> 168 - } 169 - 170 - // BrewParameter renders a single parameter in the grid 171 - templ BrewParameter(label string, value string) { 172 - <div class="section-box"> 173 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3> 174 - if value != "" { 175 - <div class="font-semibold text-brown-900">{ value }</div> 176 - } else { 177 - <span class="text-brown-400">Not specified</span> 178 - } 179 - </div> 180 - } 181 - 182 - // BrewLinkedParameter renders a parameter with a clickable link to the entity view page 183 - templ BrewLinkedParameter(label string, value string, href string) { 184 - <div class="section-box"> 185 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3> 186 - if value != "" && href != "" { 187 - <a href={ templ.SafeURL(href) } class="font-semibold text-brown-900 hover:underline">{ value }</a> 188 - } else if value != "" { 189 - <div class="font-semibold text-brown-900">{ value }</div> 190 - } else { 191 - <span class="text-brown-400">Not specified</span> 192 - } 193 167 </div> 194 168 } 195 169
+1 -6
internal/web/pages/brewer_view.templ
··· 38 38 templ BrewerViewCard(props BrewerViewProps) { 39 39 @BrewerViewHeader(props) 40 40 <div class="space-y-6"> 41 - if props.Brewer.BrewerType != "" { 42 - <div class="section-box"> 43 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">โ˜• Type</h3> 44 - <div class="font-semibold text-brown-900">{ props.Brewer.BrewerType }</div> 45 - </div> 46 - } 41 + @components.DetailField(components.DetailFieldProps{Label: "โ˜• Type", Value: props.Brewer.BrewerType}) 47 42 if props.Brewer.Description != "" { 48 43 <div class="section-box"> 49 44 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">๐Ÿ“ Description</h3>
+2 -12
internal/web/pages/grinder_view.templ
··· 39 39 @GrinderViewHeader(props) 40 40 <div class="space-y-6"> 41 41 <div class="grid grid-cols-2 gap-4"> 42 - @GrinderDetailField("โš™๏ธ Type", props.Grinder.GrinderType) 43 - @GrinderDetailField("๐Ÿ”ฉ Burr Type", props.Grinder.BurrType) 42 + @components.DetailField(components.DetailFieldProps{Label: "โš™๏ธ Type", Value: props.Grinder.GrinderType}) 43 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ”ฉ Burr Type", Value: props.Grinder.BurrType}) 44 44 </div> 45 45 if props.Grinder.Notes != "" { 46 46 <div class="section-box"> ··· 97 97 </div> 98 98 } 99 99 100 - templ GrinderDetailField(label, value string) { 101 - <div class="section-box"> 102 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3> 103 - if value != "" { 104 - <div class="font-semibold text-brown-900">{ value }</div> 105 - } else { 106 - <span class="text-brown-400">Not specified</span> 107 - } 108 - </div> 109 - }
+1 -11
internal/web/pages/roaster_view.templ
··· 40 40 @RoasterViewHeader(props) 41 41 <div class="space-y-6"> 42 42 <div class="grid grid-cols-2 gap-4"> 43 - @RoasterDetailField("๐Ÿ“ Location", props.Roaster.Location) 43 + @components.DetailField(components.DetailFieldProps{Label: "๐Ÿ“ Location", Value: props.Roaster.Location}) 44 44 if props.Roaster.Website != "" { 45 45 <div class="section-box"> 46 46 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">๐Ÿ”— Website</h3> ··· 103 103 </div> 104 104 } 105 105 106 - templ RoasterDetailField(label, value string) { 107 - <div class="section-box"> 108 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3> 109 - if value != "" { 110 - <div class="font-semibold text-brown-900">{ value }</div> 111 - } else { 112 - <span class="text-brown-400">Not specified</span> 113 - } 114 - </div> 115 - }