Files
zeekay 4b229fae5b Merge remote-tracking branch 'upstream/master'
# Conflicts:
#	core/record_field_resolver_multi_match.go
#	ui/dist/assets/AuthMethodsDocs-ChShXLwE.js
#	ui/dist/assets/AuthMethodsDocs-Cqvl0T_l.js
#	ui/dist/assets/AuthMethodsDocs-DIqhS7qY.js
#	ui/dist/assets/AuthRefreshDocs-CHakkNnn.js
#	ui/dist/assets/AuthRefreshDocs-DLb1qree.js
#	ui/dist/assets/AuthRefreshDocs-DXgrlMdk.js
#	ui/dist/assets/AuthWithOAuth2Docs-Bl8VRxNv.js
#	ui/dist/assets/AuthWithOAuth2Docs-DVBOel22.js
#	ui/dist/assets/AuthWithOAuth2Docs-I213UiJ7.js
#	ui/dist/assets/AuthWithOtpDocs-B9tL6gIN.js
#	ui/dist/assets/AuthWithOtpDocs-C2PPm9-Z.js
#	ui/dist/assets/AuthWithOtpDocs-qEMoMG3D.js
#	ui/dist/assets/AuthWithPasswordDocs-B8JWDbPL.js
#	ui/dist/assets/AuthWithPasswordDocs-BTHeYJwE.js
#	ui/dist/assets/AuthWithPasswordDocs-D9rqgLpO.js
#	ui/dist/assets/BatchApiDocs-BV4PYYXn.js
#	ui/dist/assets/BatchApiDocs-DXa997am.js
#	ui/dist/assets/BatchApiDocs-Di2Qe2dV.js
#	ui/dist/assets/DeleteApiDocs-C3IINRwz.js
#	ui/dist/assets/DeleteApiDocs-LfmVp4Uf.js
#	ui/dist/assets/DeleteApiDocs-SU4XdGhJ.js
#	ui/dist/assets/EmailChangeDocs-COx1USU5.js
#	ui/dist/assets/EmailChangeDocs-D-qNHT_P.js
#	ui/dist/assets/EmailChangeDocs-DpfXIE_V.js
#	ui/dist/assets/FieldsQueryParam-BYe9ZK-G.js
#	ui/dist/assets/FieldsQueryParam-L8vpHeF4.js
#	ui/dist/assets/FieldsQueryParam-z0dGQ6uY.js
#	ui/dist/assets/ListApiDocs-BCTT-AOz.js
#	ui/dist/assets/ListApiDocs-C0_GwI7k.js
#	ui/dist/assets/ListApiDocs-T9_aO-pW.js
#	ui/dist/assets/PageInstaller-B22bivaN.js
#	ui/dist/assets/PageInstaller-C0BBhyBx.js
#	ui/dist/assets/PageInstaller-IYSwJhZW.js
#	ui/dist/assets/PageOAuth2RedirectFailure-BjbpqlFj.js
#	ui/dist/assets/PageOAuth2RedirectFailure-COyNF79R.js
#	ui/dist/assets/PageOAuth2RedirectFailure-DPmGG9zv.js
#	ui/dist/assets/PageOAuth2RedirectSuccess-6cMXLFAX.js
#	ui/dist/assets/PageOAuth2RedirectSuccess-CdkCarGH.js
#	ui/dist/assets/PageOAuth2RedirectSuccess-DSEIHeQL.js
#	ui/dist/assets/PageRecordConfirmEmailChange-BYW-6ASg.js
#	ui/dist/assets/PageRecordConfirmEmailChange-Bm1CIYvq.js
#	ui/dist/assets/PageRecordConfirmEmailChange-BpaqOT4u.js
#	ui/dist/assets/PageRecordConfirmPasswordReset--wx_u2lI.js
#	ui/dist/assets/PageRecordConfirmPasswordReset-CoeY2Z_C.js
#	ui/dist/assets/PageRecordConfirmPasswordReset-Ptya5bfe.js
#	ui/dist/assets/PageRecordConfirmVerification-6xjF3T0O.js
#	ui/dist/assets/PageRecordConfirmVerification-C1UTIIJh.js
#	ui/dist/assets/PageRecordConfirmVerification-C4LixXvA.js
#	ui/dist/assets/PageSuperuserConfirmPasswordReset-19z2IFg4.js
#	ui/dist/assets/PageSuperuserConfirmPasswordReset-DwNi0ugQ.js
#	ui/dist/assets/PageSuperuserConfirmPasswordReset-SMnDTRNi.js
#	ui/dist/assets/PageSuperuserRequestPasswordReset-BpDW217f.js
#	ui/dist/assets/PageSuperuserRequestPasswordReset-DK63qE6z.js
#	ui/dist/assets/PageSuperuserRequestPasswordReset-bG02qLIM.js
#	ui/dist/assets/PasswordResetDocs-CE67mLC0.js
#	ui/dist/assets/PasswordResetDocs-CHAK0vma.js
#	ui/dist/assets/PasswordResetDocs-Dap-N4rx.js
#	ui/dist/assets/RealtimeApiDocs-BooTSQCb.js
#	ui/dist/assets/RealtimeApiDocs-CoHmYVEx.js
#	ui/dist/assets/RealtimeApiDocs-D7ds3fHt.js
#	ui/dist/assets/VerificationDocs-B42AIoCQ.js
#	ui/dist/assets/VerificationDocs-BS7b11zH.js
#	ui/dist/assets/VerificationDocs-ZhkdFVIl.js
#	ui/dist/assets/ViewApiDocs-BXmRLHZ1.js
#	ui/dist/assets/ViewApiDocs-DM5JJQjh.js
#	ui/dist/assets/ViewApiDocs-DRpxcuJR.js
#	ui/public/fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-700.woff2
#	ui/public/fonts/ubuntu-mono/ubuntu-mono-v17-cyrillic_latin-700.woff2
2026-02-22 01:17:16 -08:00

234 lines
6.9 KiB
Go

package apis
import (
"context"
"errors"
"fmt"
"log/slog"
"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("HZ_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("HZ_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,
slog.Any("error", err),
slog.String("original", originalPath),
slog.String("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
}