234 lines
7.2 KiB
Go
234 lines
7.2 KiB
Go
package editor
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/media"
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
|
|
)
|
|
|
|
// Pictures. An upload is re-encoded (internal/media) and committed to the
|
|
// draft like any other change, so a picture reaches the site the same way
|
|
// words do: on a branch, previewed, checked and reviewed. The library lists
|
|
// what's in static/media/ and where each picture is used.
|
|
|
|
// upload takes one picture from a multipart form: file, branch, baseCommit,
|
|
// and source (the page it's for, which names a new draft).
|
|
func (s *Server) upload(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, st *SiteConfig) {
|
|
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
|
apiError(w, http.StatusRequestEntityTooLarge, "That upload couldn't be read, or is over the limit of 25 MB.")
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
file, hdr, err := r.FormFile("file")
|
|
if err != nil {
|
|
apiError(w, http.StatusBadRequest, "Choose a picture to upload.")
|
|
return
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(file, media.MaxBytes+1))
|
|
file.Close()
|
|
if err != nil {
|
|
apiError(w, http.StatusBadRequest, "That upload couldn't be read.")
|
|
return
|
|
}
|
|
branch := r.FormValue("branch")
|
|
if branch == "" {
|
|
branch = st.Branch
|
|
}
|
|
_, siteDir, _, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), branch)
|
|
if siteDir == "" {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
taken := func(p string) bool {
|
|
_, err := os.Stat(filepath.Join(siteDir, filepath.FromSlash(p)))
|
|
return err == nil
|
|
}
|
|
pic, err := media.Process(data, hdr.Filename, time.Now().Format("2006/01"), taken)
|
|
if err != nil {
|
|
apiError(w, http.StatusUnprocessableEntity, err.Error())
|
|
return
|
|
}
|
|
files := map[string][]byte{}
|
|
var paths []string
|
|
for _, pf := range pic.Files {
|
|
rel := path.Join("static", pf.Path)
|
|
files[repoFile(st, rel)] = pf.Data
|
|
paths = append(paths, rel)
|
|
}
|
|
target, create := branch, false
|
|
if branch == st.Branch {
|
|
source := r.FormValue("source")
|
|
if source == "" {
|
|
source = "media.md"
|
|
}
|
|
target, create = s.draftName(r, sess, f, st, source), true
|
|
}
|
|
commit, err := s.ws.save(sess, f.Kind, st, s.cloneURL(st), branch, target, create, r.FormValue("baseCommit"), files, "Add picture "+pic.URL)
|
|
if err != nil {
|
|
s.writeError(w, r, err)
|
|
return
|
|
}
|
|
s.log.Printf("%s added %s to %s@%s (%s)", sess.Login, pic.URL, st.ID, target, shortSHA(commit))
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"branch": target, "commit": commit, "created": create,
|
|
"url": pic.URL, "width": pic.Width, "height": pic.Height, "notes": pic.Notes, "files": paths,
|
|
})
|
|
}
|
|
|
|
// MediaItem is one picture in the library.
|
|
type MediaItem struct {
|
|
URL string `json:"url"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
Thumb string `json:"thumb"` // the smallest size, to show in the library
|
|
Bytes int64 `json:"bytes"` // every file of it together
|
|
Files int `json:"files"`
|
|
UsedBy []string `json:"usedBy"` // files that refer to it
|
|
Alt string `json:"alt"` // alt text it's been given before, to start from
|
|
Sizes []site.MediaSize `json:"-"`
|
|
}
|
|
|
|
var mdImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(\s*<?([^)\s>]+)`)
|
|
|
|
func (s *Server) apiMedia(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id string) {
|
|
st, _, err := s.siteByID(r.Context(), sess, f, id)
|
|
if s.signedOut(w, r, err) {
|
|
return
|
|
}
|
|
if err != nil || st == nil {
|
|
apiError(w, http.StatusNotFound, "No such site, or you can't open it.")
|
|
return
|
|
}
|
|
branch := r.URL.Query().Get("branch")
|
|
if branch == "" {
|
|
branch = st.Branch
|
|
}
|
|
_, siteDir, _, err := s.ws.checkout(sess, f.Kind, st, s.cloneURL(st), branch)
|
|
if siteDir == "" {
|
|
apiError(w, http.StatusBadGateway, "Couldn't fetch the site: "+trimErr(err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, mediaLibrary(siteDir))
|
|
}
|
|
|
|
// mediaLibrary lists the pictures in static/media/ with where they're used:
|
|
// any page, template, data file or setting that names one, or one of its
|
|
// sizes.
|
|
func mediaLibrary(siteDir string) []*MediaItem {
|
|
pics := site.LoadMedia(filepath.Join(siteDir, "static"))
|
|
items := []*MediaItem{}
|
|
byStem := map[string]*MediaItem{}
|
|
for u, p := range pics {
|
|
it := &MediaItem{URL: u, Width: p.Width, Height: p.Height, Sizes: p.Sizes, UsedBy: []string{}}
|
|
for _, sz := range p.Sizes {
|
|
if fi, err := os.Stat(filepath.Join(siteDir, "static", filepath.FromSlash(sz.URL))); err == nil {
|
|
it.Bytes += fi.Size()
|
|
it.Files++
|
|
}
|
|
}
|
|
for _, sz := range p.Sizes { // narrowest first; prefer the JPEG or PNG
|
|
if !sz.WebP {
|
|
it.Thumb = sz.URL
|
|
break
|
|
}
|
|
}
|
|
items = append(items, it)
|
|
byStem[strings.TrimSuffix(u, path.Ext(u))] = it
|
|
}
|
|
_ = filepath.WalkDir(siteDir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(siteDir, p)
|
|
rel = filepath.ToSlash(rel)
|
|
if d.IsDir() {
|
|
switch rel {
|
|
case ".git", "public", "static", "node_modules":
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
switch path.Ext(rel) {
|
|
case ".md", ".html", ".yaml", ".yml", ".json", ".toml", ".css", ".txt":
|
|
default:
|
|
return nil
|
|
}
|
|
data, err := readIn(siteDir, rel)
|
|
if err != nil || !bytes.Contains(data, []byte("/media/")) {
|
|
return nil
|
|
}
|
|
for stem, it := range byStem {
|
|
if bytes.Contains(data, []byte(stem)) {
|
|
it.UsedBy = append(it.UsedBy, rel)
|
|
}
|
|
}
|
|
for _, m := range mdImageRe.FindAllSubmatch(data, -1) {
|
|
u := string(m[2])
|
|
if it := byStem[strings.TrimSuffix(u, path.Ext(u))]; it != nil && it.Alt == "" {
|
|
it.Alt = string(m[1])
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
for _, it := range items {
|
|
sort.Strings(it.UsedBy)
|
|
}
|
|
sort.Slice(items, func(i, j int) bool { return items[i].URL > items[j].URL }) // newest month first
|
|
return items
|
|
}
|
|
|
|
// mediaTypes are the only files the editor serves out of a checkout.
|
|
var mediaTypes = map[string]string{".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif"}
|
|
|
|
// apiMediaFile serves a picture from the person's checkout, for the
|
|
// library's thumbnails. Only pictures under static/media/, only as images,
|
|
// and sandboxed: a file that isn't really a picture can't run as a page.
|
|
func (s *Server) apiMediaFile(w http.ResponseWriter, r *http.Request, sess *Session, f *ForgeConfig, id string) {
|
|
st, _, err := s.siteByID(r.Context(), sess, f, id)
|
|
if s.signedOut(w, r, err) {
|
|
return
|
|
}
|
|
if err != nil || st == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
u := r.URL.Query().Get("url")
|
|
clean := path.Clean("/" + u)
|
|
typ := mediaTypes[strings.ToLower(path.Ext(clean))]
|
|
if clean != u || !strings.HasPrefix(clean, "/media/") || typ == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
branch := r.URL.Query().Get("branch")
|
|
if branch == "" {
|
|
branch = st.Branch
|
|
}
|
|
dir := filepath.Join(s.ws.dir(sess, st, branch), st.Subdir)
|
|
data, err := readIn(dir, "static/"+clean)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.Error(w, "unreadable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h := w.Header()
|
|
h.Set("Content-Type", typ)
|
|
h.Set("Content-Security-Policy", "sandbox; default-src 'none'")
|
|
h.Set("Cache-Control", "private, max-age=300")
|
|
_, _ = w.Write(data)
|
|
}
|