mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Every non-handler call site that wrapped a value in slog.String/Int/Bool/Any/Float64/Uint64 was passing an Attr through the variadic any... slot of (*slog.Logger).Info/Warn/Error/Debug; the same slot accepts the bare "key", value pair with identical semantics. Drop the wrapper so the surface is one level less indirect and so the next two commits can swap (*slog.Logger) for a small Logger interface without rewriting every attr expression as well. 23 files, 156 call sites collapsed; no behavior change. Five files — tools/logger/log.go, tools/logger/batch_handler.go +_test, and core/log_printer.go +_test — are the handler-implementation layer and legitimately reference slog.Attr/Level/Group; they were skipped.
233 lines
6.9 KiB
Go
233 lines
6.9 KiB
Go
package apis
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"runtime"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/filesystem"
|
|
"github.com/hanzoai/base/tools/list"
|
|
"github.com/hanzoai/base/tools/router"
|
|
"github.com/spf13/cast"
|
|
"golang.org/x/sync/semaphore"
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp"}
|
|
var defaultThumbSizes = []string{"100x100"}
|
|
|
|
// bindFileApi registers the file api endpoints and the corresponding handlers.
|
|
func bindFileApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
|
maxWorkers := cast.ToInt64(os.Getenv("THUMBS_MAX_WORKERS"))
|
|
if maxWorkers <= 0 {
|
|
maxWorkers = int64(runtime.NumCPU() + 2) // the value is arbitrary chosen and may change in the future
|
|
}
|
|
|
|
maxWait := cast.ToInt64(os.Getenv("THUMBS_MAX_WAIT"))
|
|
if maxWait <= 0 {
|
|
maxWait = 60
|
|
}
|
|
|
|
api := fileApi{
|
|
thumbGenPending: new(singleflight.Group),
|
|
thumbGenSem: semaphore.NewWeighted(maxWorkers),
|
|
thumbGenMaxWait: time.Duration(maxWait) * time.Second,
|
|
}
|
|
|
|
sub := rg.Group("/files")
|
|
sub.POST("/token", api.fileToken).Bind(RequireAuth())
|
|
sub.GET("/{collection}/{recordId}/{filename}", api.download).Bind(collectionPathRateLimit("", "file"))
|
|
}
|
|
|
|
type fileApi struct {
|
|
// thumbGenSem is a semaphore to prevent too much concurrent
|
|
// requests generating new thumbs at the same time.
|
|
thumbGenSem *semaphore.Weighted
|
|
|
|
// thumbGenPending represents a group of currently pending
|
|
// thumb generation processes.
|
|
thumbGenPending *singleflight.Group
|
|
|
|
// thumbGenMaxWait is the maximum waiting time for starting a new
|
|
// thumb generation process.
|
|
thumbGenMaxWait time.Duration
|
|
}
|
|
|
|
func (api *fileApi) fileToken(e *core.RequestEvent) error {
|
|
if e.Auth == nil {
|
|
return e.UnauthorizedError("Missing auth context.", nil)
|
|
}
|
|
|
|
token, err := e.Auth.NewFileToken()
|
|
if err != nil {
|
|
return e.InternalServerError("Failed to generate file token", err)
|
|
}
|
|
|
|
event := new(core.FileTokenRequestEvent)
|
|
event.RequestEvent = e
|
|
event.Token = token
|
|
event.Record = e.Auth
|
|
|
|
return e.App.OnFileTokenRequest().Trigger(event, func(e *core.FileTokenRequestEvent) error {
|
|
return execAfterSuccessTx(true, e.App, func() error {
|
|
return e.JSON(http.StatusOK, map[string]string{"token": e.Token})
|
|
})
|
|
})
|
|
}
|
|
|
|
func (api *fileApi) download(e *core.RequestEvent) error {
|
|
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
|
if err != nil {
|
|
return e.NotFoundError("", nil)
|
|
}
|
|
|
|
recordId := e.Request.PathValue("recordId")
|
|
if recordId == "" {
|
|
return e.NotFoundError("", nil)
|
|
}
|
|
|
|
record, err := e.App.FindRecordById(collection, recordId)
|
|
if err != nil {
|
|
return e.NotFoundError("", err)
|
|
}
|
|
|
|
filename := e.Request.PathValue("filename")
|
|
|
|
fileField := record.FindFileFieldByFile(filename)
|
|
if fileField == nil {
|
|
return e.NotFoundError("", nil)
|
|
}
|
|
|
|
// check whether the request is authorized to view the protected file
|
|
if fileField.Protected {
|
|
originalRequestInfo, err := e.RequestInfo()
|
|
if err != nil {
|
|
return e.InternalServerError("Failed to load request info", err)
|
|
}
|
|
|
|
token := e.Request.URL.Query().Get("token")
|
|
authRecord, _ := e.App.FindAuthRecordByToken(token, core.TokenTypeFile)
|
|
|
|
// create a shallow copy of the cached request data and adjust it to the current auth record (if any)
|
|
requestInfo := *originalRequestInfo
|
|
requestInfo.Context = core.RequestInfoContextProtectedFile
|
|
requestInfo.Auth = authRecord
|
|
|
|
if ok, _ := e.App.CanAccessRecord(record, &requestInfo, record.Collection().ViewRule); !ok {
|
|
return e.NotFoundError("", errors.New("insufficient permissions to access the file resource"))
|
|
}
|
|
}
|
|
|
|
baseFilesPath := record.BaseFilesPath()
|
|
|
|
// fetch the original view file field related record
|
|
if collection.IsView() {
|
|
fileRecord, err := e.App.FindRecordByViewFile(collection.Id, fileField.Name, filename)
|
|
if err != nil {
|
|
return e.NotFoundError("", fmt.Errorf("failed to fetch view file field record: %w", err))
|
|
}
|
|
baseFilesPath = fileRecord.BaseFilesPath()
|
|
}
|
|
|
|
fsys, err := e.App.NewFilesystem()
|
|
if err != nil {
|
|
return e.InternalServerError("Filesystem initialization failure.", err)
|
|
}
|
|
defer fsys.Close()
|
|
|
|
originalPath := baseFilesPath + "/" + filename
|
|
|
|
event := new(core.FileDownloadRequestEvent)
|
|
event.RequestEvent = e
|
|
event.Collection = collection
|
|
event.Record = record
|
|
event.FileField = fileField
|
|
event.ServedPath = originalPath
|
|
event.ServedName = filename
|
|
|
|
// check for valid thumb size param
|
|
thumbSize := e.Request.URL.Query().Get("thumb")
|
|
if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, fileField.Thumbs)) {
|
|
// extract the original file meta attributes and check it existence
|
|
oAttrs, oAttrsErr := fsys.Attributes(originalPath)
|
|
if oAttrsErr != nil {
|
|
return e.NotFoundError("", err)
|
|
}
|
|
|
|
// check if it is an image
|
|
if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) {
|
|
// add thumb size as file suffix
|
|
event.ServedName = thumbSize + "_" + filename
|
|
event.ServedPath = baseFilesPath + "/thumbs_" + filename + "/" + event.ServedName
|
|
|
|
// create a new thumb if it doesn't exist
|
|
if exists, _ := fsys.Exists(event.ServedPath); !exists {
|
|
if err := api.createThumb(e, fsys, originalPath, event.ServedPath, thumbSize); err != nil {
|
|
e.App.Logger().Warn(
|
|
"Fallback to original - failed to create thumb "+event.ServedName,
|
|
"error", err,
|
|
"original", originalPath,
|
|
"thumb", event.ServedPath,
|
|
)
|
|
|
|
// fallback to the original
|
|
event.ThumbError = err
|
|
event.ServedName = filename
|
|
event.ServedPath = originalPath
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if thumbSize != "" && event.ThumbError == nil && event.ServedPath == originalPath {
|
|
event.ThumbError = fmt.Errorf("the thumb size %q or the original file format are not supported", thumbSize)
|
|
}
|
|
|
|
// clickjacking shouldn't be a concern when serving uploaded files,
|
|
// so it safe to unset the global X-Frame-Options to allow files embedding
|
|
// (note: it is out of the hook to allow users to customize the behavior)
|
|
e.Response.Header().Del("X-Frame-Options")
|
|
|
|
return e.App.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadRequestEvent) error {
|
|
err = execAfterSuccessTx(true, e.App, func() error {
|
|
return fsys.Serve(e.Response, e.Request, e.ServedPath, e.ServedName)
|
|
})
|
|
if err != nil {
|
|
return e.NotFoundError("", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (api *fileApi) createThumb(
|
|
e *core.RequestEvent,
|
|
fsys *filesystem.System,
|
|
originalPath string,
|
|
thumbPath string,
|
|
thumbSize string,
|
|
) error {
|
|
ch := api.thumbGenPending.DoChan(thumbPath, func() (any, error) {
|
|
ctx, cancel := context.WithTimeout(e.Request.Context(), api.thumbGenMaxWait)
|
|
defer cancel()
|
|
|
|
if err := api.thumbGenSem.Acquire(ctx, 1); err != nil {
|
|
return nil, err
|
|
}
|
|
defer api.thumbGenSem.Release(1)
|
|
|
|
return nil, fsys.CreateThumb(originalPath, thumbPath, thumbSize)
|
|
})
|
|
|
|
res := <-ch
|
|
|
|
api.thumbGenPending.Forget(thumbPath)
|
|
|
|
return res.Err
|
|
}
|