This commit is contained in:
onysd 2026-08-25 00:20:05 +03:00
parent fe1cf1184f
commit e6a777983c
10 changed files with 174 additions and 2 deletions

View file

@ -126,6 +126,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-category", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogCategoryAPI)))
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
@ -1961,6 +1962,24 @@ func (s *server) handleAutoCategorizeGifCatalogAPI(w http.ResponseWriter, r *htt
writeCommandResultAPI(w, result, err)
}
type deleteUncategorizedGifsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
}
func (s *server) handleDeleteUncategorizedGifsAPI(w http.ResponseWriter, r *http.Request) {
var body deleteUncategorizedGifsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.DeleteUncategorizedGifsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-uncategorized-gifs"),
}
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/delete-uncategorized", req)
writeCommandResultAPI(w, result, err)
}
type deleteGifCatalogEntryAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`

View file

@ -23,7 +23,7 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-CUyr22ox.js"></script>
<script type="module" crossorigin src="/assets/index-Bt9UBcEE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
</head>
<body>

View file

@ -104,6 +104,13 @@ export function GifCatalogPage() {
payload={() => ({})}
onDone={() => void load()}
/>
<ActionButton
tone="danger"
label={"Delete uncategorized"}
path="/api/actions/delete-uncategorized-gifs"
payload={() => ({})}
onDone={() => void load()}
/>
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
<Plus size={15} /> {"Add GIF"}
</button>

View file

@ -59,6 +59,7 @@ const (
ActionSetGifCatalogSortOrder = "gif_catalog.set_sort_order"
ActionSetGifCatalogCategory = "gif_catalog.set_category"
ActionAutoCategorizeGifCatalog = "gif_catalog.auto_categorize"
ActionDeleteUncategorizedGifs = "gif_catalog.delete_uncategorized"
ActionDeleteGifCatalogEntry = "gif_catalog.delete"
// Collectible (Fragment-style) username lifecycle.
ActionMintCollectibleUsername = "usernames.collectible.mint"
@ -312,6 +313,10 @@ type GifCatalogService interface {
// currently-uncategorized entry's title and returns how many got a
// category assigned.
AdminAutoCategorizeGifCatalog(ctx context.Context) (int, error)
// AdminDeleteUncategorizedGifs removes every catalog entry with no
// category, plus its document/blob when nothing else references it.
// Returns (catalog entries deleted, documents actually deleted).
AdminDeleteUncategorizedGifs(ctx context.Context) (int, int, error)
AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error)
}
@ -717,6 +722,10 @@ type AutoCategorizeGifCatalogRequest struct {
CommandMeta
}
type DeleteUncategorizedGifsRequest struct {
CommandMeta
}
type DeleteGifCatalogEntryRequest struct {
CommandMeta
ID int64 `json:"id"`
@ -2897,6 +2906,36 @@ func (s *Service) AutoCategorizeGifCatalog(ctx context.Context, req AutoCategori
})
}
func (s *Service) DeleteUncategorizedGifs(ctx context.Context, req DeleteUncategorizedGifsRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil {
return CommandResult{}, fmt.Errorf("gif catalog service is not configured")
}
return s.runCommand(ctx, req.CommandMeta, ActionDeleteUncategorizedGifs, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{}
if req.DryRun {
// A real count, not just "validated" -- this is a bulk delete, and
// an operator confirming it deserves to know how many entries
// they're about to lose before they do.
entries, err := s.gifCatalog.AdminListGifCatalog(ctx)
if err != nil {
return CommandResult{}, err
}
uncategorized := 0
for _, e := range entries {
if e.Category == "" {
uncategorized++
}
}
details["would_delete"] = uncategorized
return CommandResult{Message: fmt.Sprintf("would delete %d uncategorized gif(s)", uncategorized), Details: details}, nil
}
deletedEntries, deletedDocuments, err := s.gifCatalog.AdminDeleteUncategorizedGifs(ctx)
details["deleted_entries"] = deletedEntries
details["deleted_documents"] = deletedDocuments
return CommandResult{Message: "uncategorized gifs deleted", Details: details}, err
})
}
func (s *Service) DeleteGifCatalogEntry(ctx context.Context, req DeleteGifCatalogEntryRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")

View file

@ -81,6 +81,7 @@ type Service interface {
SetGifCatalogSortOrder(ctx context.Context, req admin.SetGifCatalogSortOrderRequest) (admin.CommandResult, error)
SetGifCatalogCategory(ctx context.Context, req admin.SetGifCatalogCategoryRequest) (admin.CommandResult, error)
AutoCategorizeGifCatalog(ctx context.Context, req admin.AutoCategorizeGifCatalogRequest) (admin.CommandResult, error)
DeleteUncategorizedGifs(ctx context.Context, req admin.DeleteUncategorizedGifsRequest) (admin.CommandResult, error)
DeleteGifCatalogEntry(ctx context.Context, req admin.DeleteGifCatalogEntryRequest) (admin.CommandResult, error)
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
@ -215,6 +216,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/gif-catalog/set-sort-order", s.authenticated(s.handleSetGifCatalogSortOrder))
mux.HandleFunc("POST /v1/gif-catalog/set-category", s.authenticated(s.handleSetGifCatalogCategory))
mux.HandleFunc("POST /v1/gif-catalog/auto-categorize", s.authenticated(s.handleAutoCategorizeGifCatalog))
mux.HandleFunc("POST /v1/gif-catalog/delete-uncategorized", s.authenticated(s.handleDeleteUncategorizedGifs))
mux.HandleFunc("POST /v1/gif-catalog/delete", s.authenticated(s.handleDeleteGifCatalogEntry))
mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation))
mux.HandleFunc("GET /v1/gif-catalog/documents/{id}/preview", s.authenticated(s.handleGifCatalogDocumentPreview))
@ -777,6 +779,15 @@ func (s *Server) handleAutoCategorizeGifCatalog(w http.ResponseWriter, r *http.R
writeCommandResult(w, result, err)
}
func (s *Server) handleDeleteUncategorizedGifs(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteUncategorizedGifsRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.DeleteUncategorizedGifs(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleDeleteGifCatalogEntry(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteGifCatalogEntryRequest
if !decodeJSON(w, r, &req) {

View file

@ -468,6 +468,10 @@ func (fakeService) AutoCategorizeGifCatalog(_ context.Context, req admin.AutoCat
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) DeleteUncategorizedGifs(_ context.Context, req admin.DeleteUncategorizedGifsRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}
func (fakeService) DeleteGifCatalogEntry(_ context.Context, req admin.DeleteGifCatalogEntryRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
}

View file

@ -249,6 +249,47 @@ func (s *Service) AdminAutoCategorizeGifCatalog(ctx context.Context) (int, error
return changed, nil
}
// AdminDeleteUncategorizedGifs removes every gif_catalog entry with no
// category (Category == "") -- both the catalog row and, when safe, the
// underlying document/blob. "Safe" means deleteDocumentNowIfUnreferenced
// found nothing else pointing at that document (see its doc comment): a
// user who already saved or forwarded one of these GIFs before this ran
// keeps their copy, only the catalog listing (and the document, if no
// longer referenced anywhere) goes away. Returns how many catalog entries
// and how many documents were actually deleted.
func (s *Service) AdminDeleteUncategorizedGifs(ctx context.Context) (deletedEntries, deletedDocuments int, err error) {
if s.gifCatalog == nil {
return 0, 0, domain.ErrGifCatalogUnavailable
}
entries, err := s.gifCatalog.ListGifCatalog(ctx, false, 0)
if err != nil {
return 0, 0, err
}
for _, e := range entries {
if e.Category != "" {
continue
}
ok, err := s.gifCatalog.DeleteGifCatalogEntry(ctx, e.ID)
if err != nil {
return deletedEntries, deletedDocuments, err
}
if !ok {
continue
}
deletedEntries++
deleted, err := s.deleteDocumentNowIfUnreferenced(ctx, e.DocumentID)
if err != nil {
s.log.Warn("delete uncategorized gif document failed",
zap.Int64("catalog_entry_id", e.ID), zap.Int64("document_id", e.DocumentID), zap.Error(err))
continue
}
if deleted {
deletedDocuments++
}
}
return deletedEntries, deletedDocuments, nil
}
// AdminDeleteGifCatalogEntry removes an entry from the catalog. The
// referenced document is left alone.
func (s *Service) AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) {

View file

@ -21,6 +21,9 @@ type mediaRetentionStore interface {
CountFileBlobRefs(ctx context.Context, backend, objectKey string) (int, error)
DeleteDocumentAndBlobs(ctx context.Context, id int64) ([]domain.FileBlob, error)
DeletePhotoAndBlobs(ctx context.Context, id int64) ([]domain.FileBlob, error)
// OrphanDocumentIfUnreferenced is the immediate (no grace period)
// counterpart to the age-based sweep above -- see its doc comment.
OrphanDocumentIfUnreferenced(ctx context.Context, id int64) (bool, error)
}
// DeleteOrphanedOlderThan implements maintenance.OrphanedMediaRetentionStore:
@ -65,6 +68,33 @@ func (s *Service) DeleteOrphanedOlderThan(ctx context.Context, cutoff time.Time,
return deleted, nil
}
// deleteDocumentNowIfUnreferenced is the immediate counterpart to the
// age-based sweep DeleteOrphanedOlderThan runs in the background: orphans
// id right now (skipping the grace period) and, only if that succeeds --
// i.e. nothing else currently references it -- physically deletes it and
// its blobs immediately. Returns whether it was actually deleted; false
// (with no error) means something still references the document, so it and
// its blob(s) were deliberately left alone.
func (s *Service) deleteDocumentNowIfUnreferenced(ctx context.Context, id int64) (bool, error) {
store, ok := s.media.(mediaRetentionStore)
if !ok {
return false, nil
}
orphaned, err := store.OrphanDocumentIfUnreferenced(ctx, id)
if err != nil {
return false, fmt.Errorf("orphan document: %w", err)
}
if !orphaned {
return false, nil
}
blobs, err := store.DeleteDocumentAndBlobs(ctx, id)
if err != nil {
return false, fmt.Errorf("delete document: %w", err)
}
s.deleteOrphanedBlobs(ctx, store, blobs)
return true, nil
}
// deleteOrphanedBlobs removes each blob from its backend once confirming
// (via CountFileBlobRefs) no other file_blobs row still references
// (backend, object_key). Resolves the correct backend per blob via

View file

@ -107,6 +107,27 @@ RETURNING media_kind, media_id`, string(refKind), refKey)
// ---- storage retention sweep ----
// OrphanDocumentIfUnreferenced marks a document orphaned right now if
// nothing currently references it (media_references), for a caller that
// wants an immediate answer instead of waiting for the age-based
// ListOrphanedDocumentIDsOlderThan sweep -- e.g. a human deliberately
// pruning catalog entries, not an accidental delete the sweep's grace period
// exists to protect against. Returns whether it just became orphaned; false
// if something still references it (safe: the document is left alone) or it
// was already orphaned.
func (s *MediaStore) OrphanDocumentIfUnreferenced(ctx context.Context, id int64) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE documents SET orphaned_at = now()
WHERE id = $1
AND orphaned_at IS NULL
AND NOT EXISTS (SELECT 1 FROM media_references WHERE media_kind = 'document' AND media_id = $1)`,
id)
if err != nil {
return false, fmt.Errorf("orphan document if unreferenced: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// ListOrphanedDocumentIDsOlderThan returns document ids whose orphaned_at is
// set and older than cutoff, oldest first, up to limit.
func (s *MediaStore) ListOrphanedDocumentIDsOlderThan(ctx context.Context, cutoff time.Time, limit int) ([]int64, error) {