feat: complete remaining Go backend modules
This commit is contained in:
1 parent
cea2751dc5
commit
aef5a97165
145 files changed
+18376
-199
No files matched your search
@@ -0,0 +1,472 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAssetJSONBytes int64 = 1 << 20
|
||||
defaultAssetUploadBytes int64 = 20 << 20
|
||||
)
|
||||
|
||||
type PublicAssetAuthenticator interface {
|
||||
Authenticate(*http.Request) (publicapi.PublicClient, string, error)
|
||||
}
|
||||
|
||||
type AssetsConfig struct {
|
||||
MaxJSONBytes int64
|
||||
MaxUploadBytes int64
|
||||
}
|
||||
|
||||
type assetsHandler struct {
|
||||
service *assets.Service
|
||||
platform *PlatformAuthorizer
|
||||
public PublicAssetAuthenticator
|
||||
config AssetsConfig
|
||||
}
|
||||
|
||||
func NewAssetsHandler(service *assets.Service, platform *PlatformAuthorizer, public PublicAssetAuthenticator, config AssetsConfig) (http.Handler, error) {
|
||||
if service == nil || platform == nil || public == nil {
|
||||
return nil, errors.New("assets HTTP dependencies are not configured")
|
||||
}
|
||||
if config.MaxJSONBytes <= 0 {
|
||||
config.MaxJSONBytes = defaultAssetJSONBytes
|
||||
}
|
||||
if config.MaxUploadBytes <= 0 {
|
||||
config.MaxUploadBytes = defaultAssetUploadBytes
|
||||
}
|
||||
return &assetsHandler{service: service, platform: platform, public: public, config: config}, nil
|
||||
}
|
||||
|
||||
func (h *assetsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
route, values := matchAssetRoute(r.URL.Path)
|
||||
if route == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
allow := assetRouteAllow(route)
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Allow", allow)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
method := r.Method
|
||||
if method == http.MethodHead && strings.Contains(allow, http.MethodHead) {
|
||||
method = http.MethodGet
|
||||
w = headResponseWriter{ResponseWriter: w}
|
||||
}
|
||||
if !methodAllowed(allow, method) {
|
||||
w.Header().Set("Allow", allow)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
switch route {
|
||||
case "platform-collection":
|
||||
h.platformCollection(w, r, method)
|
||||
case "platform-upload":
|
||||
h.upload(w, r, false)
|
||||
case "platform-item":
|
||||
h.delete(w, r, values[0])
|
||||
case "platform-download":
|
||||
h.download(w, r, false, values[0])
|
||||
case "public-collection":
|
||||
h.publicCollection(w, r, method)
|
||||
case "public-item":
|
||||
h.publicGet(w, r, values[0])
|
||||
case "public-download":
|
||||
h.download(w, r, true, values[0])
|
||||
case "served-file":
|
||||
h.serveStored(w, r, values[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *assetsHandler) platformCollection(w http.ResponseWriter, r *http.Request, method string) {
|
||||
scope, ok := h.platformScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if method == http.MethodGet {
|
||||
values, err := h.service.List(r.Context(), scope)
|
||||
if err != nil {
|
||||
writeAssetError(w, err, false, "")
|
||||
return
|
||||
}
|
||||
if values == nil {
|
||||
values = []assets.Asset{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"assets": values})
|
||||
return
|
||||
}
|
||||
var input createAssetInput
|
||||
if !decodeAssetJSON(w, r, h.config.MaxJSONBytes, &input) {
|
||||
return
|
||||
}
|
||||
command := assets.CreateExternalCommand{URL: input.URL, Name: input.Name, Kind: input.Kind, Source: input.Source, Tags: input.Tags}
|
||||
created, err := h.service.CreateExternal(r.Context(), scope, command)
|
||||
if err != nil {
|
||||
writeAssetError(w, err, false, "")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"asset": created})
|
||||
}
|
||||
|
||||
func (h *assetsHandler) publicCollection(w http.ResponseWriter, r *http.Request, method string) {
|
||||
client, scope, ok := h.publicScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if method == http.MethodGet {
|
||||
values, err := h.service.List(r.Context(), scope)
|
||||
if err != nil {
|
||||
writeAssetError(w, err, true, "")
|
||||
return
|
||||
}
|
||||
if values == nil {
|
||||
values = []assets.Asset{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"assets": values})
|
||||
return
|
||||
}
|
||||
if strings.Contains(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") {
|
||||
h.uploadScope(w, r, scope, true)
|
||||
return
|
||||
}
|
||||
_ = client
|
||||
var input createAssetInput
|
||||
if !decodeAssetJSON(w, r, h.config.MaxJSONBytes, &input) {
|
||||
return
|
||||
}
|
||||
command := assets.CreateExternalCommand{URL: input.URL, Name: input.Name, Kind: input.Kind, Tags: input.Tags}
|
||||
created, err := h.service.CreateExternal(r.Context(), scope, command)
|
||||
if err != nil {
|
||||
writeAssetError(w, err, true, "")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"asset": created})
|
||||
}
|
||||
|
||||
type createAssetInput struct {
|
||||
URL string `json:"url"`
|
||||
Name string `json:"name"`
|
||||
Kind assets.Kind `json:"kind"`
|
||||
Source assets.Source `json:"source"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func (h *assetsHandler) upload(w http.ResponseWriter, r *http.Request, public bool) {
|
||||
scope, ok := h.platformScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.uploadScope(w, r, scope, public)
|
||||
}
|
||||
|
||||
func (h *assetsHandler) uploadScope(w http.ResponseWriter, r *http.Request, scope assets.Scope, public bool) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, h.config.MaxUploadBytes)
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
writeAssetError(w, err, public, "")
|
||||
return
|
||||
}
|
||||
type pendingUpload struct {
|
||||
data []byte
|
||||
fileName, contentType string
|
||||
}
|
||||
pending := make([]pendingUpload, 0)
|
||||
for {
|
||||
part, nextErr := reader.NextPart()
|
||||
if errors.Is(nextErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if nextErr != nil {
|
||||
writeMultipartError(w, nextErr, public)
|
||||
return
|
||||
}
|
||||
if part.FormName() != "files" || part.FileName() == "" {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
data, readErr := io.ReadAll(part)
|
||||
_ = part.Close()
|
||||
if readErr != nil {
|
||||
writeMultipartError(w, readErr, public)
|
||||
return
|
||||
}
|
||||
contentType := part.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
pending = append(pending, pendingUpload{data: data, fileName: part.FileName(), contentType: contentType})
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
writeAssetJSONError(w, http.StatusBadRequest, "No files uploaded.")
|
||||
return
|
||||
}
|
||||
created := make([]assets.Asset, 0, len(pending))
|
||||
for _, file := range pending {
|
||||
a, createErr := h.service.Upload(r.Context(), scope, assets.UploadCommand{Bytes: file.data, FileName: file.fileName, ContentType: file.contentType, Origin: requestOrigin(r)})
|
||||
if createErr != nil {
|
||||
writeAssetError(w, createErr, public, "")
|
||||
return
|
||||
}
|
||||
created = append(created, a)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"assets": created})
|
||||
}
|
||||
|
||||
func (h *assetsHandler) delete(w http.ResponseWriter, r *http.Request, id string) {
|
||||
scope, ok := h.platformScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, err := h.service.Delete(r.Context(), scope, id)
|
||||
if err != nil {
|
||||
writeAssetError(w, err, false, "资产不存在")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "deletedAssetId": id})
|
||||
}
|
||||
|
||||
func (h *assetsHandler) publicGet(w http.ResponseWriter, r *http.Request, id string) {
|
||||
_, scope, ok := h.publicScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a, err := h.service.Get(r.Context(), scope, id)
|
||||
if err != nil {
|
||||
writeAssetError(w, err, true, "Asset not found.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"asset": a})
|
||||
}
|
||||
|
||||
func (h *assetsHandler) download(w http.ResponseWriter, r *http.Request, public bool, id string) {
|
||||
var scope assets.Scope
|
||||
var ok bool
|
||||
if public {
|
||||
_, scope, ok = h.publicScope(w, r)
|
||||
} else {
|
||||
scope, ok = h.platformScope(w, r)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a, err := h.service.Get(r.Context(), scope, id)
|
||||
if err != nil {
|
||||
if public {
|
||||
writeAssetError(w, err, true, "Asset not found.")
|
||||
} else {
|
||||
writeAssetError(w, err, false, "资产不存在")
|
||||
}
|
||||
return
|
||||
}
|
||||
blob, err := h.service.Download(r.Context(), scope, id)
|
||||
if err != nil {
|
||||
if public {
|
||||
writeAssetError(w, err, true, "Asset file is not downloadable.")
|
||||
} else {
|
||||
writeAssetError(w, err, false, "资产文件不可下载")
|
||||
}
|
||||
return
|
||||
}
|
||||
defer blob.Body.Close()
|
||||
writeBlob(w, blob, "private, no-store", contentDisposition(a.Name))
|
||||
}
|
||||
|
||||
func (h *assetsHandler) serveStored(w http.ResponseWriter, r *http.Request, key string) {
|
||||
scope, ok := h.platformScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
blob, err := h.service.DownloadPath(r.Context(), scope, key)
|
||||
if err != nil {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer blob.Body.Close()
|
||||
writeBlob(w, blob, "public, max-age=31536000, immutable", "")
|
||||
}
|
||||
|
||||
func (h *assetsHandler) platformScope(w http.ResponseWriter, r *http.Request) (assets.Scope, bool) {
|
||||
session, err := h.platform.Authorize(r, PlatformApp)
|
||||
if err != nil {
|
||||
writeAssetError(w, err, false, "")
|
||||
return assets.Scope{}, false
|
||||
}
|
||||
return assets.PlatformScope(session.User.ID), true
|
||||
}
|
||||
func (h *assetsHandler) publicScope(w http.ResponseWriter, r *http.Request) (publicapi.PublicClient, assets.Scope, bool) {
|
||||
client, _, err := h.public.Authenticate(r)
|
||||
if err != nil {
|
||||
writeAssetError(w, err, true, "")
|
||||
return publicapi.PublicClient{}, assets.Scope{}, false
|
||||
}
|
||||
return client, assets.PublicScope(client.ID), true
|
||||
}
|
||||
|
||||
func decodeAssetJSON(w http.ResponseWriter, r *http.Request, limit int64, target any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
if isTooLarge(err) {
|
||||
writeAssetJSONError(w, http.StatusRequestEntityTooLarge, "Request body is too large.")
|
||||
} else {
|
||||
writeAssetJSONError(w, http.StatusBadRequest, "Invalid request body.")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func writeMultipartError(w http.ResponseWriter, err error, public bool) {
|
||||
if isTooLarge(err) {
|
||||
writeAssetJSONError(w, http.StatusRequestEntityTooLarge, "Request body is too large.")
|
||||
return
|
||||
}
|
||||
writeAssetError(w, err, public, "")
|
||||
}
|
||||
func isTooLarge(err error) bool { var max *http.MaxBytesError; return errors.As(err, &max) }
|
||||
func writeAssetError(w http.ResponseWriter, err error, public bool, notFound string) {
|
||||
if errors.Is(err, assets.ErrNotFound) || errors.Is(err, assets.ErrBlobNotFound) {
|
||||
if notFound == "" {
|
||||
if public {
|
||||
notFound = "Asset not found."
|
||||
} else {
|
||||
notFound = "资产不存在"
|
||||
}
|
||||
}
|
||||
writeAssetJSONError(w, http.StatusNotFound, notFound)
|
||||
return
|
||||
}
|
||||
var platformErr *PlatformAuthError
|
||||
if errors.As(err, &platformErr) {
|
||||
writeAssetJSONError(w, platformErr.Status, platformErr.Message)
|
||||
return
|
||||
}
|
||||
var publicErr *publicapi.AuthError
|
||||
if errors.As(err, &publicErr) {
|
||||
writeAssetJSONError(w, publicErr.Status, publicErr.Message)
|
||||
return
|
||||
}
|
||||
if err != nil && (err.Error() == "url is required" || strings.Contains(err.Error(), "multipart")) {
|
||||
writeAssetJSONError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeAssetJSONError(w, http.StatusInternalServerError, "Internal server error.")
|
||||
}
|
||||
func writeAssetJSONError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
func writeBlob(w http.ResponseWriter, blob assets.Blob, cache, disposition string) {
|
||||
if blob.ContentType == "" {
|
||||
blob.ContentType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", blob.ContentType)
|
||||
if blob.Size >= 0 {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(blob.Size))
|
||||
}
|
||||
w.Header().Set("Cache-Control", cache)
|
||||
if disposition != "" {
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, blob.Body)
|
||||
}
|
||||
func contentDisposition(name string) string {
|
||||
clean := strings.TrimSpace(strings.NewReplacer("\r", "_", "\n", "_", "/", "_", "\\", "_").Replace(name))
|
||||
if clean == "" {
|
||||
clean = "download"
|
||||
}
|
||||
var ascii strings.Builder
|
||||
for _, r := range clean {
|
||||
if r >= 0x20 && r <= 0x7e && r != '"' {
|
||||
ascii.WriteRune(r)
|
||||
} else {
|
||||
ascii.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return `attachment; filename="` + ascii.String() + `"; filename*=UTF-8''` + url.PathEscape(clean)
|
||||
}
|
||||
func requestOrigin(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if forwarded := r.Header.Get("X-Forwarded-Proto"); forwarded != "" {
|
||||
scheme = strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
func methodAllowed(allow, method string) bool {
|
||||
for _, v := range strings.Split(allow, ",") {
|
||||
if strings.TrimSpace(v) == method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type headResponseWriter struct{ http.ResponseWriter }
|
||||
|
||||
func (headResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
func matchAssetRoute(value string) (string, []string) {
|
||||
if value == "/api/assets" {
|
||||
return "platform-collection", nil
|
||||
}
|
||||
if value == "/api/assets/upload" {
|
||||
return "platform-upload", nil
|
||||
}
|
||||
if value == "/api/v1/assets" {
|
||||
return "public-collection", nil
|
||||
}
|
||||
if strings.HasPrefix(value, "/api/assets/") {
|
||||
rest := strings.TrimPrefix(value, "/api/assets/")
|
||||
if rest != "" && !strings.Contains(rest, "/") {
|
||||
return "platform-item", []string{rest}
|
||||
}
|
||||
if strings.HasSuffix(rest, "/download") && strings.Count(rest, "/") == 1 {
|
||||
return "platform-download", []string{strings.TrimSuffix(rest, "/download")}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(value, "/api/v1/assets/") {
|
||||
rest := strings.TrimPrefix(value, "/api/v1/assets/")
|
||||
if rest != "" && !strings.Contains(rest, "/") {
|
||||
return "public-item", []string{rest}
|
||||
}
|
||||
if strings.HasSuffix(rest, "/download") && strings.Count(rest, "/") == 1 {
|
||||
return "public-download", []string{strings.TrimSuffix(rest, "/download")}
|
||||
}
|
||||
}
|
||||
for _, prefix := range []string{"/uploads/", "/generated-results/"} {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
rest := strings.TrimPrefix(value, "/")
|
||||
if rest != "" && path.Clean(rest) == rest && !strings.Contains(rest, "\\") {
|
||||
return "served-file", []string{rest}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
func assetRouteAllow(route string) string {
|
||||
switch route {
|
||||
case "platform-collection", "public-collection":
|
||||
return "GET, HEAD, POST, OPTIONS"
|
||||
case "platform-upload":
|
||||
return "POST, OPTIONS"
|
||||
case "platform-item":
|
||||
return "DELETE, OPTIONS"
|
||||
default:
|
||||
return "GET, HEAD, OPTIONS"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user