tangled
alpha
login
or
join now
arabica.social
/
arabica
7
fork
atom
Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
7
fork
atom
overview
issues
pulls
pipelines
refactor: moderation details compoment
pdewey.com
2 weeks ago
6c3fa527
bea02985
verified
This commit was signed with the committer's
known signature
.
pdewey.com
SSH Key Fingerprint:
SHA256:ePOVkJstqVLchGK8m9/OGQG+aFNHD5XN3xjvW9wKCA4=
+94
-170
10 changed files
expand all
collapse all
unified
split
internal
handlers
admin.go
report.go
web
components
action_bar.templ
dialog_modals.templ
shared.templ
pages
bean_view.templ
brew_view.templ
brewer_view.templ
grinder_view.templ
roaster_view.templ
+38
-68
internal/handlers/admin.go
···
2
2
3
3
import (
4
4
"context"
5
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
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
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
40
-
// Parse request - support both JSON and form data
41
41
-
var req hideRequest
42
42
-
contentType := r.Header.Get("Content-Type")
43
43
-
if contentType == "application/json" {
44
44
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
45
45
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
46
46
-
return
47
47
-
}
48
48
-
} else {
49
49
-
// Parse as form data (HTMX default)
50
50
-
if err := r.ParseForm(); err != nil {
51
51
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
52
52
-
return
53
53
-
}
54
54
-
req.URI = r.FormValue("uri")
55
55
-
req.Reason = r.FormValue("reason")
41
41
+
// Parse form data only (JSON is rejected to prevent CSRF bypass)
42
42
+
if err := r.ParseForm(); err != nil {
43
43
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
44
44
+
return
56
45
}
46
46
+
var req hideRequest
47
47
+
req.URI = r.FormValue("uri")
48
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
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
117
-
// Parse request - support both JSON and form data
110
110
+
// Parse form data only (JSON is rejected to prevent CSRF bypass)
111
111
+
if err := r.ParseForm(); err != nil {
112
112
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
113
113
+
return
114
114
+
}
118
115
var req hideRequest
119
119
-
contentType := r.Header.Get("Content-Type")
120
120
-
if contentType == "application/json" {
121
121
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
122
122
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
123
123
-
return
124
124
-
}
125
125
-
} else {
126
126
-
// Parse as form data (HTMX default)
127
127
-
if err := r.ParseForm(); err != nil {
128
128
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
129
129
-
return
130
130
-
}
131
131
-
req.URI = r.FormValue("uri")
132
132
-
req.Reason = r.FormValue("reason")
133
133
-
}
116
116
+
req.URI = r.FormValue("uri")
117
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
170
-
// generateTID generates a TID (timestamp-based identifier)
154
154
+
// generateTID generates a TID (timestamp-based identifier) using the AT Protocol TID format.
171
155
func generateTID() string {
172
172
-
// Simple implementation using unix nano timestamp
173
173
-
// In production, you might want a more sophisticated TID generator
174
174
-
return time.Now().Format("20060102150405.000000000")
156
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
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
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
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
389
-
// Parse request - support both JSON and form data
374
374
+
// Parse form data only (JSON is rejected to prevent CSRF bypass)
375
375
+
if err := r.ParseForm(); err != nil {
376
376
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
377
377
+
return
378
378
+
}
390
379
var req blockRequest
391
391
-
contentType := r.Header.Get("Content-Type")
392
392
-
if contentType == "application/json" {
393
393
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
394
394
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
395
395
-
return
396
396
-
}
397
397
-
} else {
398
398
-
// Parse as form data (HTMX default)
399
399
-
if err := r.ParseForm(); err != nil {
400
400
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
401
401
-
return
402
402
-
}
403
403
-
req.DID = r.FormValue("did")
404
404
-
req.Reason = r.FormValue("reason")
405
405
-
}
380
380
+
req.DID = r.FormValue("did")
381
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
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
464
-
// Parse request - support both JSON and form data
465
465
-
var req blockRequest
466
466
-
contentType := r.Header.Get("Content-Type")
467
467
-
if contentType == "application/json" {
468
468
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
469
469
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
470
470
-
return
471
471
-
}
472
472
-
} else {
473
473
-
// Parse as form data (HTMX default)
474
474
-
if err := r.ParseForm(); err != nil {
475
475
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
476
476
-
return
477
477
-
}
478
478
-
req.DID = r.FormValue("did")
441
441
+
// Parse form data only (JSON is rejected to prevent CSRF bypass)
442
442
+
if err := r.ParseForm(); err != nil {
443
443
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
444
444
+
return
479
445
}
446
446
+
var req blockRequest
447
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
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
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
63
-
// Parse request (supports both JSON and form data)
63
63
+
// Parse form data only (JSON is rejected to prevent CSRF bypass)
64
64
var req ReportRequest
65
65
-
if isJSONRequest(r) {
66
66
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
67
67
-
writeReportError(w, "Invalid JSON", http.StatusBadRequest)
68
68
-
return
69
69
-
}
70
70
-
} else {
71
71
-
if err := r.ParseForm(); err != nil {
72
72
-
writeReportError(w, "Invalid form data", http.StatusBadRequest)
73
73
-
return
74
74
-
}
75
75
-
req.SubjectURI = r.FormValue("subject_uri")
76
76
-
req.SubjectCID = r.FormValue("subject_cid")
77
77
-
req.Reason = r.FormValue("reason")
65
65
+
if err := r.ParseForm(); err != nil {
66
66
+
writeReportError(w, "Invalid form data", http.StatusBadRequest)
67
67
+
return
78
68
}
69
69
+
req.SubjectURI = r.FormValue("subject_uri")
70
70
+
req.SubjectCID = r.FormValue("subject_cid")
71
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
280
+
const body = new URLSearchParams({
281
281
+
subject_uri: '%s',
282
282
+
subject_cid: '%s',
283
283
+
reason: reason
284
284
+
});
280
285
fetch('/api/report', {
281
286
method: 'POST',
282
282
-
headers: {'Content-Type': 'application/json'},
283
283
-
body: JSON.stringify({
284
284
-
subject_uri: '%s',
285
285
-
subject_cid: '%s',
286
286
-
reason: reason
287
287
-
})
287
287
+
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
288
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
346
+
const body = new URLSearchParams({
347
347
+
subject_uri: $el.querySelector('[name=subject_uri]').value,
348
348
+
subject_cid: $el.querySelector('[name=subject_cid]').value,
349
349
+
reason: reason
350
350
+
});
346
351
fetch('/api/report', {
347
352
method: 'POST',
348
348
-
headers: {'Content-Type': 'application/json'},
349
349
-
body: JSON.stringify({
350
350
-
subject_uri: $el.querySelector('[name=subject_uri]').value,
351
351
-
subject_cid: $el.querySelector('[name=subject_cid]').value,
352
352
-
reason: reason
353
353
-
})
353
353
+
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
354
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
33
+
// DetailFieldProps defines properties for a labeled detail field
34
34
+
type DetailFieldProps struct {
35
35
+
Label string
36
36
+
Value string
37
37
+
LinkHref string // Optional: wraps value in a link
38
38
+
}
39
39
+
40
40
+
// DetailField renders a labeled value with a "Not specified" fallback
41
41
+
templ DetailField(props DetailFieldProps) {
42
42
+
<div class="section-box">
43
43
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ props.Label }</h3>
44
44
+
if props.Value != "" && props.LinkHref != "" {
45
45
+
<a href={ templ.SafeURL(props.LinkHref) } class="font-semibold text-brown-900 hover:underline">{ props.Value }</a>
46
46
+
} else if props.Value != "" {
47
47
+
<div class="font-semibold text-brown-900">{ props.Value }</div>
48
48
+
} else {
49
49
+
<span class="text-brown-400">Not specified</span>
50
50
+
}
51
51
+
</div>
52
52
+
}
53
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
60
-
@BeanDetailField("๐ Origin", props.Bean.Origin)
61
61
-
@BeanDetailField("๐ฅ Roast Level", props.Bean.RoastLevel)
62
62
-
@BeanDetailField("๐ฑ Process", props.Bean.Process)
60
60
+
@components.DetailField(components.DetailFieldProps{Label: "๐ Origin", Value: props.Bean.Origin})
61
61
+
@components.DetailField(components.DetailFieldProps{Label: "๐ฅ Roast Level", Value: props.Bean.RoastLevel})
62
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
128
-
</div>
129
129
-
}
130
130
-
131
131
-
templ BeanDetailField(label, value string) {
132
132
-
<div class="section-box">
133
133
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
134
134
-
if value != "" {
135
135
-
<div class="font-semibold text-brown-900">{ value }</div>
136
136
-
} else {
137
137
-
<span class="text-brown-400">Not specified</span>
138
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
158
-
@BrewParameter("โ๏ธ Coffee", getCoffeeAmountDisplay(brew))
159
159
-
@BrewLinkedParameter("โ Brew Method", getBrewerName(brew), getBrewerViewURL(brew, owner))
160
160
-
@BrewLinkedParameter("โ๏ธ Grinder", getGrinderName(brew), getGrinderViewURL(brew, owner))
161
161
-
@BrewParameter("๐ฉ Grind Size", getGrindSizeDisplay(brew))
162
162
-
@BrewParameter("๐ง Water", getWaterAmountDisplay(brew))
163
163
-
@BrewParameter("๐ก๏ธ Temperature", getTemperatureDisplay(brew))
158
158
+
@components.DetailField(components.DetailFieldProps{Label: "โ๏ธ Coffee", Value: getCoffeeAmountDisplay(brew)})
159
159
+
@components.DetailField(components.DetailFieldProps{Label: "โ Brew Method", Value: getBrewerName(brew), LinkHref: getBrewerViewURL(brew, owner)})
160
160
+
@components.DetailField(components.DetailFieldProps{Label: "โ๏ธ Grinder", Value: getGrinderName(brew), LinkHref: getGrinderViewURL(brew, owner)})
161
161
+
@components.DetailField(components.DetailFieldProps{Label: "๐ฉ Grind Size", Value: getGrindSizeDisplay(brew)})
162
162
+
@components.DetailField(components.DetailFieldProps{Label: "๐ง Water", Value: getWaterAmountDisplay(brew)})
163
163
+
@components.DetailField(components.DetailFieldProps{Label: "๐ก๏ธ Temperature", Value: getTemperatureDisplay(brew)})
164
164
<div class="col-span-2">
165
165
-
@BrewParameter("โฑ๏ธ Brew Time", getBrewTimeDisplay(brew))
165
165
+
@components.DetailField(components.DetailFieldProps{Label: "โฑ๏ธ Brew Time", Value: getBrewTimeDisplay(brew)})
166
166
</div>
167
167
-
</div>
168
168
-
}
169
169
-
170
170
-
// BrewParameter renders a single parameter in the grid
171
171
-
templ BrewParameter(label string, value string) {
172
172
-
<div class="section-box">
173
173
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
174
174
-
if value != "" {
175
175
-
<div class="font-semibold text-brown-900">{ value }</div>
176
176
-
} else {
177
177
-
<span class="text-brown-400">Not specified</span>
178
178
-
}
179
179
-
</div>
180
180
-
}
181
181
-
182
182
-
// BrewLinkedParameter renders a parameter with a clickable link to the entity view page
183
183
-
templ BrewLinkedParameter(label string, value string, href string) {
184
184
-
<div class="section-box">
185
185
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
186
186
-
if value != "" && href != "" {
187
187
-
<a href={ templ.SafeURL(href) } class="font-semibold text-brown-900 hover:underline">{ value }</a>
188
188
-
} else if value != "" {
189
189
-
<div class="font-semibold text-brown-900">{ value }</div>
190
190
-
} else {
191
191
-
<span class="text-brown-400">Not specified</span>
192
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
41
-
if props.Brewer.BrewerType != "" {
42
42
-
<div class="section-box">
43
43
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">โ Type</h3>
44
44
-
<div class="font-semibold text-brown-900">{ props.Brewer.BrewerType }</div>
45
45
-
</div>
46
46
-
}
41
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
42
-
@GrinderDetailField("โ๏ธ Type", props.Grinder.GrinderType)
43
43
-
@GrinderDetailField("๐ฉ Burr Type", props.Grinder.BurrType)
42
42
+
@components.DetailField(components.DetailFieldProps{Label: "โ๏ธ Type", Value: props.Grinder.GrinderType})
43
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
100
-
templ GrinderDetailField(label, value string) {
101
101
-
<div class="section-box">
102
102
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
103
103
-
if value != "" {
104
104
-
<div class="font-semibold text-brown-900">{ value }</div>
105
105
-
} else {
106
106
-
<span class="text-brown-400">Not specified</span>
107
107
-
}
108
108
-
</div>
109
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
43
-
@RoasterDetailField("๐ Location", props.Roaster.Location)
43
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
106
-
templ RoasterDetailField(label, value string) {
107
107
-
<div class="section-box">
108
108
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
109
109
-
if value != "" {
110
110
-
<div class="font-semibold text-brown-900">{ value }</div>
111
111
-
} else {
112
112
-
<span class="text-brown-400">Not specified</span>
113
113
-
}
114
114
-
</div>
115
115
-
}