206 lines
5.7 KiB
Go
206 lines
5.7 KiB
Go
package main
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"log"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"regexp"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// trashStamp is the timestamp moveToTrash appends: 20060102-150405.
|
||
|
|
var trashStamp = regexp.MustCompile(`^\d{8}-\d{6}$`)
|
||
|
|
|
||
|
|
// trashItem is one recoverable post in content/.trash/, grouping the image and
|
||
|
|
// its sidecar that were trashed together (same slug, same timestamp).
|
||
|
|
type trashItem struct {
|
||
|
|
ID string `json:"id"` // "<slug>.<stamp>", the restore/empty handle
|
||
|
|
Slug string `json:"slug"` // original slug it would restore to
|
||
|
|
DeletedAt string `json:"deletedAt"`
|
||
|
|
Image string `json:"image"` // trash filename of the image, "" if only a sidecar
|
||
|
|
HasMeta bool `json:"hasMeta"` // whether a sidecar is present
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) trashDir() string { return filepath.Join(s.contentDir, ".trash") }
|
||
|
|
|
||
|
|
// parseTrashName splits a trash filename "<slug>.<stamp><ext>" into its parts.
|
||
|
|
// slug is a bare sanitised name (no dots) and stamp is a fixed timestamp, so
|
||
|
|
// the extension and the stamp peel off unambiguously from the right.
|
||
|
|
func parseTrashName(name string) (slug, stamp, ext string, ok bool) {
|
||
|
|
ext = filepath.Ext(name)
|
||
|
|
rest := strings.TrimSuffix(name, ext) // "<slug>.<stamp>"
|
||
|
|
i := strings.LastIndex(rest, ".")
|
||
|
|
if i < 0 {
|
||
|
|
return "", "", "", false
|
||
|
|
}
|
||
|
|
slug, stamp = rest[:i], rest[i+1:]
|
||
|
|
if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") {
|
||
|
|
return "", "", "", false
|
||
|
|
}
|
||
|
|
if !trashStamp.MatchString(stamp) {
|
||
|
|
return "", "", "", false
|
||
|
|
}
|
||
|
|
return slug, stamp, ext, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func isImageExt(ext string) bool {
|
||
|
|
for _, e := range contentExts {
|
||
|
|
if ext == e {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleListTrash returns trashed items, newest first.
|
||
|
|
func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) {
|
||
|
|
entries, err := os.ReadDir(s.trashDir())
|
||
|
|
if err != nil {
|
||
|
|
// No trash dir yet → empty list, not an error.
|
||
|
|
writeJSON(w, []trashItem{})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
byID := make(map[string]*trashItem)
|
||
|
|
for _, e := range entries {
|
||
|
|
if e.IsDir() {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
slug, stamp, ext, ok := parseTrashName(e.Name())
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
id := slug + "." + stamp
|
||
|
|
it := byID[id]
|
||
|
|
if it == nil {
|
||
|
|
when := stamp
|
||
|
|
if t, err := time.ParseInLocation("20060102-150405", stamp, time.Local); err == nil {
|
||
|
|
when = t.Format("2006-01-02 15:04")
|
||
|
|
}
|
||
|
|
it = &trashItem{ID: id, Slug: slug, DeletedAt: when}
|
||
|
|
byID[id] = it
|
||
|
|
}
|
||
|
|
if ext == ".toml" {
|
||
|
|
it.HasMeta = true
|
||
|
|
} else if isImageExt(ext) {
|
||
|
|
it.Image = e.Name()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
items := make([]trashItem, 0, len(byID))
|
||
|
|
for _, it := range byID {
|
||
|
|
items = append(items, *it)
|
||
|
|
}
|
||
|
|
// Newest first: the stamp sorts lexically in time order, and it is the tail
|
||
|
|
// of the ID.
|
||
|
|
sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID })
|
||
|
|
writeJSON(w, items)
|
||
|
|
}
|
||
|
|
|
||
|
|
// splitTrashID validates a "<slug>.<stamp>" handle and returns its parts.
|
||
|
|
func splitTrashID(id string) (slug, stamp string, ok bool) {
|
||
|
|
i := strings.LastIndex(id, ".")
|
||
|
|
if i < 0 {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
slug, stamp = id[:i], id[i+1:]
|
||
|
|
if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
if !trashStamp.MatchString(stamp) {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
return slug, stamp, true
|
||
|
|
}
|
||
|
|
|
||
|
|
// trashFiles returns the files in .trash belonging to one id (image + sidecar).
|
||
|
|
func (s *Server) trashFiles(slug, stamp string) []string {
|
||
|
|
var out []string
|
||
|
|
prefix := slug + "." + stamp
|
||
|
|
for _, ext := range append(append([]string{}, contentExts...), ".toml") {
|
||
|
|
p := filepath.Join(s.trashDir(), prefix+ext)
|
||
|
|
if _, err := os.Stat(p); err == nil {
|
||
|
|
out = append(out, p)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleRestore moves a trashed post back into content/, stripping the
|
||
|
|
// timestamp. It refuses to overwrite a live post with the same slug.
|
||
|
|
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if s.rejectCrossSite(w, r) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := r.ParseForm(); err != nil {
|
||
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
slug, stamp, ok := splitTrashID(r.FormValue("id"))
|
||
|
|
if !ok {
|
||
|
|
http.Error(w, "bad id", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if _, live := s.resolveSlug(slug); live {
|
||
|
|
http.Error(w, "a post with that slug already exists", http.StatusConflict)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
files := s.trashFiles(slug, stamp)
|
||
|
|
if len(files) == 0 {
|
||
|
|
http.Error(w, "not in trash", http.StatusNotFound)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
for _, src := range files {
|
||
|
|
name := filepath.Base(src)
|
||
|
|
trashPrefix := slug + "." + stamp // "<slug>.<stamp>"
|
||
|
|
// "<slug>.<stamp>.png" → "<slug>.png"; "<slug>.<stamp>.toml" → "<slug>.toml"
|
||
|
|
dstName := slug + strings.TrimPrefix(name, trashPrefix)
|
||
|
|
if err := os.Rename(src, filepath.Join(s.contentDir, dstName)); err != nil {
|
||
|
|
log.Printf("restore %s: %v", name, err)
|
||
|
|
http.Error(w, "could not restore", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
s.rebuild()
|
||
|
|
w.WriteHeader(http.StatusOK)
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleEmptyTrash permanently deletes trashed items — specific ids (repeated
|
||
|
|
// `id` fields), or everything with `all=1`. This is the only hard delete.
|
||
|
|
func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if s.rejectCrossSite(w, r) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := r.ParseForm(); err != nil {
|
||
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if r.FormValue("all") == "1" {
|
||
|
|
if err := os.RemoveAll(s.trashDir()); err != nil {
|
||
|
|
log.Printf("empty trash: %v", err)
|
||
|
|
http.Error(w, "could not empty trash", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, map[string]string{"emptied": "all"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
removed := 0
|
||
|
|
for _, id := range r.Form["id"] {
|
||
|
|
slug, stamp, ok := splitTrashID(id)
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, p := range s.trashFiles(slug, stamp) {
|
||
|
|
if err := os.Remove(p); err == nil {
|
||
|
|
removed++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
writeJSON(w, map[string]int{"removed": removed})
|
||
|
|
}
|