moderation.gno
8.83 Kb · 312 lines
1package catalog
2
3import (
4 "strconv"
5 "strings"
6
7 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
8 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
9)
10
11// Report flags a track, album, artist or playlist for the moderators
12// (e.g. "broken link", "not the real artist", "copyright").
13func Report(cur realm, kind string, target int, reason string) {
14 caller := userCaller(cur)
15 noPayment()
16 reason = strings.TrimSpace(reason)
17 mustText("reason", reason, 4, maxReason)
18 t := rd()
19 switch kind {
20 case "track":
21 mustTrack(t, target)
22 case "album":
23 if al := getAlbum(t, target); al == nil || al.Hidden {
24 panic("catalog: unknown album")
25 }
26 case "artist":
27 mustArtist(t, target)
28 case "playlist":
29 if p := getPlaylist(t, target); p == nil || p.Hidden {
30 panic("catalog: unknown playlist")
31 }
32 default:
33 panic("catalog: kind must be track, album, artist or playlist")
34 }
35 u := user(t, caller)
36 if u.Reports >= maxUserReports {
37 panic("catalog: you have " + text.Itoa(maxUserReports) + " open reports, wait for the moderators")
38 }
39 u.Reports++
40 putUser(t, caller, u)
41 t.bump(mOpen, 1)
42 t.put(cReports, store.Pad(int(t.bump(mReports, 1))), store.Rec(kind, text.Itoa(target), reason, caller.String(), text.Itoa64(now())))
43 save(cur, t)
44 emitEvent("Reported", "kind", kind, "target", text.Itoa(target), "by", caller.String())
45}
46
47func getReport(t *tx, id int) (ReportInfo, bool) {
48 rec, ok := t.get(cReports, store.Pad(id))
49 if !ok || id < 1 {
50 return ReportInfo{}, false
51 }
52 f := store.Fields(rec)
53 return ReportInfo{ID: id, Kind: f[0], Target: f[1], Reason: f[2], By: address(f[3]), At: text.Atoi64(f[4])}, true
54}
55
56// ResolveReport closes a report once handled. Admin only.
57func ResolveReport(cur realm, id int) {
58 onlyAdmin(cur)
59 t := rd()
60 if !resolve(t, id) {
61 panic("catalog: unknown report")
62 }
63 save(cur, t)
64}
65
66// ResolveReports closes the open reports with ids from..from+count-1 (count
67// 1-50) in one transaction, e.g. a run of spam reports; ids already closed
68// are skipped. There is no global cap on open reports, so spam never blocks
69// an honest reporter: each wallet keeps at most 5 open. Admin only.
70func ResolveReports(cur realm, from, count int) {
71 onlyAdmin(cur)
72 if count < 1 || count > 50 {
73 panic("catalog: resolve 1 to 50 reports at a time")
74 }
75 t := rd()
76 for id := from; id < from+count; id++ {
77 resolve(t, id)
78 }
79 save(cur, t)
80}
81
82// resolve closes one report, false when there is no such open report.
83func resolve(t *tx, id int) bool {
84 r, ok := getReport(t, id)
85 if !ok {
86 return false
87 }
88 t.del(cReports, store.Pad(id))
89 if t.count(mOpen) > 0 {
90 t.bump(mOpen, -1)
91 }
92 if u := user(t, r.By); u.Reports > 0 {
93 u.Reports--
94 putUser(t, r.By, u)
95 }
96 emitEvent("ReportResolved", "id", text.Itoa(id))
97 return true
98}
99
100// Hide or restore content. Admin only; hiding takes a public reason (4-200
101// characters) and restoring works on anything, whoever hid it. Hidden content
102// stays on-chain (and on IPFS); it is no longer listed, played or tippable.
103// Radio stations drop a hidden track on their next Refresh
104// (radio.RefreshArtist for a whole artist).
105func HideTrack(cur realm, trackID int, hidden bool, reason string) {
106 onlyAdmin(cur)
107 t := rd()
108 tr := getTrack(t, trackID)
109 if tr == nil {
110 panic("catalog: unknown track")
111 }
112 tr.Hidden = hidden
113 putTrack(t, tr)
114 moderate(cur, t, "track", trackID, hidden, reason)
115}
116
117func HideAlbum(cur realm, albumID int, hidden bool, reason string) {
118 onlyAdmin(cur)
119 t := rd()
120 al := getAlbum(t, albumID)
121 if al == nil {
122 panic("catalog: unknown album")
123 }
124 al.Hidden = hidden
125 putAlbum(t, al)
126 moderate(cur, t, "album", albumID, hidden, reason)
127}
128
129func HideArtist(cur realm, artistID int, hidden bool, reason string) {
130 onlyAdmin(cur)
131 t := rd()
132 a := getArtist(t, artistID)
133 if a == nil {
134 panic("catalog: unknown artist")
135 }
136 a.Hidden = hidden
137 putArtist(t, a)
138 moderate(cur, t, "artist", artistID, hidden, reason)
139}
140
141func HidePlaylist(cur realm, playlistID int, hidden bool, reason string) {
142 onlyAdmin(cur)
143 t := rd()
144 p := getPlaylist(t, playlistID)
145 if p == nil {
146 panic("catalog: unknown playlist")
147 }
148 p.Hidden = hidden
149 putPlaylist(t, p)
150 moderate(cur, t, "playlist", playlistID, hidden, reason)
151}
152
153// moderate records the moderator's public reason and saves t.
154func moderate(cur realm, t *tx, kind string, id int, hidden bool, reason string) {
155 mark(t, kind, id, hidden, reason)
156 save(cur, t)
157}
158
159// mark records the moderator's public reason for one item (the caller saves).
160func mark(t *tx, kind string, id int, hidden bool, reason string) {
161 reason = strings.TrimSpace(reason)
162 k := kind + ":" + text.Itoa(id)
163 if hidden {
164 mustText("reason", reason, 4, maxReason)
165 t.set(cRemoved, k, reason)
166 } else {
167 t.del(cRemoved, k)
168 }
169 emitEvent("Moderated", "kind", kind, "target", text.Itoa(id), "hidden", strconv.FormatBool(hidden), "reason", reason, "by", "moderator")
170}
171
172// maxHideBatch bounds HideSource: 2 writes per artist.
173const maxHideBatch = 50
174
175// HideSource hides every artist read from a platform ("audius" or
176// "jamendo"), and so all their tracks, for a platform that ends GnoRadio's
177// API access. It walks artist ids from `from`, count (1-50) at a time, and
178// returns where to go on (0 once past the last artist). One artist opting out
179// is a HideArtist. Admin only.
180func HideSource(cur realm, kind string, from, count int, reason string) int {
181 onlyAdmin(cur)
182 if !isRef(kind) || from < 1 || count < 1 || count > maxHideBatch {
183 panic("catalog: HideSource(audius|jamendo, from >= 1, count 1-" + text.Itoa(maxHideBatch) + ", reason)")
184 }
185 mustText("reason", strings.TrimSpace(reason), 4, maxReason)
186 t := rd()
187 last := int(t.count(mArtists))
188 for id := from; id < from+count && id <= last; id++ {
189 if a := getArtist(t, id); a != nil && a.Kind == kind {
190 // An artist that hid itself gets the reason too, so HideOwn cannot show it again;
191 // one a moderator hid keeps its own reason.
192 if r, ok := t.get(cRemoved, "artist:"+text.Itoa(id)); a.Hidden && ok && r != "" {
193 continue
194 }
195 if !a.Hidden {
196 a.Hidden = true
197 putArtist(t, a)
198 }
199 mark(t, "artist", id, true, reason)
200 }
201 }
202 save(cur, t)
203 if from+count > last {
204 return 0
205 }
206 return from + count
207}
208
209// HideOwn hides or shows again the caller's own track, album or artist
210// profile ("artist", with the profile's id). The artist can show again only
211// what they hid themselves: what the moderator hid stays hidden.
212func HideOwn(cur realm, kind string, id int, hidden bool) {
213 caller := userCaller(cur)
214 noPayment()
215 t := rd()
216 a := getArtist(t, artistOf(t, caller))
217 if a == nil {
218 panic("catalog: create your artist profile first")
219 }
220 k := kind + ":" + text.Itoa(id)
221 if reason, ok := t.get(cRemoved, k); ok && reason != "" {
222 panic("catalog: hidden by the moderator; contact them to restore it")
223 }
224 switch kind {
225 case "track":
226 tr := getTrack(t, id)
227 if tr == nil || tr.Artist != a.ID {
228 panic("catalog: not your track")
229 }
230 mustToggle(tr.Hidden, hidden)
231 tr.Hidden = hidden
232 putTrack(t, tr)
233 case "album":
234 al := getAlbum(t, id)
235 if al == nil || al.Artist != a.ID {
236 panic("catalog: not your album")
237 }
238 mustToggle(al.Hidden, hidden)
239 al.Hidden = hidden
240 putAlbum(t, al)
241 case "artist":
242 if id != a.ID {
243 panic("catalog: not your profile")
244 }
245 mustToggle(a.Hidden, hidden)
246 a.Hidden = hidden
247 putArtist(t, a)
248 default:
249 panic("catalog: kind must be track, album or artist")
250 }
251 if hidden {
252 t.set(cRemoved, k, "")
253 } else {
254 t.del(cRemoved, k)
255 }
256 save(cur, t)
257 emitEvent("Moderated", "kind", kind, "target", text.Itoa(id), "hidden", strconv.FormatBool(hidden), "reason", "", "by", "artist")
258}
259
260func mustToggle(was, hidden bool) {
261 if was == hidden {
262 if hidden {
263 panic("catalog: already hidden")
264 }
265 panic("catalog: already shown")
266 }
267}
268
269// HiddenNote says why a track, album, artist or playlist is hidden, for its
270// page: "Removed by moderation: <reason>", "Hidden by the artist.", or "".
271func HiddenNote(kind string, id int) string {
272 reason, ok := rd().get(cRemoved, kind+":"+text.Itoa(id))
273 switch {
274 case !ok:
275 return ""
276 case reason == "":
277 return "Hidden by the artist."
278 }
279 return "Removed by moderation: " + reason
280}
281
282// Verify marks an artist whose identity was checked off-chain.
283func Verify(cur realm, artistID int, verified bool) {
284 onlyAdmin(cur)
285 t := rd()
286 a := mustArtist(t, artistID)
287 a.Verified = verified
288 putArtist(t, a)
289 t.del(cClaims, store.Pad(artistID)) // settled by the admin either way
290 if !verified {
291 t.del(cProofs, store.Pad(artistID))
292 }
293 save(cur, t)
294 emitEvent("Verified", "artist", text.Itoa(artistID), "verified", strconv.FormatBool(verified))
295}
296
297// AllowHost adds or removes an https host that audio and covers may use.
298func AllowHost(cur realm, host string, allowed bool) {
299 onlyAdmin(cur)
300 host = strings.ToLower(strings.TrimSpace(host))
301 if !validHost(host) {
302 panic("catalog: invalid host")
303 }
304 t := rd()
305 if allowed {
306 t.set(cHosts, host, "1")
307 } else {
308 t.del(cHosts, host)
309 }
310 save(cur, t)
311 emitEvent("HostAllowed", "host", host, "allowed", strconv.FormatBool(allowed))
312}