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"` // ".", 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 "." 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) // "." 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 "." 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 := parseForm(r); 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 // "." // "..png" → ".png"; "..toml" → ".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 := parseForm(r); 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}) }