chore: 初始化 Cloudreve 源码仓库

This commit is contained in:
2026-09-13 11:28:59 +08:00
commit 2a7b03ab1d
489 changed files with 163867 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
package middleware
import (
"crypto/subtle"
"net/http"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/oss"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
)
const (
CallbackFailedStatusCode = http.StatusUnauthorized
)
// SignRequired 验证请求签名
func SignRequired(authInstance auth.Auth) gin.HandlerFunc {
return func(c *gin.Context) {
var err error
switch c.Request.Method {
case http.MethodPut, http.MethodPost, http.MethodPatch:
err = auth.CheckRequest(c, authInstance, c.Request)
default:
err = auth.CheckURI(c, authInstance, c.Request.URL)
}
if err != nil {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCredentialInvalid, err.Error(), err))
c.Abort()
return
}
c.Next()
}
}
// CurrentUser 获取登录用户
func CurrentUser() gin.HandlerFunc {
return func(c *gin.Context) {
dep := dependency.FromContext(c)
shouldContinue, err := dep.TokenAuth().VerifyAndRetrieveUser(c)
if err != nil {
c.JSON(200, serializer.Err(c, err))
c.Abort()
return
}
if shouldContinue {
// TODO: Logto handler
}
uid := inventory.UserIDFromContext(c)
if err := SetUserCtx(c, uid); err != nil {
c.JSON(200, serializer.Err(c, err))
c.Abort()
return
}
c.Next()
}
}
// SetUserCtx set the current login user via uid
func SetUserCtx(c *gin.Context, uid int) error {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
loginUser, err := userClient.GetLoginUserByID(c, uid)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "failed to get login user", err)
}
SetUserCtxByUser(c, loginUser)
return nil
}
func SetUserCtxByUser(c *gin.Context, user *ent.User) {
util.WithValue(c, inventory.UserCtx{}, user)
}
// LoginRequired 需要登录
func LoginRequired() gin.HandlerFunc {
return func(c *gin.Context) {
if u := inventory.UserFromContext(c); u != nil && !inventory.IsAnonymousUser(u) {
c.Next()
return
}
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCheckLogin, "Login required", nil))
c.Abort()
}
}
// WebDAVAuth 验证WebDAV登录及权限
func WebDAVAuth() gin.HandlerFunc {
return func(c *gin.Context) {
username, password, ok := c.Request.BasicAuth()
if !ok {
// OPTIONS 请求不需要鉴权
if c.Request.Method == http.MethodOptions {
c.Next()
return
}
c.Writer.Header()["WWW-Authenticate"] = []string{`Basic realm="cloudreve"`}
c.Status(http.StatusUnauthorized)
c.Abort()
return
}
dep := dependency.FromContext(c)
l := dep.Logger()
userClient := dep.UserClient()
expectedUser, err := userClient.GetActiveByDavAccount(c, username, password)
if err != nil {
if username == "" {
if u, err := userClient.GetByEmail(c, username); err == nil {
// Try login with known user but incorrect password, record audit log
SetUserCtxByUser(c, u)
}
}
l.Debug("WebDAVAuth: failed to get user %q with provided credential: %s", username, err)
c.Status(http.StatusUnauthorized)
c.Abort()
return
}
// Validate dav account
accounts, err := expectedUser.Edges.DavAccountsOrErr()
if err != nil || len(accounts) == 0 {
l.Debug("WebDAVAuth: failed to get user dav accounts %q with provided credential: %s", username, err)
c.Status(http.StatusUnauthorized)
c.Abort()
return
}
// 用户组已启用WebDAV?
group, err := expectedUser.Edges.GroupOrErr()
if err != nil {
l.Debug("WebDAVAuth: user group not found: %s", err)
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
if !group.Permissions.Enabled(int(types.GroupPermissionWebDAV)) {
c.Status(http.StatusForbidden)
l.Debug("WebDAVAuth: user %q does not have WebDAV permission.", expectedUser.Email)
c.Abort()
return
}
// 检查是否只读
if expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) {
switch c.Request.Method {
case http.MethodDelete, http.MethodPut, "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK":
c.Status(http.StatusForbidden)
c.Abort()
return
}
}
SetUserCtxByUser(c, expectedUser)
c.Next()
}
}
// 对上传会话进行验证
func UseUploadSession(policyType types.PolicyType) gin.HandlerFunc {
return func(c *gin.Context) {
// 验证key并查找用户
err := uploadCallbackCheck(c, policyType)
if err != nil {
c.JSON(CallbackFailedStatusCode, serializer.Err(c, err))
c.Abort()
return
}
c.Next()
}
}
// uploadCallbackCheck 对上传回调请求的 callback key 进行验证,如果成功则返回上传用户
func uploadCallbackCheck(c *gin.Context, policyType types.PolicyType) error {
// 验证 Callback Key
sessionID := c.Param("sessionID")
if sessionID == "" {
return serializer.NewError(serializer.CodeParamErr, "Session ID cannot be empty", nil)
}
dep := dependency.FromContext(c)
callbackSessionRaw, exist := dep.KV().Get(manager.UploadSessionCachePrefix + sessionID)
if !exist {
return serializer.NewError(serializer.CodeUploadSessionExpired, "Upload session does not exist or expired", nil)
}
callbackSession := callbackSessionRaw.(fs.UploadSession)
if subtle.ConstantTimeCompare(
[]byte(callbackSession.CallbackSecret),
[]byte(c.Param("key")),
) != 1 {
return serializer.NewError(serializer.CodeCredentialInvalid, "Invalid callback secret", nil)
}
c.Set(manager.UploadSessionCtx, &callbackSession)
if callbackSession.Policy.Type != string(policyType) {
return serializer.NewError(serializer.CodePolicyNotAllowed, "", nil)
}
if err := SetUserCtx(c, callbackSession.UID); err != nil {
return err
}
return nil
}
// RemoteCallbackAuth 远程回调签名验证
func RemoteCallbackAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// 验证签名
session := c.MustGet(manager.UploadSessionCtx).(*fs.UploadSession)
if session.Policy.Edges.Node == nil {
c.JSON(CallbackFailedStatusCode, serializer.ErrWithDetails(c, serializer.CodeCredentialInvalid, "Node not found", nil))
c.Abort()
return
}
authInstance := auth.HMACAuth{SecretKey: []byte(session.Policy.Edges.Node.SlaveKey)}
if err := auth.CheckRequest(c, authInstance, c.Request); err != nil {
c.JSON(CallbackFailedStatusCode, serializer.ErrWithDetails(c, serializer.CodeCredentialInvalid, err.Error(), err))
c.Abort()
return
}
c.Next()
}
}
// OSSCallbackAuth 阿里云OSS回调签名验证
func OSSCallbackAuth() gin.HandlerFunc {
return func(c *gin.Context) {
dep := dependency.FromContext(c)
err := oss.VerifyCallbackSignature(c.Request, dep.KV(), dep.RequestClient(
request.WithContext(c),
request.WithLogger(logging.FromContext(c)),
))
if err != nil {
dep.Logger().Debug("Failed to verify callback request: %s", err)
c.JSON(401, serializer.GeneralUploadCallbackFailed{Error: "Failed to verify callback request."})
c.Abort()
return
}
c.Next()
}
}
// IsAdmin 必须为管理员用户组
func IsAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
user := inventory.UserFromContext(c)
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeNoPermissionErr, "", nil))
c.Abort()
return
}
c.Next()
}
}
// RequiredScopes checks if the JWT token has the required scopes.
// If the token has scopes (hasScopes is true), it verifies the token has all required scopes.
// Write scopes implicitly include Read scopes for the same resource (e.g., "File.Write" includes "File.Read").
// If hasScopes is false (e.g., session-based auth), the check is skipped.
func RequiredScopes(requiredScopes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
if err := auth.CheckScope(c, requiredScopes...); err != nil {
c.JSON(200, serializer.Err(c, err))
c.Abort()
return
}
c.Next()
}
}
+195
View File
@@ -0,0 +1,195 @@
package middleware
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/recaptcha"
request2 "github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha"
)
type req struct {
Captcha string `json:"captcha"`
Ticket string `json:"ticket"`
Randstr string `json:"randstr"`
}
const (
captchaNotMatch = "CAPTCHA not match."
captchaRefresh = "Verification failed, please refresh the page and retry."
tcCaptchaEndpoint = "captcha.tencentcloudapi.com"
turnstileEndpoint = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
)
// CaptchaIDCtx defines keys for captcha ID
type (
CaptchaIDCtx struct{}
turnstileResponse struct {
Success bool `json:"success"`
}
capResponse struct {
Success bool `json:"success"`
}
)
// CaptchaRequired 验证请求签名
func CaptchaRequired(enabled func(c *gin.Context) bool) gin.HandlerFunc {
return func(c *gin.Context) {
if enabled(c) {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
l := logging.FromContext(c)
var service req
bodyCopy := new(bytes.Buffer)
_, err := io.Copy(bodyCopy, c.Request.Body)
if err != nil {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaNotMatch, err))
c.Abort()
return
}
bodyData := bodyCopy.Bytes()
err = json.Unmarshal(bodyData, &service)
if err != nil {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaNotMatch, err))
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(bodyData))
switch settings.CaptchaType(c) {
case setting.CaptchaNormal, setting.CaptchaTcaptcha:
if service.Ticket == "" || !base64Captcha.VerifyCaptcha(service.Ticket, service.Captcha) {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaNotMatch, err))
c.Abort()
return
}
break
case setting.CaptchaReCaptcha:
captchaSetting := settings.ReCaptcha(c)
reCAPTCHA, err := recaptcha.NewReCAPTCHA(captchaSetting.Secret, recaptcha.V2, 10*time.Second)
if err != nil {
l.Warning("reCAPTCHA verification failed, %s", err)
c.Abort()
break
}
err = reCAPTCHA.Verify(service.Captcha)
if err != nil {
l.Warning("reCAPTCHA verification failed, %s", err)
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaRefresh, err))
c.Abort()
return
}
break
case setting.CaptchaTurnstile:
captchaSetting := settings.TurnstileCaptcha(c)
r := dep.RequestClient(
request2.WithContext(c),
request2.WithLogger(logging.FromContext(c)),
request2.WithHeader(http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}),
)
formData := url.Values{}
formData.Set("secret", captchaSetting.Secret)
formData.Set("response", service.Ticket)
res, err := r.Request("POST", turnstileEndpoint, strings.NewReader(formData.Encode())).
CheckHTTPResponse(http.StatusOK).
GetResponse()
if err != nil {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha validation failed", err))
c.Abort()
return
}
var trunstileRes turnstileResponse
err = json.Unmarshal([]byte(res), &trunstileRes)
if err != nil {
l.Warning("Turnstile verification failed, %s", err)
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha validation failed", err))
c.Abort()
return
}
if !trunstileRes.Success {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha validation failed", err))
c.Abort()
return
}
break
case setting.CaptchaCap:
captchaSetting := settings.CapCaptcha(c)
if captchaSetting.InstanceURL == "" || captchaSetting.SiteKey == "" || captchaSetting.SecretKey == "" {
l.Warning("Cap verification failed: missing configuration")
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha configuration error", nil))
c.Abort()
return
}
r := dep.RequestClient(
request2.WithContext(c),
request2.WithLogger(logging.FromContext(c)),
request2.WithHeader(http.Header{"Content-Type": []string{"application/json"}}),
)
// Cap 2.0 API format: /{siteKey}/siteverify
capEndpoint := strings.TrimSuffix(captchaSetting.InstanceURL, "/") + "/" + captchaSetting.SiteKey + "/siteverify"
requestBody := map[string]string{
"secret": captchaSetting.SecretKey,
"response": service.Ticket,
}
requestData, err := json.Marshal(requestBody)
if err != nil {
l.Warning("Cap verification failed: %s", err)
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha validation failed", err))
c.Abort()
return
}
res, err := r.Request("POST", capEndpoint, strings.NewReader(string(requestData))).
CheckHTTPResponse(http.StatusOK).
GetResponse()
if err != nil {
l.Warning("Cap verification failed: %s", err)
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha validation failed", err))
c.Abort()
return
}
var capRes capResponse
err = json.Unmarshal([]byte(res), &capRes)
if err != nil {
l.Warning("Cap verification failed: %s", err)
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha validation failed", err))
c.Abort()
return
}
if !capRes.Success {
l.Warning("Cap verification failed: validation returned false")
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha validation failed", nil))
c.Abort()
return
}
break
}
}
c.Next()
}
}
+75
View File
@@ -0,0 +1,75 @@
package middleware
import (
"sync"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster"
"github.com/cloudreve/Cloudreve/v4/pkg/downloader"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/routers/controllers"
"github.com/gin-gonic/gin"
)
type SlaveNodeSettingGetter interface {
// GetNodeSetting returns the node settings and its hash
GetNodeSetting() (*types.NodeSetting, string)
}
var downloaderPool = sync.Map{}
// PrepareSlaveDownloader creates or resume a downloader based on input node settings
func PrepareSlaveDownloader(dep dependency.Dep, ctxKey interface{}) gin.HandlerFunc {
return func(c *gin.Context) {
nodeSettings, hash := controllers.ParametersFromContext[SlaveNodeSettingGetter](c, ctxKey).GetNodeSetting()
// try to get downloader from pool
if d, ok := downloaderPool.Load(hash); ok {
c.Set(downloader.DownloaderCtxKey, d)
c.Next()
return
}
// create a new downloader
d, err := cluster.NewDownloader(c, dep.RequestClient(request.WithContext(c), request.WithLogger(dep.Logger())), dep.SettingProvider(), nodeSettings)
if err != nil {
c.JSON(200, serializer.ParamErr(c, "Failed to create downloader", err))
c.Abort()
return
}
// save downloader to pool
downloaderPool.Store(hash, d)
c.Set(downloader.DownloaderCtxKey, d)
c.Next()
}
}
func SlaveRPCSignRequired() gin.HandlerFunc {
return func(c *gin.Context) {
nodeId := cluster.NodeIdFromContext(c)
if nodeId == 0 {
c.JSON(200, serializer.ParamErr(c, "Unknown node ID", nil))
c.Abort()
return
}
np, err := dependency.FromContext(c).NodePool(c)
if err != nil {
c.JSON(200, serializer.NewError(serializer.CodeInternalSetting, "Failed to get node pool", err))
c.Abort()
return
}
slaveNode, err := np.Get(c, types.NodeCapabilityNone, nodeId)
if slaveNode == nil || slaveNode.IsMaster() {
c.JSON(200, serializer.ParamErr(c, "Unknown node ID", err))
c.Abort()
return
}
SignRequired(slaveNode.AuthInstance())(c)
}
}
+170
View File
@@ -0,0 +1,170 @@
package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/auth/requestinfo"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
)
// HashID 将给定对象的HashID转换为真实ID
func HashID(IDType int) gin.HandlerFunc {
return func(c *gin.Context) {
dep := dependency.FromContext(c)
if c.Param("id") != "" {
id, err := dep.HashIDEncoder().Decode(c.Param("id"), IDType)
if err == nil {
util.WithValue(c, hashid.ObjectIDCtx{}, id)
c.Next()
return
}
c.JSON(200, serializer.ParamErr(c, "Failed to parse object ID", err))
c.Abort()
return
}
c.Next()
}
}
// IsFunctionEnabled 当功能未开启时阻止访问
func IsFunctionEnabled(check func(c *gin.Context) bool) gin.HandlerFunc {
return func(c *gin.Context) {
if !check(c) {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeFeatureNotEnabled, "This feature is not enabled", nil))
c.Abort()
return
}
c.Next()
}
}
// CacheControl 屏蔽客户端缓存
func CacheControl() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "private, no-cache")
}
}
func Sandbox() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Content-Security-Policy", "sandbox")
}
}
// StaticResourceCache 使用静态资源缓存策略
func StaticResourceCache(dep dependency.Dep) gin.HandlerFunc {
settings := dep.SettingProvider()
return func(c *gin.Context) {
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", settings.PublicResourceMaxAge(c)))
}
}
// MobileRequestOnly
func MobileRequestOnly() gin.HandlerFunc {
return func(c *gin.Context) {
dep := dependency.FromContext(c)
if c.GetHeader(constants.CrHeaderPrefix+"ios") == "" {
c.Redirect(http.StatusMovedPermanently, dep.SettingProvider().SiteURL(c).String())
c.Abort()
return
}
c.Next()
}
}
// InitializeHandling is added at the beginning of handler chain, it did following setups:
// 1. Inject dependency manager into request context
// 2. Generate and inject correlation ID for diagnostic.
func InitializeHandling(dep dependency.Dep) gin.HandlerFunc {
return func(c *gin.Context) {
clientIp := c.ClientIP()
if idx := strings.Index(clientIp, ","); idx > 0 {
clientIp = clientIp[:idx]
}
reqInfo := &requestinfo.RequestInfo{
IP: clientIp,
Host: c.Request.Host,
UserAgent: c.Request.UserAgent(),
ClientID: c.GetHeader(request.ClientIDHeader),
}
cid := uuid.FromStringOrNil(c.GetHeader(request.CorrelationHeader))
if cid == uuid.Nil {
cid = uuid.Must(uuid.NewV4())
}
l := dep.Logger().CopyWithPrefix(fmt.Sprintf("[Cid: %s]", cid))
ctx := dep.ForkWithLogger(c.Request.Context(), l)
ctx = context.WithValue(ctx, logging.CorrelationIDCtx{}, cid)
ctx = context.WithValue(ctx, requestinfo.RequestInfoCtx{}, reqInfo)
ctx = context.WithValue(ctx, logging.LoggerCtx{}, l)
if id := c.Param("nodeId"); id != "" {
ctx = context.WithValue(ctx, cluster.SlaveNodeIDCtx{}, id)
} else {
ctx = context.WithValue(ctx, cluster.SlaveNodeIDCtx{}, c.GetHeader(request.SlaveNodeIDHeader))
}
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// InitializeHandlingSlave retrieves coll correlation ID and other metadata from request header
func InitializeHandlingSlave() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), cluster.MasterSiteIDCtx{}, c.GetHeader(request.SiteIDHeader))
ctx = context.WithValue(ctx, cluster.MasterSiteUrlCtx{}, c.GetHeader(request.SiteURLHeader))
ctx = context.WithValue(ctx, cluster.MasterSiteVersionCtx{}, c.GetHeader(request.SiteVersionHeader))
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// Logging logs incoming request info
func Logging() gin.HandlerFunc {
return func(c *gin.Context) {
// Start timer
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
// Process request
c.Next()
if raw != "" {
path = path + "?" + raw
}
l := logging.FromContext(c)
logging.Request(l, true, c.Writer.Status(), c.Request.Method, c.ClientIP(), path,
c.Errors.ByType(gin.ErrorTypePrivate).String(), start)
}
}
func ContentCORS() gin.HandlerFunc {
return cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "HEAD", "OPTIONS"},
AllowHeaders: []string{"Range", "If-Range", "Authorization", "Content-Type"},
ExposeHeaders: []string{"Content-Range", "Accept-Ranges", "Content-Length", "Content-Disposition", "Content-Disposition", "ETag"},
AllowCredentials: false,
})
}
+49
View File
@@ -0,0 +1,49 @@
package middleware
import (
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/cloudreve/Cloudreve/v4/routers/controllers"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
)
// UrisService is a wrapper for service supports batch file operations
type UrisService interface {
GetUris() []string
}
// ValidateBatchFileCount validates if the batch file count is within the limit
func ValidateBatchFileCount(dep dependency.Dep, ctxKey interface{}) gin.HandlerFunc {
settings := dep.SettingProvider()
return func(c *gin.Context) {
uris := controllers.ParametersFromContext[UrisService](c, ctxKey)
limit := settings.MaxBatchedFile(c)
if len((uris).GetUris()) > limit {
c.JSON(200, serializer.ErrWithDetails(
c,
serializer.CodeTooManyUris,
fmt.Sprintf("Maximum allowed batch size: %d", limit),
nil,
))
c.Abort()
return
}
c.Next()
}
}
// ContextHint parses the context hint header and set it to context
func ContextHint() gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetHeader(dbfs.ContextHintHeader) != "" {
util.WithValue(c, dbfs.ContextHintCtxKey{}, uuid.FromStringOrNil(c.GetHeader(dbfs.ContextHintHeader)))
}
c.Next()
}
}
+86
View File
@@ -0,0 +1,86 @@
package middleware
import (
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"io"
"net/http"
"strings"
)
// FrontendFileHandler 前端静态文件处理
func FrontendFileHandler(dep dependency.Dep) gin.HandlerFunc {
fs := dep.ServerStaticFS()
l := dep.Logger()
ignoreFunc := func(c *gin.Context) {
c.Next()
}
if fs == nil {
return ignoreFunc
}
// 读取index.html
file, err := fs.Open("/index.html")
if err != nil {
l.Warning("Static file \"index.html\" does not exist, it might affect the display of the homepage.")
return ignoreFunc
}
fileContentBytes, err := io.ReadAll(file)
if err != nil {
l.Warning("Cannot read static file \"index.html\", it might affect the display of the homepage.")
return ignoreFunc
}
fileContent := string(fileContentBytes)
fileServer := http.FileServer(fs)
return func(c *gin.Context) {
path := c.Request.URL.Path
// Skipping routers handled by backend
if strings.HasPrefix(path, "/api") ||
strings.HasPrefix(path, "/dav") ||
strings.HasPrefix(path, "/f/") ||
strings.HasPrefix(path, "/s/") ||
path == "/manifest.json" {
c.Next()
return
}
// 不存在的路径和index.html均返回index.html
if (path == "/index.html") || (path == "/") || !fs.Exists("/", path) {
// 读取、替换站点设置
settingClient := dep.SettingProvider()
siteBasic := settingClient.SiteBasic(c)
pwaOpts := settingClient.PWA(c)
theme := settingClient.Theme(c)
finalHTML := util.Replace(map[string]string{
"{siteName}": siteBasic.Name,
"{siteDes}": siteBasic.Description,
"{siteScript}": siteBasic.Script,
"{pwa_small_icon}": pwaOpts.SmallIcon,
"{pwa_medium_icon}": pwaOpts.MediumIcon,
"var(--defaultThemeColor)": theme.DefaultTheme,
}, fileContent)
c.Header("Content-Type", "text/html")
c.Header("Cache-Control", "public, no-cache")
c.String(200, finalHTML)
c.Abort()
return
}
if path == "/sw.js" || strings.HasPrefix(path, "/locales/") {
c.Header("Cache-Control", "public, no-cache")
} else if strings.HasPrefix(path, "/assets/") {
c.Header("Cache-Control", "public, max-age=31536000")
}
// 存在的静态文件
fileServer.ServeHTTP(c.Writer, c.Request)
c.Abort()
}
}
+24
View File
@@ -0,0 +1,24 @@
package middleware
import (
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
)
// SessionMock 测试时模拟Session
var SessionMock = make(map[string]interface{})
// ContextMock 测试时模拟Context
var ContextMock = make(map[string]interface{})
// MockHelper 单元测试助手中间件
func MockHelper() gin.HandlerFunc {
return func(c *gin.Context) {
// 将SessionMock写入会话
util.SetSession(c, SessionMock)
for key, value := range ContextMock {
c.Set(key, value)
}
c.Next()
}
}
+67
View File
@@ -0,0 +1,67 @@
package middleware
import (
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/sessionstore"
"net/http"
"strings"
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// Store session存储
var Store sessions.Store
const SessionName = "cloudreve-session"
// Session 初始化session
func Session(dep dependency.Dep) gin.HandlerFunc {
Store = sessionstore.NewStore(dep.KV(), []byte(dep.ConfigProvider().System().SessionSecret))
sameSiteMode := http.SameSiteDefaultMode
switch strings.ToLower(conf.CORSConfig.SameSite) {
case "default":
sameSiteMode = http.SameSiteDefaultMode
case "none":
sameSiteMode = http.SameSiteNoneMode
case "strict":
sameSiteMode = http.SameSiteStrictMode
case "lax":
sameSiteMode = http.SameSiteLaxMode
}
// Also set Secure: true if using SSL, you should though
Store.Options(sessions.Options{
HttpOnly: true,
MaxAge: 60 * 86400,
Path: "/",
SameSite: sameSiteMode,
Secure: conf.CORSConfig.Secure,
})
return sessions.Sessions(SessionName, Store)
}
// CSRFInit 初始化CSRF标记
func CSRFInit() gin.HandlerFunc {
return func(c *gin.Context) {
util.SetSession(c, map[string]interface{}{"CSRF": true})
c.Next()
}
}
// CSRFCheck 检查CSRF标记
func CSRFCheck() gin.HandlerFunc {
return func(c *gin.Context) {
if check, ok := util.GetSession(c, "CSRF").(bool); ok && check {
c.Next()
return
}
c.JSON(200, serializer.ErrDeprecated(serializer.CodeNoPermissionErr, "Invalid origin", nil))
c.Abort()
}
}
+252
View File
@@ -0,0 +1,252 @@
package middleware
import (
"bytes"
"errors"
"fmt"
"html/template"
"net/url"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/cloudreve/Cloudreve/v4/service/explorer"
"github.com/cloudreve/Cloudreve/v4/service/share"
"github.com/gin-gonic/gin"
)
const (
ogStatusInvalidLink = "Invalid Link"
)
type ogData struct {
SiteName string
Title string
Description string
ImageURL string
ShareURL string
RedirectURL string
}
const ogHTMLTemplate = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Description}}">
<meta property="og:image" content="{{.ImageURL}}">
<meta property="og:url" content="{{.ShareURL}}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{.SiteName}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.Title}}">
<meta name="twitter:description" content="{{.Description}}">
<meta name="twitter:image" content="{{.ImageURL}}">
<title>{{.Title}} - {{.SiteName}}</title>
</head>
<body>
<script>window.location.href = "{{.RedirectURL}}";</script>
<noscript><a href="{{.RedirectURL}}">{{.Title}}</a></noscript>
</body>
</html>`
var ogTemplate = template.Must(template.New("og").Parse(ogHTMLTemplate))
var socialMediaBots = []string{
"facebookexternalhit",
"facebookcatalog",
"facebot",
"twitterbot",
"linkedinbot",
"discordbot",
"telegrambot",
"slackbot",
"whatsapp",
}
func isSocialMediaBot(ua string) bool {
ua = strings.ToLower(ua)
for _, bot := range socialMediaBots {
if strings.Contains(ua, bot) {
return true
}
}
return false
}
// SharePreview 为社交媒体爬虫渲染OG预览页面
func SharePreview(dep dependency.Dep) gin.HandlerFunc {
return func(c *gin.Context) {
if !isSocialMediaBot(c.GetHeader("User-Agent")) {
c.Next()
return
}
id, password := extractShareParams(c)
if id == "" {
c.Next()
return
}
html := renderShareOGPage(c, dep, id, password)
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "public, no-cache")
c.String(200, html)
c.Abort()
}
}
func extractShareParams(c *gin.Context) (id, password string) {
urlPath := c.Request.URL.Path
if strings.HasPrefix(urlPath, "/s/") {
parts := strings.Split(strings.TrimPrefix(urlPath, "/s/"), "/")
if len(parts) >= 1 && parts[0] != "" {
id = parts[0]
if len(parts) >= 2 {
password = parts[1]
}
}
} else if urlPath == "/home" || urlPath == "/home/" {
rawPath := c.Query("path")
uri, err := fs.NewUriFromString(rawPath)
if err != nil || uri.FileSystem() != constants.FileSystemShare {
return "", ""
}
return uri.ID(""), uri.Password()
}
return id, password
}
func renderShareOGPage(c *gin.Context, dep dependency.Dep, id, password string) string {
settings := dep.SettingProvider()
siteBasic := settings.SiteBasic(c)
pwa := settings.PWA(c)
base := settings.SiteURL(c)
data := &ogData{
SiteName: siteBasic.Name,
Title: siteBasic.Name,
Description: siteBasic.Description,
ShareURL: routes.MasterShareUrl(base, id, password).String(),
RedirectURL: routes.MasterShareLongUrl(id, password).String(),
}
if pwa.LargeIcon != "" {
data.ImageURL = resolveURL(base, pwa.LargeIcon)
} else if pwa.MediumIcon != "" {
data.ImageURL = resolveURL(base, pwa.MediumIcon)
}
shareID, err := dep.HashIDEncoder().Decode(id, hashid.ShareID)
if err != nil {
data.Description = ogStatusInvalidLink
return renderOGHTML(data)
}
shareInfo, err := loadShareForOG(c, shareID, password)
if err != nil {
var appErr serializer.AppError
if errors.As(err, &appErr) {
data.Description = appErr.Msg
} else {
data.Description = ogStatusInvalidLink
}
return renderOGHTML(data)
}
data.Title = shareInfo.Name
if shareInfo.SourceType != nil && *shareInfo.SourceType == types.FileTypeFolder {
data.Description = "Folder"
} else if shareInfo.Unlocked {
data.Description = formatFileSize(shareInfo.Size)
thumbnail, err := loadShareThumbnail(c, id, password, shareInfo)
if err == nil {
data.ImageURL = thumbnail
}
}
data.Description += " · " + shareInfo.Owner.Nickname
return renderOGHTML(data)
}
func loadShareThumbnail(c *gin.Context, shareID, password string, shareInfo *explorer.Share) (string, error) {
shareUri, err := fs.NewUriFromString(fs.NewShareUri(shareID, password))
if err != nil {
return "", fmt.Errorf("failed to construct share uri: %w", err)
}
subService := &explorer.FileThumbService{
Uri: shareUri.Join(shareInfo.Name).String(),
}
if err := SetUserCtx(c, 0); err != nil {
return "", err
}
res, err := subService.Get(c)
if err != nil {
return "", err
}
return res.Url, nil
}
func loadShareForOG(c *gin.Context, shareID int, password string) (*explorer.Share, error) {
subService := &share.ShareInfoService{
Password: password,
CountViews: false,
}
if err := SetUserCtx(c, 0); err != nil {
return nil, err
}
util.WithValue(c, hashid.ObjectIDCtx{}, shareID)
return subService.Get(c)
}
func renderOGHTML(data *ogData) string {
var buf bytes.Buffer
if err := ogTemplate.Execute(&buf, data); err != nil {
return ""
}
return buf.String()
}
func resolveURL(base *url.URL, path string) string {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
return base.ResolveReference(&url.URL{Path: path}).String()
}
func formatFileSize(size int64) string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
TB = 1024 * GB
)
switch {
case size >= TB:
return fmt.Sprintf("%.2f TB", float64(size)/TB)
case size >= GB:
return fmt.Sprintf("%.2f GB", float64(size)/GB)
case size >= MB:
return fmt.Sprintf("%.2f MB", float64(size)/MB)
case size >= KB:
return fmt.Sprintf("%.2f KB", float64(size)/KB)
default:
return fmt.Sprintf("%d B", size)
}
}
+105
View File
@@ -0,0 +1,105 @@
package middleware
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/cloudreve/Cloudreve/v4/pkg/wopi"
"github.com/gin-gonic/gin"
)
// WopiWriteAccess validates if write access is obtained.
func WopiWriteAccess() gin.HandlerFunc {
return func(c *gin.Context) {
session := c.MustGet(wopi.WopiSessionCtx).(*wopi.SessionCache)
if session.Action != wopi.ActionEdit {
c.Status(http.StatusNotFound)
c.Header(wopi.ServerErrorHeader, "read-only access")
c.Abort()
return
}
c.Next()
}
}
func ViewerSessionValidation() gin.HandlerFunc {
return func(c *gin.Context) {
dep := dependency.FromContext(c)
store := dep.KV()
settings := dep.SettingProvider()
accessToken := c.Query(wopi.AccessTokenQuery)
sessionID, _, ok := strings.Cut(accessToken, ".")
if !ok || sessionID == "" {
c.Status(http.StatusForbidden)
c.Header(wopi.ServerErrorHeader, "malformed access token")
c.Abort()
return
}
sessionRaw, exist := store.Get(manager.ViewerSessionCachePrefix + sessionID)
if !exist {
c.Status(http.StatusForbidden)
c.Header(wopi.ServerErrorHeader, "invalid access token")
c.Abort()
return
}
session := sessionRaw.(manager.ViewerSessionCache)
if subtle.ConstantTimeCompare([]byte(accessToken), []byte(session.Token)) != 1 {
c.Status(http.StatusForbidden)
c.Header(wopi.ServerErrorHeader, "invalid access token")
c.Abort()
return
}
if err := SetUserCtx(c, session.UserID); err != nil {
c.Status(http.StatusInternalServerError)
c.Header(wopi.ServerErrorHeader, "user not found")
c.Abort()
return
}
fileId := hashid.FromContext(c)
if fileId != session.FileID {
c.Status(http.StatusForbidden)
c.Header(wopi.ServerErrorHeader, "invalid file")
c.Abort()
return
}
// Check if the viewer is still available
viewers := settings.FileViewers(c)
var v *types.Viewer
for _, group := range viewers {
for _, viewer := range group.Viewers {
if viewer.ID == session.ViewerID && !viewer.Disabled {
v = &viewer
break
}
}
if v != nil {
break
}
}
if v == nil {
c.Status(http.StatusInternalServerError)
c.Header(wopi.ServerErrorHeader, "viewer not found")
c.Abort()
return
}
util.WithValue(c, manager.ViewerCtx{}, v)
util.WithValue(c, manager.ViewerSessionCacheCtx{}, &session)
c.Next()
}
}