- POST /_journal/api/v1/media/upload requires access_posts and stays under journal/ - Anonymous GET rss is RSS 2.0 honoring rss_* settings; unpublished posts are omitted - beachcomber Gate reads search_use_typesense (default false) so a fresh save makes no Typesense HTTP Co-authored-by: Cursor <cursoragent@cursor.com>
148 lines
4.0 KiB
Go
148 lines
4.0 KiB
Go
package api
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/wire"
|
|
"gocloud.dev/blob"
|
|
"gocloud.dev/gcerrors"
|
|
)
|
|
|
|
const mediaMaxBytes = 10240 << 10
|
|
|
|
var mediaFolderPattern = regexp.MustCompile(`^[A-Za-z0-9_\-/]*$`)
|
|
|
|
// UploadMedia is POST /_journal/api/v1/media/upload (JOURNAL-006).
|
|
func UploadMedia(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireAccessPosts(w, pr) {
|
|
return
|
|
}
|
|
folder := strings.TrimSpace(r.FormValue("folder"))
|
|
if len(folder) > 100 || !mediaFolderPattern.MatchString(folder) || strings.Contains(folder, "..") {
|
|
writeValidation(w, map[string][]string{"folder": {"The folder format is invalid."}})
|
|
return
|
|
}
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeValidation(w, map[string][]string{"file": {"The file field is required."}})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
name := path.Base(strings.ReplaceAll(header.Filename, `\`, "/"))
|
|
ext := strings.ToLower(strings.TrimPrefix(path.Ext(name), "."))
|
|
switch ext {
|
|
case "jpg", "jpeg", "png", "gif", "webp":
|
|
default:
|
|
writeValidation(w, map[string][]string{"file": {"The file must be an image."}})
|
|
return
|
|
}
|
|
bucket, ok := app.Lookup[*blob.Bucket]()
|
|
if !ok || bucket == nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
keyPath := mediaObjectPath(folder, name)
|
|
body, err := io.ReadAll(io.LimitReader(file, mediaMaxBytes+1))
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
if int64(len(body)) > mediaMaxBytes {
|
|
writeValidation(w, map[string][]string{"file": {"The file may not be greater than 10240 kilobytes."}})
|
|
return
|
|
}
|
|
keyPath, err = uniqueMediaKey(r, bucket, keyPath)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
wtr, err := bucket.NewWriter(r.Context(), strings.TrimPrefix(keyPath, "/"), nil)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
if _, err := wtr.Write(body); err != nil {
|
|
_ = wtr.Close()
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
if err := wtr.Close(); err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
|
return
|
|
}
|
|
wire.WriteJSON(w, http.StatusCreated, map[string]any{
|
|
"data": map[string]any{
|
|
"url": attach.PublicURL(strings.TrimPrefix(keyPath, "/")),
|
|
"path": keyPath,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
func mediaObjectPath(folder, fileName string) string {
|
|
segments := make([]string, 0, 4)
|
|
for _, part := range strings.Split(strings.Trim(folder, "/"), "/") {
|
|
if part == "" || part == "." || part == ".." {
|
|
continue
|
|
}
|
|
segments = append(segments, part)
|
|
}
|
|
if len(segments) > 0 && strings.EqualFold(segments[0], "journal") {
|
|
segments = segments[1:]
|
|
}
|
|
folder = strings.Join(segments, "/")
|
|
if folder == "" {
|
|
return "/journal/" + fileName
|
|
}
|
|
return "/journal/" + folder + "/" + fileName
|
|
}
|
|
|
|
func uniqueMediaKey(r *http.Request, bucket *blob.Bucket, pathName string) (string, error) {
|
|
key := strings.TrimPrefix(pathName, "/")
|
|
ok, err := bucket.Exists(r.Context(), key)
|
|
if err != nil && gcerrors.Code(err) != gcerrors.NotFound {
|
|
return "", err
|
|
}
|
|
if !ok {
|
|
return pathName, nil
|
|
}
|
|
ext := path.Ext(pathName)
|
|
base := strings.TrimSuffix(pathName, ext)
|
|
for n := 1; n < 1000; n++ {
|
|
candidate := base + "-" + itoa(n) + ext
|
|
exists, err := bucket.Exists(r.Context(), strings.TrimPrefix(candidate, "/"))
|
|
if err != nil && gcerrors.Code(err) != gcerrors.NotFound {
|
|
return "", err
|
|
}
|
|
if !exists {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return pathName, nil
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var b [16]byte
|
|
i := len(b)
|
|
for n > 0 {
|
|
i--
|
|
b[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
return string(b[i:])
|
|
}
|