fix
This commit is contained in:
parent
fe1cf1184f
commit
e6a777983c
10 changed files with 174 additions and 2 deletions
|
|
@ -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"`
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue