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:]) }