Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
7.1 KiB
Go
215 lines
7.1 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
|
|
)
|
|
|
|
// adminQueueRowView is a single aggregated row in the admin queue response.
|
|
type adminQueueRowView struct {
|
|
TrackID string `json:"track_id"`
|
|
TrackTitle string `json:"track_title"`
|
|
ArtistName string `json:"artist_name"`
|
|
AlbumTitle string `json:"album_title"`
|
|
AlbumID string `json:"album_id"`
|
|
ReportCount int32 `json:"report_count"`
|
|
LatestAt string `json:"latest_at"`
|
|
ReasonCounts map[string]int `json:"reason_counts"`
|
|
Reports []adminQueueReportView `json:"reports"`
|
|
}
|
|
|
|
// adminQueueReportView is one user's report under an aggregated admin row.
|
|
type adminQueueReportView struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
Reason string `json:"reason"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// formatTimestamp renders a pgtype.Timestamptz as RFC3339 or empty string.
|
|
func formatTimestamp(ts pgtype.Timestamptz) string {
|
|
if !ts.Valid {
|
|
return ""
|
|
}
|
|
return ts.Time.Format("2006-01-02T15:04:05Z07:00")
|
|
}
|
|
|
|
// handleListAdminQuarantine implements GET /api/admin/quarantine.
|
|
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := h.quarantine.ListAdminQueue(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("admin: list quarantine", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
|
return
|
|
}
|
|
out := make([]adminQueueRowView, 0, len(rows))
|
|
for _, row := range rows {
|
|
reports := make([]adminQueueReportView, 0, len(row.Reports))
|
|
for _, rep := range row.Reports {
|
|
reports = append(reports, adminQueueReportView{
|
|
UserID: uuidToString(rep.UserID),
|
|
Username: rep.Username,
|
|
Reason: rep.Reason,
|
|
Notes: rep.Notes,
|
|
CreatedAt: formatTimestamp(rep.CreatedAt),
|
|
})
|
|
}
|
|
out = append(out, adminQueueRowView{
|
|
TrackID: uuidToString(row.TrackID),
|
|
TrackTitle: row.TrackTitle,
|
|
ArtistName: row.ArtistName,
|
|
AlbumTitle: row.AlbumTitle,
|
|
AlbumID: uuidToString(row.AlbumID),
|
|
ReportCount: row.ReportCount,
|
|
LatestAt: formatTimestamp(row.LatestAt),
|
|
ReasonCounts: row.ReasonCounts,
|
|
Reports: reports,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// actionResultView is the response shape for the admin actions (resolve,
|
|
// delete-file).
|
|
type actionResultView struct {
|
|
ActionID string `json:"action_id"`
|
|
AffectedUsers int32 `json:"affected_users"`
|
|
}
|
|
|
|
// handleResolveQuarantine implements POST
|
|
// /api/admin/quarantine/{track_id}/resolve.
|
|
func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Request) {
|
|
admin, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
id, ok := parseUUID(chi.URLParam(r, "track_id"))
|
|
if !ok {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
|
return
|
|
}
|
|
action, err := h.quarantine.Resolve(r.Context(), id, admin.ID)
|
|
if err != nil {
|
|
if errors.Is(err, quarantine.ErrTrackNotFound) {
|
|
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
|
return
|
|
}
|
|
h.logger.Error("admin: resolve quarantine", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
|
return
|
|
}
|
|
// Broadcast: affects every user who'd flagged this track.
|
|
h.publishQuarantineEvent("quarantine.resolved", pgtype.UUID{}, id, true)
|
|
writeJSON(w, http.StatusOK, actionResultView{
|
|
ActionID: uuidToString(action.ID),
|
|
AffectedUsers: action.AffectedUsers,
|
|
})
|
|
}
|
|
|
|
// handleDeleteQuarantineFile implements POST
|
|
// /api/admin/quarantine/{track_id}/delete-file.
|
|
func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Request) {
|
|
admin, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
id, ok := parseUUID(chi.URLParam(r, "track_id"))
|
|
if !ok {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
|
return
|
|
}
|
|
action, err := h.quarantine.DeleteFile(r.Context(), id, admin.ID)
|
|
if err != nil {
|
|
// Written in the enveloped shape, not writeAdminJSONErr's bare code:
|
|
// the message is the part that tells an admin which directory and uid.
|
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
|
logFileRemoveFailure(h.logger, apiErr, "track_id", uuidToString(id))
|
|
writeErr(w, apiErr)
|
|
return
|
|
}
|
|
switch {
|
|
case errors.Is(err, quarantine.ErrTrackNotFound):
|
|
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
|
default:
|
|
h.logger.Error("admin: delete file", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "file_delete_failed")
|
|
}
|
|
return
|
|
}
|
|
// Broadcast: the track row is gone; every client's library/likes/queue
|
|
// invalidates so stale references stop showing.
|
|
h.publishQuarantineEvent("quarantine.file_deleted", pgtype.UUID{}, id, true)
|
|
writeJSON(w, http.StatusOK, actionResultView{
|
|
ActionID: uuidToString(action.ID),
|
|
AffectedUsers: action.AffectedUsers,
|
|
})
|
|
}
|
|
|
|
// actionLogView is the row shape returned by GET /api/admin/quarantine/actions.
|
|
type actionLogView struct {
|
|
ID string `json:"id"`
|
|
TrackID string `json:"track_id"`
|
|
TrackTitle string `json:"track_title"`
|
|
ArtistName string `json:"artist_name"`
|
|
AlbumTitle *string `json:"album_title,omitempty"`
|
|
Action string `json:"action"`
|
|
AdminID *string `json:"admin_id,omitempty"`
|
|
// AlbumMBID is set on the older deleted_album rows: the album removed.
|
|
AlbumMBID *string `json:"album_mbid,omitempty"`
|
|
AffectedUsers int32 `json:"affected_users"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// handleListQuarantineActions implements GET /api/admin/quarantine/actions.
|
|
// ?limit= defaults to 50, capped at 200.
|
|
func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Request) {
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit := int32(50)
|
|
if limitStr != "" {
|
|
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 {
|
|
if v > 200 {
|
|
v = 200
|
|
}
|
|
limit = int32(v)
|
|
}
|
|
}
|
|
rows, err := dbq.New(h.pool).ListQuarantineActions(r.Context(), limit)
|
|
if err != nil {
|
|
h.logger.Error("admin: list actions", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
|
return
|
|
}
|
|
out := make([]actionLogView, 0, len(rows))
|
|
for _, row := range rows {
|
|
var adminID *string
|
|
if row.AdminID.Valid {
|
|
s := uuidToString(row.AdminID)
|
|
adminID = &s
|
|
}
|
|
out = append(out, actionLogView{
|
|
ID: uuidToString(row.ID),
|
|
TrackID: uuidToString(row.TrackID),
|
|
TrackTitle: row.TrackTitle,
|
|
ArtistName: row.ArtistName,
|
|
AlbumTitle: row.AlbumTitle,
|
|
Action: string(row.Action),
|
|
AdminID: adminID,
|
|
AlbumMBID: row.AlbumMbid,
|
|
AffectedUsers: row.AffectedUsers,
|
|
CreatedAt: formatTimestamp(row.CreatedAt),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|