chore: 初始化 Cloudreve 源码仓库
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
rawsql "database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
_ "github.com/cloudreve/Cloudreve/v4/ent/runtime"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/debug"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/lib/pq"
|
||||
"modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
DBVersionPrefix = "db_version_"
|
||||
EnvDefaultOverwritePrefix = "CR_SETTING_DEFAULT_"
|
||||
EnvEnableAria2 = "CR_ENABLE_ARIA2"
|
||||
)
|
||||
|
||||
// InitializeDBClient runs migration and returns a new ent.Client with additional configurations
|
||||
// for hooks and interceptors.
|
||||
func InitializeDBClient(l logging.Logger,
|
||||
client *ent.Client, kv cache.Driver, requiredDbVersion string) (*ent.Client, error) {
|
||||
ctx := context.WithValue(context.Background(), logging.LoggerCtx{}, l)
|
||||
if needMigration(client, ctx, requiredDbVersion) {
|
||||
// Run the auto migration tool.
|
||||
if err := migrate(l, client, ctx, kv, requiredDbVersion); err != nil {
|
||||
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
} else {
|
||||
l.Info("Database schema is up to date.")
|
||||
}
|
||||
|
||||
//createMockData(client, ctx)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// NewRawEntClient returns a new ent.Client without additional configurations.
|
||||
func NewRawEntClient(l logging.Logger, config conf.ConfigProvider) (*ent.Client, error) {
|
||||
l.Info("Initializing database connection...")
|
||||
dbConfig := config.Database()
|
||||
confDBType := dbConfig.Type
|
||||
if confDBType == conf.SQLite3DB || confDBType == "" {
|
||||
confDBType = conf.SQLiteDB
|
||||
}
|
||||
if confDBType == conf.MariaDB {
|
||||
confDBType = conf.MySqlDB
|
||||
}
|
||||
|
||||
var (
|
||||
err error
|
||||
client *sql.Driver
|
||||
)
|
||||
|
||||
// Check if the database type is supported.
|
||||
if confDBType != conf.SQLiteDB && confDBType != conf.MySqlDB && confDBType != conf.PostgresDB {
|
||||
return nil, fmt.Errorf("unsupported database type: %s", confDBType)
|
||||
}
|
||||
// If Database connection string provided, use it directly.
|
||||
if dbConfig.DatabaseURL != "" {
|
||||
l.Info("Connect to database with connection string")
|
||||
client, err = sql.Open(string(confDBType), dbConfig.DatabaseURL)
|
||||
} else {
|
||||
|
||||
switch confDBType {
|
||||
case conf.SQLiteDB:
|
||||
dbFile := util.RelativePath(dbConfig.DBFile)
|
||||
l.Info("Connect to SQLite database %q.", dbFile)
|
||||
client, err = sql.Open("sqlite3", util.RelativePath(dbConfig.DBFile))
|
||||
case conf.PostgresDB:
|
||||
l.Info("Connect to Postgres database %q.", dbConfig.Host)
|
||||
client, err = sql.Open("postgres", fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable",
|
||||
dbConfig.Host,
|
||||
dbConfig.User,
|
||||
dbConfig.Password,
|
||||
dbConfig.Name,
|
||||
dbConfig.Port))
|
||||
case conf.MySqlDB, conf.MsSqlDB:
|
||||
l.Info("Connect to MySQL/SQLServer database %q.", dbConfig.Host)
|
||||
var host string
|
||||
if dbConfig.UnixSocket {
|
||||
host = fmt.Sprintf("unix(%s)",
|
||||
dbConfig.Host)
|
||||
} else {
|
||||
host = fmt.Sprintf("(%s:%d)",
|
||||
dbConfig.Host,
|
||||
dbConfig.Port)
|
||||
}
|
||||
|
||||
client, err = sql.Open(string(confDBType), fmt.Sprintf("%s:%s@%s/%s?charset=%s&parseTime=True&loc=Local",
|
||||
dbConfig.User,
|
||||
dbConfig.Password,
|
||||
host,
|
||||
dbConfig.Name,
|
||||
dbConfig.Charset))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type %q", confDBType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
}
|
||||
// Set connection pool
|
||||
db := client.DB()
|
||||
db.SetMaxIdleConns(50)
|
||||
if confDBType == "sqlite" || confDBType == "UNSET" {
|
||||
db.SetMaxOpenConns(1)
|
||||
} else {
|
||||
db.SetMaxOpenConns(100)
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
db.SetConnMaxLifetime(time.Second * 30)
|
||||
|
||||
driverOpt := ent.Driver(client)
|
||||
|
||||
// Enable verbose logging for debug mode.
|
||||
if config.System().Debug {
|
||||
l.Debug("Debug mode is enabled for DB client.")
|
||||
driverOpt = ent.Driver(debug.DebugWithContext(client, func(ctx context.Context, i ...any) {
|
||||
logging.FromContext(ctx).Debug(i[0].(string), i[1:]...)
|
||||
}))
|
||||
}
|
||||
|
||||
return ent.NewClient(driverOpt), nil
|
||||
}
|
||||
|
||||
type sqlite3Driver struct {
|
||||
*sqlite.Driver
|
||||
}
|
||||
|
||||
type sqlite3DriverConn interface {
|
||||
Exec(string, []driver.Value) (driver.Result, error)
|
||||
}
|
||||
|
||||
func (d sqlite3Driver) Open(name string) (conn driver.Conn, err error) {
|
||||
conn, err = d.Driver.Open(name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = conn.(sqlite3DriverConn).Exec("PRAGMA foreign_keys = ON;", nil)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func init() {
|
||||
rawsql.Register("sqlite3", sqlite3Driver{Driver: &sqlite.Driver{}})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
)
|
||||
|
||||
type (
|
||||
OrderDirection string
|
||||
PaginationArgs struct {
|
||||
UseCursorPagination bool
|
||||
Page int
|
||||
PageToken string
|
||||
PageSize int
|
||||
OrderBy string
|
||||
Order OrderDirection
|
||||
}
|
||||
|
||||
PaginationResults struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalItems int `json:"total_items,omitempty"`
|
||||
NextPageToken string `json:"next_token,omitempty"`
|
||||
IsCursor bool `json:"is_cursor,omitempty"`
|
||||
}
|
||||
|
||||
PageToken struct {
|
||||
Time *time.Time `json:"time,omitempty"`
|
||||
ID int `json:"-"`
|
||||
IDHash string `json:"id,omitempty"`
|
||||
String string `json:"string,omitempty"`
|
||||
Int int `json:"int,omitempty"`
|
||||
StartWithFile bool `json:"start_with_file,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
OrderDirectionAsc = OrderDirection("asc")
|
||||
OrderDirectionDesc = OrderDirection("desc")
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTooManyArguments = fmt.Errorf("too many arguments")
|
||||
)
|
||||
|
||||
func pageTokenFromString(s string, hasher hashid.Encoder, idType int) (*PageToken, error) {
|
||||
sB64Decoded, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 for page token: %w", err)
|
||||
}
|
||||
|
||||
token := &PageToken{}
|
||||
if err := json.Unmarshal(sB64Decoded, token); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal page token: %w", err)
|
||||
}
|
||||
|
||||
id, err := hasher.Decode(token.IDHash, idType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode id: %w", err)
|
||||
}
|
||||
|
||||
if token.Time == nil {
|
||||
token.Time = &time.Time{}
|
||||
}
|
||||
|
||||
token.ID = id
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (p *PageToken) Encode(hasher hashid.Encoder, encodeFunc hashid.EncodeFunc) (string, error) {
|
||||
p.IDHash = encodeFunc(hasher, p.ID)
|
||||
res, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal page token: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(res), nil
|
||||
}
|
||||
|
||||
// sqlParamLimit returns the max number of sql parameters.
|
||||
func sqlParamLimit(dbType conf.DBType) int {
|
||||
switch dbType {
|
||||
case conf.PostgresDB:
|
||||
return 34464
|
||||
case conf.SQLiteDB, conf.SQLite3DB:
|
||||
// https://www.sqlite.org/limits.html
|
||||
return 32766
|
||||
default:
|
||||
return 32766
|
||||
}
|
||||
}
|
||||
|
||||
// getOrderTerm returns the order term for ent.
|
||||
func getOrderTerm(d OrderDirection) sql.OrderTermOption {
|
||||
switch d {
|
||||
case OrderDirectionDesc:
|
||||
return sql.OrderDesc()
|
||||
default:
|
||||
return sql.OrderAsc()
|
||||
}
|
||||
}
|
||||
|
||||
func capPageSize(maxSQlParam, preferredSize, margin int) int {
|
||||
// Page size should not be bigger than max SQL parameter
|
||||
pageSize := preferredSize
|
||||
if maxSQlParam > 0 && pageSize > maxSQlParam-margin || pageSize == 0 {
|
||||
pageSize = maxSQlParam - margin
|
||||
}
|
||||
|
||||
return pageSize
|
||||
}
|
||||
|
||||
type StorageDiff map[int]int64
|
||||
|
||||
func (s *StorageDiff) Merge(diff StorageDiff) {
|
||||
for k, v := range diff {
|
||||
(*s)[k] += v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"fmt"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/davaccount"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type (
|
||||
DavAccountClient interface {
|
||||
TxOperator
|
||||
// List returns a list of dav accounts with the given args.
|
||||
List(ctx context.Context, args *ListDavAccountArgs) (*ListDavAccountResult, error)
|
||||
// Create creates a new dav account.
|
||||
Create(ctx context.Context, params *CreateDavAccountParams) (*ent.DavAccount, error)
|
||||
// Update updates a dav account.
|
||||
Update(ctx context.Context, id int, params *CreateDavAccountParams) (*ent.DavAccount, error)
|
||||
// GetByIDAndUserID returns the dav account with given id and user id.
|
||||
GetByIDAndUserID(ctx context.Context, id, userID int) (*ent.DavAccount, error)
|
||||
// Delete deletes the dav account.
|
||||
Delete(ctx context.Context, id int) error
|
||||
}
|
||||
|
||||
ListDavAccountArgs struct {
|
||||
*PaginationArgs
|
||||
UserID int
|
||||
}
|
||||
ListDavAccountResult struct {
|
||||
*PaginationResults
|
||||
Accounts []*ent.DavAccount
|
||||
}
|
||||
CreateDavAccountParams struct {
|
||||
UserID int
|
||||
Name string
|
||||
URI string
|
||||
Password string
|
||||
Options *boolset.BooleanSet
|
||||
}
|
||||
)
|
||||
|
||||
func NewDavAccountClient(client *ent.Client, dbType conf.DBType, hasher hashid.Encoder) DavAccountClient {
|
||||
return &davAccountClient{
|
||||
client: client,
|
||||
hasher: hasher,
|
||||
maxSQlParam: sqlParamLimit(dbType),
|
||||
}
|
||||
}
|
||||
|
||||
type davAccountClient struct {
|
||||
maxSQlParam int
|
||||
client *ent.Client
|
||||
hasher hashid.Encoder
|
||||
}
|
||||
|
||||
func (c *davAccountClient) SetClient(newClient *ent.Client) TxOperator {
|
||||
return &davAccountClient{client: newClient, hasher: c.hasher, maxSQlParam: c.maxSQlParam}
|
||||
}
|
||||
|
||||
func (c *davAccountClient) GetClient() *ent.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *davAccountClient) Create(ctx context.Context, params *CreateDavAccountParams) (*ent.DavAccount, error) {
|
||||
account := c.client.DavAccount.Create().
|
||||
SetOwnerID(params.UserID).
|
||||
SetName(params.Name).
|
||||
SetURI(params.URI).
|
||||
SetPassword(params.Password).
|
||||
SetOptions(params.Options)
|
||||
|
||||
return account.Save(ctx)
|
||||
}
|
||||
|
||||
func (c *davAccountClient) GetByIDAndUserID(ctx context.Context, id, userID int) (*ent.DavAccount, error) {
|
||||
return c.client.DavAccount.Query().
|
||||
Where(davaccount.ID(id), davaccount.OwnerID(userID)).
|
||||
First(ctx)
|
||||
}
|
||||
|
||||
func (c *davAccountClient) Update(ctx context.Context, id int, params *CreateDavAccountParams) (*ent.DavAccount, error) {
|
||||
account := c.client.DavAccount.UpdateOneID(id).
|
||||
SetName(params.Name).
|
||||
SetURI(params.URI).
|
||||
SetOptions(params.Options)
|
||||
|
||||
return account.Save(ctx)
|
||||
}
|
||||
|
||||
func (c *davAccountClient) Delete(ctx context.Context, id int) error {
|
||||
return c.client.DavAccount.DeleteOneID(id).Exec(ctx)
|
||||
}
|
||||
|
||||
func (c *davAccountClient) List(ctx context.Context, args *ListDavAccountArgs) (*ListDavAccountResult, error) {
|
||||
query := c.listQuery(args)
|
||||
|
||||
var (
|
||||
accounts []*ent.DavAccount
|
||||
err error
|
||||
paginationRes *PaginationResults
|
||||
)
|
||||
accounts, paginationRes, err = c.cursorPagination(ctx, query, args, 10)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed with paginiation: %w", err)
|
||||
}
|
||||
|
||||
return &ListDavAccountResult{
|
||||
Accounts: accounts,
|
||||
PaginationResults: paginationRes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *davAccountClient) cursorPagination(ctx context.Context, query *ent.DavAccountQuery, args *ListDavAccountArgs, paramMargin int) ([]*ent.DavAccount, *PaginationResults, error) {
|
||||
pageSize := capPageSize(c.maxSQlParam, args.PageSize, paramMargin)
|
||||
query.Order(davaccount.ByID(sql.OrderDesc()))
|
||||
|
||||
var (
|
||||
pageToken *PageToken
|
||||
err error
|
||||
)
|
||||
if args.PageToken != "" {
|
||||
pageToken, err = pageTokenFromString(args.PageToken, c.hasher, hashid.DavAccountID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid page token %q: %w", args.PageToken, err)
|
||||
}
|
||||
}
|
||||
queryPaged := getDavAccountCursorQuery(pageToken, query)
|
||||
|
||||
// Use page size + 1 to determine if there are more items to come
|
||||
queryPaged.Limit(pageSize + 1)
|
||||
|
||||
logs, err := queryPaged.
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// More items to come
|
||||
nextTokenStr := ""
|
||||
if len(logs) > pageSize {
|
||||
lastItem := logs[len(logs)-2]
|
||||
nextToken, err := getDavAccountNextPageToken(c.hasher, lastItem)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate next page token: %w", err)
|
||||
}
|
||||
|
||||
nextTokenStr = nextToken
|
||||
}
|
||||
|
||||
return lo.Subset(logs, 0, uint(pageSize)), &PaginationResults{
|
||||
PageSize: pageSize,
|
||||
NextPageToken: nextTokenStr,
|
||||
IsCursor: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *davAccountClient) listQuery(args *ListDavAccountArgs) *ent.DavAccountQuery {
|
||||
query := c.client.DavAccount.Query()
|
||||
if args.UserID > 0 {
|
||||
query.Where(davaccount.OwnerID(args.UserID))
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// getDavAccountNextPageToken returns the next page token for the given last dav account.
|
||||
func getDavAccountNextPageToken(hasher hashid.Encoder, last *ent.DavAccount) (string, error) {
|
||||
token := &PageToken{
|
||||
ID: last.ID,
|
||||
}
|
||||
|
||||
return token.Encode(hasher, hashid.EncodeDavAccountID)
|
||||
}
|
||||
|
||||
func getDavAccountCursorQuery(token *PageToken, query *ent.DavAccountQuery) *ent.DavAccountQuery {
|
||||
if token != nil {
|
||||
query.Where(davaccount.IDLT(token.ID))
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
const strMaxLen = 102400
|
||||
|
||||
type SkipDbLogging struct{}
|
||||
|
||||
// DebugDriver is a driver that logs all driver operations.
|
||||
type DebugDriver struct {
|
||||
dialect.Driver // underlying driver.
|
||||
log func(context.Context, ...any) // log function. defaults to log.Println.
|
||||
}
|
||||
|
||||
// DebugWithContext gets a driver and a logging function, and returns
|
||||
// a new debugged-driver that prints all outgoing operations with context.
|
||||
func DebugWithContext(d dialect.Driver, logger func(context.Context, ...any)) dialect.Driver {
|
||||
drv := &DebugDriver{d, logger}
|
||||
return drv
|
||||
}
|
||||
|
||||
// Exec logs its params and calls the underlying driver Exec method.
|
||||
func (d *DebugDriver) Exec(ctx context.Context, query string, args, v any) error {
|
||||
start := time.Now()
|
||||
err := d.Driver.Exec(ctx, query, args, v)
|
||||
if skip, ok := ctx.Value(SkipDbLogging{}).(bool); ok && skip {
|
||||
return err
|
||||
}
|
||||
|
||||
d.log(ctx, fmt.Sprintf("driver.Exec: query=%v args=%v time=%v", query, args, time.Since(start)))
|
||||
return err
|
||||
}
|
||||
|
||||
// ExecContext logs its params and calls the underlying driver ExecContext method if it is supported.
|
||||
func (d *DebugDriver) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
drv, ok := d.Driver.(interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Driver.ExecContext is not supported")
|
||||
}
|
||||
if skip, ok := ctx.Value(SkipDbLogging{}).(bool); ok && skip {
|
||||
return drv.ExecContext(ctx, query, args...)
|
||||
}
|
||||
d.log(ctx, fmt.Sprintf("driver.ExecContext: query=%v args=%v", query, args))
|
||||
return drv.ExecContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// Query logs its params and calls the underlying driver Query method.
|
||||
func (d *DebugDriver) Query(ctx context.Context, query string, args, v any) error {
|
||||
start := time.Now()
|
||||
err := d.Driver.Query(ctx, query, args, v)
|
||||
if skip, ok := ctx.Value(SkipDbLogging{}).(bool); ok && skip {
|
||||
return err
|
||||
}
|
||||
d.log(ctx, fmt.Sprintf("driver.Query: query=%v args=%v time=%v", query, args, time.Since(start)))
|
||||
return err
|
||||
}
|
||||
|
||||
// QueryContext logs its params and calls the underlying driver QueryContext method if it is supported.
|
||||
func (d *DebugDriver) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
||||
drv, ok := d.Driver.(interface {
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Driver.QueryContext is not supported")
|
||||
}
|
||||
if skip, ok := ctx.Value(SkipDbLogging{}).(bool); ok && skip {
|
||||
return drv.QueryContext(ctx, query, args...)
|
||||
}
|
||||
d.log(ctx, fmt.Sprintf("driver.QueryContext: query=%v args=%v", query, args))
|
||||
return drv.QueryContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// Tx adds an log-id for the transaction and calls the underlying driver Tx command.
|
||||
func (d *DebugDriver) Tx(ctx context.Context) (dialect.Tx, error) {
|
||||
tx, err := d.Driver.Tx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := uuid.New().String()
|
||||
d.log(ctx, fmt.Sprintf("driver.Tx(%s): started", id))
|
||||
return &DebugTx{tx, id, d.log, ctx}, nil
|
||||
}
|
||||
|
||||
// BeginTx adds an log-id for the transaction and calls the underlying driver BeginTx command if it is supported.
|
||||
func (d *DebugDriver) BeginTx(ctx context.Context, opts *sql.TxOptions) (dialect.Tx, error) {
|
||||
drv, ok := d.Driver.(interface {
|
||||
BeginTx(context.Context, *sql.TxOptions) (dialect.Tx, error)
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Driver.BeginTx is not supported")
|
||||
}
|
||||
tx, err := drv.BeginTx(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := uuid.New().String()
|
||||
d.log(ctx, fmt.Sprintf("driver.BeginTx(%s): started", id))
|
||||
return &DebugTx{tx, id, d.log, ctx}, nil
|
||||
}
|
||||
|
||||
// DebugTx is a transaction implementation that logs all transaction operations.
|
||||
type DebugTx struct {
|
||||
dialect.Tx // underlying transaction.
|
||||
id string // transaction logging id.
|
||||
log func(context.Context, ...any) // log function. defaults to fmt.Println.
|
||||
ctx context.Context // underlying transaction context.
|
||||
}
|
||||
|
||||
// Exec logs its params and calls the underlying transaction Exec method.
|
||||
func (d *DebugTx) Exec(ctx context.Context, query string, args, v any) error {
|
||||
start := time.Now()
|
||||
err := d.Tx.Exec(ctx, query, args, v)
|
||||
printArgs := args
|
||||
if argsArray, ok := args.([]interface{}); ok {
|
||||
for i, argVal := range argsArray {
|
||||
if argValStr, ok := argVal.(string); ok && len(argValStr) > strMaxLen {
|
||||
printArgs.([]interface{})[i] = argValStr[:strMaxLen] + "...[Truncated]..."
|
||||
}
|
||||
}
|
||||
}
|
||||
if skip, ok := ctx.Value(SkipDbLogging{}).(bool); ok && skip {
|
||||
return err
|
||||
}
|
||||
d.log(ctx, fmt.Sprintf("Tx(%s).Exec: query=%v args=%v time=%v", d.id, query, args, time.Since(start)))
|
||||
return err
|
||||
}
|
||||
|
||||
// ExecContext logs its params and calls the underlying transaction ExecContext method if it is supported.
|
||||
func (d *DebugTx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
drv, ok := d.Tx.(interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Tx.ExecContext is not supported")
|
||||
}
|
||||
if skip, ok := ctx.Value(SkipDbLogging{}).(bool); ok && skip {
|
||||
return drv.ExecContext(ctx, query, args...)
|
||||
}
|
||||
d.log(ctx, fmt.Sprintf("Tx(%s).ExecContext: query=%v args=%v", d.id, query, args))
|
||||
return drv.ExecContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// Query logs its params and calls the underlying transaction Query method.
|
||||
func (d *DebugTx) Query(ctx context.Context, query string, args, v any) error {
|
||||
start := time.Now()
|
||||
err := d.Tx.Query(ctx, query, args, v)
|
||||
if skip, ok := ctx.Value(SkipDbLogging{}).(bool); ok && skip {
|
||||
return err
|
||||
}
|
||||
d.log(ctx, fmt.Sprintf("Tx(%s).Query: query=%v args=%v time=%v", d.id, query, args, time.Since(start)))
|
||||
return err
|
||||
}
|
||||
|
||||
// QueryContext logs its params and calls the underlying transaction QueryContext method if it is supported.
|
||||
func (d *DebugTx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
||||
drv, ok := d.Tx.(interface {
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Tx.QueryContext is not supported")
|
||||
}
|
||||
if skip, ok := ctx.Value(SkipDbLogging{}).(bool); ok && skip {
|
||||
return drv.QueryContext(ctx, query, args...)
|
||||
}
|
||||
d.log(ctx, fmt.Sprintf("Tx(%s).QueryContext: query=%v args=%v", d.id, query, args))
|
||||
return drv.QueryContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// Commit logs this step and calls the underlying transaction Commit method.
|
||||
func (d *DebugTx) Commit() error {
|
||||
d.log(d.ctx, fmt.Sprintf("Tx(%s): committed", d.id))
|
||||
return d.Tx.Commit()
|
||||
}
|
||||
|
||||
// Rollback logs this step and calls the underlying transaction Rollback method.
|
||||
func (d *DebugTx) Rollback() error {
|
||||
d.log(d.ctx, fmt.Sprintf("Tx(%s): rollbacked", d.id))
|
||||
return d.Tx.Rollback()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/directlink"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/schema"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
)
|
||||
|
||||
type (
|
||||
DirectLinkClient interface {
|
||||
TxOperator
|
||||
// GetByNameID get direct link by name and id
|
||||
GetByNameID(ctx context.Context, id int, name string) (*ent.DirectLink, error)
|
||||
// GetByID get direct link by id
|
||||
GetByID(ctx context.Context, id int) (*ent.DirectLink, error)
|
||||
// Delete delete direct link by id
|
||||
Delete(ctx context.Context, id int) error
|
||||
}
|
||||
LoadDirectLinkFile struct{}
|
||||
)
|
||||
|
||||
func NewDirectLinkClient(client *ent.Client, dbType conf.DBType, hasher hashid.Encoder) DirectLinkClient {
|
||||
return &directLinkClient{
|
||||
client: client,
|
||||
hasher: hasher,
|
||||
maxSQlParam: sqlParamLimit(dbType),
|
||||
}
|
||||
}
|
||||
|
||||
type directLinkClient struct {
|
||||
maxSQlParam int
|
||||
client *ent.Client
|
||||
hasher hashid.Encoder
|
||||
}
|
||||
|
||||
func (c *directLinkClient) SetClient(newClient *ent.Client) TxOperator {
|
||||
return &directLinkClient{client: newClient, hasher: c.hasher, maxSQlParam: c.maxSQlParam}
|
||||
}
|
||||
|
||||
func (c *directLinkClient) GetClient() *ent.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (d *directLinkClient) GetByID(ctx context.Context, id int) (*ent.DirectLink, error) {
|
||||
return withDirectLinkEagerLoading(ctx, d.client.DirectLink.Query().Where(directlink.ID(id))).
|
||||
First(ctx)
|
||||
}
|
||||
|
||||
func (d *directLinkClient) GetByNameID(ctx context.Context, id int, name string) (*ent.DirectLink, error) {
|
||||
res, err := withDirectLinkEagerLoading(ctx, d.client.DirectLink.Query().Where(directlink.ID(id), directlink.Name(name))).
|
||||
First(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Increase download counter
|
||||
_, _ = d.client.DirectLink.Update().Where(directlink.ID(res.ID)).SetDownloads(res.Downloads + 1).Save(ctx)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (d *directLinkClient) Delete(ctx context.Context, id int) error {
|
||||
ctx = schema.SkipSoftDelete(ctx)
|
||||
_, err := d.client.DirectLink.Delete().Where(directlink.ID(id)).Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func withDirectLinkEagerLoading(ctx context.Context, q *ent.DirectLinkQuery) *ent.DirectLinkQuery {
|
||||
if v, ok := ctx.Value(LoadDirectLinkFile{}).(bool); ok && v {
|
||||
q.WithFile(func(m *ent.FileQuery) {
|
||||
withFileEagerLoading(ctx, m)
|
||||
})
|
||||
}
|
||||
return q
|
||||
}
|
||||
+1240
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,522 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/entity"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/file"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/metadata"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/predicate"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
const (
|
||||
metadataExactMatchPrefix = "!exact:"
|
||||
)
|
||||
|
||||
func (f *fileClient) searchQuery(q *ent.FileQuery, args *SearchFileParameters, parents []*ent.File, ownerId int) *ent.FileQuery {
|
||||
if len(parents) == 1 && parents[0] == nil {
|
||||
q = q.Where(file.OwnerID(ownerId))
|
||||
} else {
|
||||
q = q.Where(
|
||||
file.HasParentWith(
|
||||
file.IDIn(lo.Map(parents, func(item *ent.File, index int) int {
|
||||
return item.ID
|
||||
})...,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if len(args.Name) > 0 {
|
||||
namePredicates := lo.Map(args.Name, func(item string, index int) predicate.File {
|
||||
// If start and ends with quotes, treat as exact match
|
||||
if strings.HasPrefix(item, "\"") && strings.HasSuffix(item, "\"") {
|
||||
return file.NameContains(strings.Trim(item, "\""))
|
||||
}
|
||||
|
||||
// if contain wildcard, use transform to sql like
|
||||
if strings.Contains(item, SearchWildcard) {
|
||||
pattern := strings.ReplaceAll(item, SearchWildcard, "%")
|
||||
if pattern[0] != '%' && pattern[len(pattern)-1] != '%' {
|
||||
// if not start with wildcard, add prefix wildcard
|
||||
pattern = "%" + pattern + "%"
|
||||
}
|
||||
|
||||
return func(s *sql.Selector) {
|
||||
s.Where(sql.Like(file.FieldName, pattern))
|
||||
}
|
||||
}
|
||||
|
||||
if args.CaseFolding {
|
||||
return file.NameContainsFold(item)
|
||||
}
|
||||
|
||||
return file.NameContains(item)
|
||||
})
|
||||
|
||||
if args.NameOperatorOr {
|
||||
q = q.Where(file.Or(namePredicates...))
|
||||
} else {
|
||||
q = q.Where(file.And(namePredicates...))
|
||||
}
|
||||
}
|
||||
|
||||
if args.Type != nil {
|
||||
q = q.Where(file.TypeEQ(int(*args.Type)))
|
||||
}
|
||||
|
||||
if len(args.Metadata) > 0 {
|
||||
metaPredicates := lo.Map(args.Metadata, func(item MetadataFilter, index int) predicate.Metadata {
|
||||
if item.Exact {
|
||||
return metadata.And(metadata.NameEQ(item.Key), metadata.ValueEQ(item.Value))
|
||||
}
|
||||
|
||||
nameEq := metadata.And(metadata.IsPublic(true), metadata.NameEQ(item.Key))
|
||||
if item.Value == "" {
|
||||
return nameEq
|
||||
} else {
|
||||
valueContain := metadata.ValueContainsFold(item.Value)
|
||||
return metadata.And(nameEq, valueContain)
|
||||
}
|
||||
})
|
||||
q.Where(file.And(lo.Map(metaPredicates, func(item predicate.Metadata, index int) predicate.File {
|
||||
return file.HasMetadataWith(item)
|
||||
})...))
|
||||
}
|
||||
|
||||
if args.SizeLte > 0 || args.SizeGte > 0 {
|
||||
q = q.Where(file.SizeGTE(args.SizeGte), file.SizeLTE(args.SizeLte))
|
||||
}
|
||||
|
||||
if args.CreatedAtLte != nil {
|
||||
q = q.Where(file.CreatedAtLTE(*args.CreatedAtLte))
|
||||
}
|
||||
|
||||
if args.CreatedAtGte != nil {
|
||||
q = q.Where(file.CreatedAtGTE(*args.CreatedAtGte))
|
||||
}
|
||||
|
||||
if args.UpdatedAtLte != nil {
|
||||
q = q.Where(file.UpdatedAtLTE(*args.UpdatedAtLte))
|
||||
}
|
||||
|
||||
if args.UpdatedAtGte != nil {
|
||||
q = q.Where(file.UpdatedAtGTE(*args.UpdatedAtGte))
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// ChildFileQuery generates query for child file(s) of a given set of root
|
||||
func (f *fileClient) childFileQuery(ownerID int, isSymbolic bool, root ...*ent.File) *ent.FileQuery {
|
||||
rawQuery := f.client.File.Query()
|
||||
if len(root) == 1 && root[0] != nil {
|
||||
// Query children of one single root
|
||||
rawQuery = f.client.File.QueryChildren(root[0])
|
||||
} else if root[0] == nil {
|
||||
// Query orphan files with owner ID
|
||||
predicates := []predicate.File{
|
||||
file.NameNEQ(RootFolderName),
|
||||
}
|
||||
|
||||
if ownerID > 0 {
|
||||
predicates = append(predicates, file.OwnerIDEQ(ownerID))
|
||||
}
|
||||
|
||||
if isSymbolic {
|
||||
predicates = append(predicates, file.And(file.IsSymbolic(true), file.FileChildrenNotNil()))
|
||||
} else {
|
||||
predicates = append(predicates, file.Not(file.HasParent()))
|
||||
}
|
||||
|
||||
rawQuery = f.client.File.Query().Where(
|
||||
file.And(predicates...),
|
||||
)
|
||||
} else {
|
||||
// Query children of multiple roots
|
||||
rawQuery.
|
||||
Where(
|
||||
file.HasParentWith(
|
||||
file.IDIn(lo.Map(root, func(item *ent.File, index int) int {
|
||||
return item.ID
|
||||
})...),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return rawQuery
|
||||
}
|
||||
|
||||
// batchInCondition returns a list of predicates that divide original group into smaller ones
|
||||
// to bypass DB limitations.
|
||||
func (f *fileClient) batchInCondition(pageSize, margin int, multiply int, ids []int) ([]predicate.File, [][]int) {
|
||||
pageSize = capPageSize(f.maxSQlParam, pageSize, margin)
|
||||
chunks := lo.Chunk(ids, max(pageSize/multiply, 1))
|
||||
return lo.Map(chunks, func(item []int, index int) predicate.File {
|
||||
return file.IDIn(item...)
|
||||
}), chunks
|
||||
}
|
||||
|
||||
func (f *fileClient) batchInConditionMetadataName(pageSize, margin int, multiply int, keys []string) ([]predicate.Metadata, [][]string) {
|
||||
pageSize = capPageSize(f.maxSQlParam, pageSize, margin)
|
||||
chunks := lo.Chunk(keys, max(pageSize/multiply, 1))
|
||||
return lo.Map(chunks, func(item []string, index int) predicate.Metadata {
|
||||
return metadata.NameIn(item...)
|
||||
}), chunks
|
||||
}
|
||||
|
||||
func (f *fileClient) batchInConditionEntityID(pageSize, margin int, multiply int, keys []int) ([]predicate.Entity, [][]int) {
|
||||
pageSize = capPageSize(f.maxSQlParam, pageSize, margin)
|
||||
chunks := lo.Chunk(keys, max(pageSize/multiply, 1))
|
||||
return lo.Map(chunks, func(item []int, index int) predicate.Entity {
|
||||
return entity.IDIn(item...)
|
||||
}), chunks
|
||||
}
|
||||
|
||||
// cursorPagination perform pagination with cursor, which is faster than fast pagination, but less flexible.
|
||||
func (f *fileClient) cursorPagination(ctx context.Context, query *ent.FileQuery,
|
||||
args *ListFileParameters, paramMargin int) ([]*ent.File, *PaginationResults, error) {
|
||||
pageSize := capPageSize(f.maxSQlParam, args.PageSize, paramMargin)
|
||||
query.Order(getFileOrderOption(args)...)
|
||||
currentPage := 0
|
||||
// Three types of query option
|
||||
queryPaged := []*ent.FileQuery{
|
||||
query.Clone().
|
||||
Where(file.TypeEQ(int(types.FileTypeFolder))),
|
||||
query.Clone().
|
||||
Where(file.TypeEQ(int(types.FileTypeFile))),
|
||||
query.Clone().
|
||||
Where(file.TypeIn(int(types.FileTypeFolder), int(types.FileTypeFile))),
|
||||
}
|
||||
|
||||
var (
|
||||
pageToken *PageToken
|
||||
err error
|
||||
)
|
||||
if args.PageToken != "" {
|
||||
pageToken, err = pageTokenFromString(args.PageToken, f.hasher, hashid.FileID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid page token %q: %w", args.PageToken, err)
|
||||
}
|
||||
}
|
||||
queryPaged = getFileCursorQuery(args, pageToken, queryPaged)
|
||||
|
||||
// Use page size + 1 to determine if there are more items to come
|
||||
queryPaged[0].Limit(pageSize + 1)
|
||||
|
||||
files, err := queryPaged[0].
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
nextStartWithFile := false
|
||||
if pageToken != nil && pageToken.StartWithFile {
|
||||
nextStartWithFile = true
|
||||
}
|
||||
if len(files) < pageSize+1 && len(queryPaged) > 1 && !args.MixedType && !args.FolderOnly {
|
||||
queryPaged[1].Limit(pageSize + 1 - len(files))
|
||||
filesContinue, err := queryPaged[1].
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
nextStartWithFile = true
|
||||
files = append(files, filesContinue...)
|
||||
}
|
||||
|
||||
// More items to come
|
||||
nextTokenStr := ""
|
||||
if len(files) > pageSize {
|
||||
lastItem := files[len(files)-2]
|
||||
nextToken, err := getFileNextPageToken(f.hasher, lastItem, args, nextStartWithFile)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate next page token: %w", err)
|
||||
}
|
||||
|
||||
nextTokenStr = nextToken
|
||||
}
|
||||
|
||||
return lo.Subset(files, 0, uint(pageSize)), &PaginationResults{
|
||||
Page: currentPage,
|
||||
PageSize: pageSize,
|
||||
NextPageToken: nextTokenStr,
|
||||
IsCursor: true,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
// offsetPagination perform traditional pagination with minor optimizations.
|
||||
func (f *fileClient) offsetPagination(ctx context.Context, query *ent.FileQuery,
|
||||
args *ListFileParameters, paramMargin int) ([]*ent.File, *PaginationResults, error) {
|
||||
pageSize := capPageSize(f.maxSQlParam, args.PageSize, paramMargin)
|
||||
queryWithoutOrder := query.Clone()
|
||||
query.Order(getFileOrderOption(args)...)
|
||||
|
||||
// Count total items by type
|
||||
var v []struct {
|
||||
Type int `json:"type"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
err := queryWithoutOrder.Clone().
|
||||
GroupBy(file.FieldType).
|
||||
Aggregate(ent.Count()).
|
||||
Scan(ctx, &v)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
folderCount := 0
|
||||
fileCount := 0
|
||||
for _, item := range v {
|
||||
if item.Type == int(types.FileTypeFolder) {
|
||||
folderCount = item.Count
|
||||
} else {
|
||||
fileCount = item.Count
|
||||
}
|
||||
}
|
||||
|
||||
allFiles := make([]*ent.File, 0, pageSize)
|
||||
folderLimit := 0
|
||||
if (args.Page+1)*pageSize > folderCount {
|
||||
folderLimit = folderCount - args.Page*pageSize
|
||||
if folderLimit < 0 {
|
||||
folderLimit = 0
|
||||
}
|
||||
} else {
|
||||
folderLimit = pageSize
|
||||
}
|
||||
|
||||
if folderLimit <= pageSize && folderLimit > 0 {
|
||||
// Folder still remains
|
||||
folders, err := query.Clone().
|
||||
Limit(folderLimit).
|
||||
Offset(args.Page * pageSize).
|
||||
Where(file.TypeEQ(int(types.FileTypeFolder))).All(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
allFiles = append(allFiles, folders...)
|
||||
}
|
||||
|
||||
if folderLimit < pageSize {
|
||||
files, err := query.Clone().
|
||||
Limit(pageSize - folderLimit).
|
||||
Offset((args.Page * pageSize) + folderLimit - folderCount).
|
||||
Where(file.TypeEQ(int(types.FileTypeFile))).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
allFiles = append(allFiles, files...)
|
||||
}
|
||||
|
||||
return allFiles, &PaginationResults{
|
||||
TotalItems: folderCount + fileCount,
|
||||
Page: args.Page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func withFileEagerLoading(ctx context.Context, q *ent.FileQuery) *ent.FileQuery {
|
||||
if v, ok := ctx.Value(LoadFileEntity{}).(bool); ok && v {
|
||||
q.WithEntities(func(m *ent.EntityQuery) {
|
||||
m.Order(ent.Desc(entity.FieldID))
|
||||
withEntityEagerLoading(ctx, m)
|
||||
})
|
||||
}
|
||||
if v, ok := ctx.Value(LoadFileMetadata{}).(bool); ok && v {
|
||||
q.WithMetadata()
|
||||
}
|
||||
if v, ok := ctx.Value(LoadFilePublicMetadata{}).(bool); ok && v {
|
||||
q.WithMetadata(func(m *ent.MetadataQuery) {
|
||||
m.Where(metadata.IsPublic(true))
|
||||
})
|
||||
}
|
||||
if v, ok := ctx.Value(LoadFileShare{}).(bool); ok && v {
|
||||
q.WithShares()
|
||||
}
|
||||
if v, ok := ctx.Value(LoadFileUser{}).(bool); ok && v {
|
||||
q.WithOwner(func(query *ent.UserQuery) {
|
||||
withUserEagerLoading(ctx, query)
|
||||
})
|
||||
}
|
||||
if v, ok := ctx.Value(LoadFileDirectLink{}).(bool); ok && v {
|
||||
q.WithDirectLinks()
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func withEntityEagerLoading(ctx context.Context, q *ent.EntityQuery) *ent.EntityQuery {
|
||||
if v, ok := ctx.Value(LoadEntityUser{}).(bool); ok && v {
|
||||
q.WithUser()
|
||||
}
|
||||
|
||||
if v, ok := ctx.Value(LoadEntityStoragePolicy{}).(bool); ok && v {
|
||||
q.WithStoragePolicy()
|
||||
}
|
||||
|
||||
if v, ok := ctx.Value(LoadEntityFile{}).(bool); ok && v {
|
||||
q.WithFile(func(fq *ent.FileQuery) {
|
||||
withFileEagerLoading(ctx, fq)
|
||||
})
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func getFileOrderOption(args *ListFileParameters) []file.OrderOption {
|
||||
orderTerm := getOrderTerm(args.Order)
|
||||
switch args.OrderBy {
|
||||
case file.FieldName:
|
||||
return []file.OrderOption{file.ByName(orderTerm), file.ByID(orderTerm)}
|
||||
case file.FieldSize:
|
||||
return []file.OrderOption{file.BySize(orderTerm), file.ByID(orderTerm)}
|
||||
case file.FieldCreatedAt:
|
||||
return []file.OrderOption{file.ByCreatedAt(orderTerm), file.ByID(orderTerm)}
|
||||
case file.FieldUpdatedAt:
|
||||
return []file.OrderOption{file.ByUpdatedAt(orderTerm), file.ByID(orderTerm)}
|
||||
default:
|
||||
return []file.OrderOption{file.ByID(orderTerm)}
|
||||
}
|
||||
}
|
||||
|
||||
func getEntityOrderOption(args *ListEntityParameters) []entity.OrderOption {
|
||||
orderTerm := getOrderTerm(args.Order)
|
||||
switch args.OrderBy {
|
||||
case entity.FieldSize:
|
||||
return []entity.OrderOption{entity.BySize(orderTerm), entity.ByID(orderTerm)}
|
||||
case entity.FieldUpdatedAt:
|
||||
return []entity.OrderOption{entity.ByUpdatedAt(orderTerm), entity.ByID(orderTerm)}
|
||||
case entity.FieldReferenceCount:
|
||||
return []entity.OrderOption{entity.ByReferenceCount(orderTerm), entity.ByID(orderTerm)}
|
||||
default:
|
||||
return []entity.OrderOption{entity.ByID(orderTerm)}
|
||||
}
|
||||
}
|
||||
|
||||
var fileCursorQuery = map[string]map[bool]func(token *PageToken) predicate.File{
|
||||
file.FieldName: {
|
||||
true: func(token *PageToken) predicate.File {
|
||||
return file.Or(
|
||||
file.NameLT(token.String),
|
||||
file.And(file.Name(token.String), file.IDLT(token.ID)),
|
||||
)
|
||||
},
|
||||
false: func(token *PageToken) predicate.File {
|
||||
return file.Or(
|
||||
file.NameGT(token.String),
|
||||
file.And(file.Name(token.String), file.IDGT(token.ID)),
|
||||
)
|
||||
},
|
||||
},
|
||||
file.FieldSize: {
|
||||
true: func(token *PageToken) predicate.File {
|
||||
return file.Or(
|
||||
file.SizeLT(int64(token.Int)),
|
||||
file.And(file.Size(int64(token.Int)), file.IDLT(token.ID)),
|
||||
)
|
||||
},
|
||||
false: func(token *PageToken) predicate.File {
|
||||
return file.Or(
|
||||
file.SizeGT(int64(token.Int)),
|
||||
file.And(file.Size(int64(token.Int)), file.IDGT(token.ID)),
|
||||
)
|
||||
},
|
||||
},
|
||||
file.FieldCreatedAt: {
|
||||
true: func(token *PageToken) predicate.File {
|
||||
return file.Or(
|
||||
file.CreatedAtLT(*token.Time),
|
||||
file.And(file.CreatedAt(*token.Time), file.IDLT(token.ID)),
|
||||
)
|
||||
},
|
||||
false: func(token *PageToken) predicate.File {
|
||||
return file.Or(
|
||||
file.CreatedAtGT(*token.Time),
|
||||
file.And(file.CreatedAt(*token.Time), file.IDGT(token.ID)),
|
||||
)
|
||||
},
|
||||
},
|
||||
file.FieldUpdatedAt: {
|
||||
true: func(token *PageToken) predicate.File {
|
||||
return file.Or(
|
||||
file.UpdatedAtLT(*token.Time),
|
||||
file.And(file.UpdatedAt(*token.Time), file.IDLT(token.ID)),
|
||||
)
|
||||
},
|
||||
false: func(token *PageToken) predicate.File {
|
||||
return file.Or(
|
||||
file.UpdatedAtGT(*token.Time),
|
||||
file.And(file.UpdatedAt(*token.Time), file.IDGT(token.ID)),
|
||||
)
|
||||
},
|
||||
},
|
||||
file.FieldID: {
|
||||
true: func(token *PageToken) predicate.File {
|
||||
return file.IDLT(token.ID)
|
||||
},
|
||||
false: func(token *PageToken) predicate.File {
|
||||
return file.IDGT(token.ID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func getFileCursorQuery(args *ListFileParameters, token *PageToken, query []*ent.FileQuery) []*ent.FileQuery {
|
||||
o := &sql.OrderTermOptions{}
|
||||
getOrderTerm(args.Order)(o)
|
||||
|
||||
predicates, ok := fileCursorQuery[args.OrderBy]
|
||||
if !ok {
|
||||
predicates = fileCursorQuery[file.FieldID]
|
||||
}
|
||||
|
||||
// If all folder is already listed in previous page, only query for files.
|
||||
if token != nil && token.StartWithFile && !args.MixedType {
|
||||
query = query[1:2]
|
||||
}
|
||||
|
||||
// Mixing folders and files with one query
|
||||
if args.MixedType {
|
||||
query = query[2:]
|
||||
} else if args.FolderOnly {
|
||||
query = query[0:1]
|
||||
}
|
||||
|
||||
if token != nil {
|
||||
query[0].Where(predicates[o.Desc](token))
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// getFileNextPageToken returns the next page token for the given last file.
|
||||
func getFileNextPageToken(hasher hashid.Encoder, last *ent.File, args *ListFileParameters, nextStartWithFile bool) (string, error) {
|
||||
token := &PageToken{
|
||||
ID: last.ID,
|
||||
StartWithFile: nextStartWithFile,
|
||||
}
|
||||
|
||||
switch args.OrderBy {
|
||||
case file.FieldName:
|
||||
token.String = last.Name
|
||||
case file.FieldCreatedAt:
|
||||
token.Time = &last.CreatedAt
|
||||
case file.FieldSize:
|
||||
token.Int = int(last.Size)
|
||||
case file.FieldUpdatedAt:
|
||||
token.Time = &last.UpdatedAt
|
||||
}
|
||||
|
||||
return token.Encode(hasher, hashid.EncodeFileID)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/fsevent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/schema"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type FsEventClient interface {
|
||||
TxOperator
|
||||
// Create a new FsEvent
|
||||
Create(ctx context.Context, uid int, subscriberId uuid.UUID, events ...string) error
|
||||
// Delete all FsEvents by subscriber
|
||||
DeleteBySubscriber(ctx context.Context, subscriberId uuid.UUID) error
|
||||
// Delete all FsEvents
|
||||
DeleteAll(ctx context.Context) error
|
||||
// Get all FsEvents by subscriber and user
|
||||
TakeBySubscriber(ctx context.Context, subscriberId uuid.UUID, userId int) ([]*ent.FsEvent, error)
|
||||
}
|
||||
|
||||
func NewFsEventClient(client *ent.Client, dbType conf.DBType) FsEventClient {
|
||||
return &fsEventClient{client: client, maxSQlParam: sqlParamLimit(dbType)}
|
||||
}
|
||||
|
||||
type fsEventClient struct {
|
||||
maxSQlParam int
|
||||
client *ent.Client
|
||||
}
|
||||
|
||||
func (c *fsEventClient) SetClient(newClient *ent.Client) TxOperator {
|
||||
return &fsEventClient{client: newClient, maxSQlParam: c.maxSQlParam}
|
||||
}
|
||||
|
||||
func (c *fsEventClient) GetClient() *ent.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *fsEventClient) Create(ctx context.Context, uid int, subscriberId uuid.UUID, events ...string) error {
|
||||
stms := lo.Map(events, func(event string, index int) *ent.FsEventCreate {
|
||||
res := c.client.FsEvent.
|
||||
Create().
|
||||
SetUserFsevent(uid).
|
||||
SetEvent(event).
|
||||
SetSubscriber(subscriberId).SetEvent(event)
|
||||
|
||||
return res
|
||||
})
|
||||
|
||||
_, err := c.client.FsEvent.CreateBulk(stms...).Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *fsEventClient) DeleteBySubscriber(ctx context.Context, subscriberId uuid.UUID) error {
|
||||
_, err := c.client.FsEvent.Delete().Where(fsevent.Subscriber(subscriberId)).Exec(schema.SkipSoftDelete(ctx))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *fsEventClient) DeleteAll(ctx context.Context) error {
|
||||
_, err := c.client.FsEvent.Delete().Exec(schema.SkipSoftDelete(ctx))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *fsEventClient) TakeBySubscriber(ctx context.Context, subscriberId uuid.UUID, userId int) ([]*ent.FsEvent, error) {
|
||||
res, err := c.client.FsEvent.Query().Where(fsevent.Subscriber(subscriberId), fsevent.UserFsevent(userId)).All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete the FsEvents
|
||||
_, err = c.client.FsEvent.Delete().Where(fsevent.Subscriber(subscriberId), fsevent.UserFsevent(userId)).Exec(schema.SkipSoftDelete(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/group"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
)
|
||||
|
||||
type (
|
||||
// Ctx keys for eager loading options.
|
||||
LoadGroupPolicy struct{}
|
||||
)
|
||||
|
||||
const (
|
||||
AnonymousGroupID = 3
|
||||
)
|
||||
|
||||
type (
|
||||
GroupClient interface {
|
||||
TxOperator
|
||||
// AnonymousGroup returns the anonymous group.
|
||||
AnonymousGroup(ctx context.Context) (*ent.Group, error)
|
||||
// ListAll returns all groups.
|
||||
ListAll(ctx context.Context) ([]*ent.Group, error)
|
||||
// GetByID returns the group by id.
|
||||
GetByID(ctx context.Context, id int) (*ent.Group, error)
|
||||
// ListGroups returns a list of groups with pagination.
|
||||
ListGroups(ctx context.Context, args *ListGroupParameters) (*ListGroupResult, error)
|
||||
// CountUsers returns the number of users in the group.
|
||||
CountUsers(ctx context.Context, id int) (int, error)
|
||||
// Upsert upserts a group.
|
||||
Upsert(ctx context.Context, group *ent.Group) (*ent.Group, error)
|
||||
// Delete deletes a group.
|
||||
Delete(ctx context.Context, id int) error
|
||||
}
|
||||
ListGroupParameters struct {
|
||||
*PaginationArgs
|
||||
}
|
||||
ListGroupResult struct {
|
||||
*PaginationResults
|
||||
Groups []*ent.Group
|
||||
}
|
||||
)
|
||||
|
||||
func NewGroupClient(client *ent.Client, dbType conf.DBType, cache cache.Driver) GroupClient {
|
||||
return &groupClient{client: client, maxSQlParam: sqlParamLimit(dbType), cache: cache}
|
||||
}
|
||||
|
||||
type groupClient struct {
|
||||
client *ent.Client
|
||||
cache cache.Driver
|
||||
maxSQlParam int
|
||||
}
|
||||
|
||||
func (c *groupClient) SetClient(newClient *ent.Client) TxOperator {
|
||||
return &groupClient{client: newClient, maxSQlParam: c.maxSQlParam, cache: c.cache}
|
||||
}
|
||||
|
||||
func (c *groupClient) GetClient() *ent.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *groupClient) CountUsers(ctx context.Context, id int) (int, error) {
|
||||
return c.client.Group.Query().Where(group.ID(id)).QueryUsers().Count(ctx)
|
||||
}
|
||||
|
||||
func (c *groupClient) AnonymousGroup(ctx context.Context) (*ent.Group, error) {
|
||||
return withGroupEagerLoading(ctx, c.client.Group.Query().Where(group.ID(AnonymousGroupID))).First(ctx)
|
||||
}
|
||||
|
||||
func (c *groupClient) ListAll(ctx context.Context) ([]*ent.Group, error) {
|
||||
return withGroupEagerLoading(ctx, c.client.Group.Query()).All(ctx)
|
||||
}
|
||||
|
||||
func (c *groupClient) Upsert(ctx context.Context, group *ent.Group) (*ent.Group, error) {
|
||||
if group.ID == 0 {
|
||||
stm := c.client.Group.Create().
|
||||
SetName(group.Name).
|
||||
SetMaxStorage(group.MaxStorage).
|
||||
SetSpeedLimit(group.SpeedLimit).
|
||||
SetPermissions(group.Permissions).
|
||||
SetSettings(group.Settings)
|
||||
|
||||
if group.Edges.StoragePolicies != nil && group.Edges.StoragePolicies.ID > 0 {
|
||||
stm.SetStoragePolicyID(group.Edges.StoragePolicies.ID)
|
||||
}
|
||||
|
||||
return stm.Save(ctx)
|
||||
}
|
||||
|
||||
stm := c.client.Group.UpdateOne(group).
|
||||
SetName(group.Name).
|
||||
SetMaxStorage(group.MaxStorage).
|
||||
SetSpeedLimit(group.SpeedLimit).
|
||||
SetPermissions(group.Permissions).
|
||||
SetSettings(group.Settings).
|
||||
ClearStoragePolicies()
|
||||
|
||||
if group.Edges.StoragePolicies != nil && group.Edges.StoragePolicies.ID > 0 {
|
||||
stm.SetStoragePolicyID(group.Edges.StoragePolicies.ID)
|
||||
}
|
||||
|
||||
res, err := stm.Save(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (c *groupClient) Delete(ctx context.Context, id int) error {
|
||||
if err := c.client.Group.DeleteOneID(id).Exec(ctx); err != nil {
|
||||
return fmt.Errorf("failed to delete group: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *groupClient) ListGroups(ctx context.Context, args *ListGroupParameters) (*ListGroupResult, error) {
|
||||
query := withGroupEagerLoading(ctx, c.client.Group.Query())
|
||||
pageSize := capPageSize(c.maxSQlParam, args.PageSize, 10)
|
||||
queryWithoutOrder := query.Clone()
|
||||
query.Order(getGroupOrderOption(args)...)
|
||||
|
||||
// Count total items
|
||||
total, err := queryWithoutOrder.Clone().
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups, err := query.Clone().
|
||||
Limit(pageSize).
|
||||
Offset(args.Page * pageSize).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListGroupResult{
|
||||
Groups: groups,
|
||||
PaginationResults: &PaginationResults{
|
||||
TotalItems: total,
|
||||
Page: args.Page,
|
||||
PageSize: pageSize,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *groupClient) GetByID(ctx context.Context, id int) (*ent.Group, error) {
|
||||
return withGroupEagerLoading(ctx, c.client.Group.Query().Where(group.ID(id))).First(ctx)
|
||||
}
|
||||
|
||||
func getGroupOrderOption(args *ListGroupParameters) []group.OrderOption {
|
||||
orderTerm := getOrderTerm(args.Order)
|
||||
switch args.OrderBy {
|
||||
case group.FieldName:
|
||||
return []group.OrderOption{group.ByName(orderTerm), group.ByID(orderTerm)}
|
||||
case group.FieldMaxStorage:
|
||||
return []group.OrderOption{group.ByMaxStorage(orderTerm), group.ByID(orderTerm)}
|
||||
default:
|
||||
return []group.OrderOption{group.ByID(orderTerm)}
|
||||
}
|
||||
}
|
||||
|
||||
func withGroupEagerLoading(ctx context.Context, q *ent.GroupQuery) *ent.GroupQuery {
|
||||
if _, ok := ctx.Value(LoadGroupPolicy{}).(bool); ok {
|
||||
q.WithStoragePolicies(func(spq *ent.StoragePolicyQuery) {
|
||||
withStoragePolicyEagerLoading(ctx, spq)
|
||||
})
|
||||
}
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/cloudreve/Cloudreve/v4/application/constants"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/group"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/node"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/oauthclient"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/setting"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/debug"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// needMigration exams if required schema version is satisfied.
|
||||
func needMigration(client *ent.Client, ctx context.Context, requiredDbVersion string) bool {
|
||||
c, _ := client.Setting.Query().Where(setting.NameEQ(DBVersionPrefix + requiredDbVersion)).Count(ctx)
|
||||
return c == 0
|
||||
}
|
||||
|
||||
func migrate(l logging.Logger, client *ent.Client, ctx context.Context, kv cache.Driver, requiredDbVersion string) error {
|
||||
l.Info("Start initializing database schema...")
|
||||
l.Info("Creating basic table schema...")
|
||||
if err := client.Schema.Create(ctx); err != nil {
|
||||
return fmt.Errorf("Failed creating schema resources: %w", err)
|
||||
}
|
||||
|
||||
migrateDefaultSettings(l, client, ctx, kv)
|
||||
|
||||
if err := migrateDefaultStoragePolicy(l, client, ctx); err != nil {
|
||||
return fmt.Errorf("failed migrating default storage policy: %w", err)
|
||||
}
|
||||
|
||||
if err := migrateSysGroups(l, client, ctx); err != nil {
|
||||
return fmt.Errorf("failed migrating default storage policy: %w", err)
|
||||
}
|
||||
|
||||
if err := migrateOAuthClient(l, client, ctx); err != nil {
|
||||
return fmt.Errorf("failed migrating OAuth client: %w", err)
|
||||
}
|
||||
|
||||
if err := applyPatches(l, client, ctx, requiredDbVersion); err != nil {
|
||||
return fmt.Errorf("failed applying schema patches: %w", err)
|
||||
}
|
||||
|
||||
client.Setting.Create().SetName(DBVersionPrefix + requiredDbVersion).SetValue("installed").Save(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateDefaultSettings(l logging.Logger, client *ent.Client, ctx context.Context, kv cache.Driver) {
|
||||
// clean kv cache
|
||||
if err := kv.DeleteAll(); err != nil {
|
||||
l.Warning("Failed to remove all KV entries while schema migration: %s", err)
|
||||
}
|
||||
|
||||
// List existing settings into a map
|
||||
existingSettings := make(map[string]struct{})
|
||||
settings, err := client.Setting.Query().All(ctx)
|
||||
if err != nil {
|
||||
l.Warning("Failed to query existing settings: %s", err)
|
||||
}
|
||||
|
||||
for _, s := range settings {
|
||||
existingSettings[s.Name] = struct{}{}
|
||||
}
|
||||
|
||||
l.Info("Insert default settings...")
|
||||
for k, v := range DefaultSettings {
|
||||
if _, ok := existingSettings[k]; ok {
|
||||
l.Debug("Skip inserting setting %s, already exists.", k)
|
||||
continue
|
||||
}
|
||||
|
||||
if override, ok := os.LookupEnv(EnvDefaultOverwritePrefix + k); ok {
|
||||
l.Info("Override default setting %q with env value %q", k, override)
|
||||
v = override
|
||||
}
|
||||
|
||||
client.Setting.Create().SetName(k).SetValue(v).SaveX(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateDefaultStoragePolicy(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
if _, err := client.StoragePolicy.Query().Where(storagepolicy.ID(1)).First(ctx); err == nil {
|
||||
l.Info("Default storage policy (ID=1) already exists, skip migrating.")
|
||||
return nil
|
||||
}
|
||||
|
||||
l.Info("Insert default storage policy...")
|
||||
if _, err := client.StoragePolicy.Create().
|
||||
SetName("Default storage policy").
|
||||
SetType(types.PolicyTypeLocal).
|
||||
SetDirNameRule(util.DataPath("uploads/{uid}/{path}")).
|
||||
SetFileNameRule("{uid}_{randomkey8}_{originname}").
|
||||
SetSettings(&types.PolicySetting{
|
||||
ChunkSize: 25 << 20, // 25MB
|
||||
PreAllocate: true,
|
||||
}).
|
||||
Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to create default storage policy: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateSysGroups(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
if err := migrateAdminGroup(l, client, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateUserGroup(l, client, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateAnonymousGroup(l, client, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateMasterNode(l, client, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateAdminGroup(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
if _, err := client.Group.Query().Where(group.ID(1)).First(ctx); err == nil {
|
||||
l.Info("Default admin group (ID=1) already exists, skip migrating.")
|
||||
return nil
|
||||
}
|
||||
|
||||
l.Info("Insert default admin group...")
|
||||
permissions := &boolset.BooleanSet{}
|
||||
boolset.Sets(map[types.GroupPermission]bool{
|
||||
types.GroupPermissionIsAdmin: true,
|
||||
types.GroupPermissionShare: true,
|
||||
types.GroupPermissionWebDAV: true,
|
||||
types.GroupPermissionWebDAVProxy: true,
|
||||
types.GroupPermissionArchiveDownload: true,
|
||||
types.GroupPermissionArchiveTask: true,
|
||||
types.GroupPermissionShareDownload: true,
|
||||
types.GroupPermissionRemoteDownload: true,
|
||||
types.GroupPermissionRedirectedSource: true,
|
||||
types.GroupPermissionAdvanceDelete: true,
|
||||
types.GroupPermissionIgnoreFileOwnership: true,
|
||||
// TODO: review default permission
|
||||
}, permissions)
|
||||
if _, err := client.Group.Create().
|
||||
SetName("Admin").
|
||||
SetStoragePoliciesID(1).
|
||||
SetMaxStorage(1 * constants.TB). // 1 TB default storage
|
||||
SetPermissions(permissions).
|
||||
SetSettings(&types.GroupSetting{
|
||||
SourceBatchSize: 1000,
|
||||
Aria2BatchSize: 50,
|
||||
MaxWalkedFiles: 100000,
|
||||
TrashRetention: 7 * 24 * 3600,
|
||||
RedirectedSource: true,
|
||||
}).
|
||||
Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to create default admin group: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateUserGroup(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
if _, err := client.Group.Query().Where(group.ID(2)).First(ctx); err == nil {
|
||||
l.Info("Default user group (ID=2) already exists, skip migrating.")
|
||||
return nil
|
||||
}
|
||||
|
||||
l.Info("Insert default user group...")
|
||||
permissions := &boolset.BooleanSet{}
|
||||
boolset.Sets(map[types.GroupPermission]bool{
|
||||
types.GroupPermissionShare: true,
|
||||
types.GroupPermissionShareDownload: true,
|
||||
types.GroupPermissionRedirectedSource: true,
|
||||
}, permissions)
|
||||
if _, err := client.Group.Create().
|
||||
SetName("User").
|
||||
SetStoragePoliciesID(1).
|
||||
SetMaxStorage(1 * constants.GB). // 1 GB default storage
|
||||
SetPermissions(permissions).
|
||||
SetSettings(&types.GroupSetting{
|
||||
SourceBatchSize: 10,
|
||||
Aria2BatchSize: 1,
|
||||
MaxWalkedFiles: 100000,
|
||||
TrashRetention: 7 * 24 * 3600,
|
||||
RedirectedSource: true,
|
||||
}).
|
||||
Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to create default user group: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateAnonymousGroup(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
if _, err := client.Group.Query().Where(group.ID(AnonymousGroupID)).First(ctx); err == nil {
|
||||
l.Info("Default anonymous group (ID=3) already exists, skip migrating.")
|
||||
return nil
|
||||
}
|
||||
|
||||
l.Info("Insert default anonymous group...")
|
||||
permissions := &boolset.BooleanSet{}
|
||||
boolset.Sets(map[types.GroupPermission]bool{
|
||||
types.GroupPermissionIsAnonymous: true,
|
||||
types.GroupPermissionShareDownload: true,
|
||||
}, permissions)
|
||||
if _, err := client.Group.Create().
|
||||
SetName("Anonymous").
|
||||
SetPermissions(permissions).
|
||||
SetSettings(&types.GroupSetting{
|
||||
MaxWalkedFiles: 100000,
|
||||
RedirectedSource: true,
|
||||
}).
|
||||
Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to create default anonymous group: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateMasterNode(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
if _, err := client.Node.Query().Where(node.TypeEQ(node.TypeMaster)).First(ctx); err == nil {
|
||||
l.Info("Default master node already exists, skip migrating.")
|
||||
return nil
|
||||
}
|
||||
|
||||
capabilities := &boolset.BooleanSet{}
|
||||
boolset.Sets(map[types.NodeCapability]bool{
|
||||
types.NodeCapabilityCreateArchive: true,
|
||||
types.NodeCapabilityExtractArchive: true,
|
||||
types.NodeCapabilityRemoteDownload: true,
|
||||
}, capabilities)
|
||||
|
||||
stm := client.Node.Create().
|
||||
SetType(node.TypeMaster).
|
||||
SetCapabilities(capabilities).
|
||||
SetName("Master").
|
||||
SetSettings(&types.NodeSetting{
|
||||
Provider: types.DownloaderProviderAria2,
|
||||
}).
|
||||
SetStatus(node.StatusActive)
|
||||
|
||||
_, enableAria2 := os.LookupEnv(EnvEnableAria2)
|
||||
if enableAria2 {
|
||||
l.Info("Aria2 is override as enabled.")
|
||||
stm.SetSettings(&types.NodeSetting{
|
||||
Provider: types.DownloaderProviderAria2,
|
||||
Aria2Setting: &types.Aria2Setting{
|
||||
Server: "http://127.0.0.1:6800/jsonrpc",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
l.Info("Insert default master node...")
|
||||
if _, err := stm.Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to create default master node: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
OAuthClientDesktopGUID = "393a1839-f52e-498e-9972-e77cc2241eee"
|
||||
OAuthClientDesktopSecret = "8GaQIu3lOSdqYoDHi9cR8IZ4pvuMH8ya"
|
||||
OAuthClientDesktopName = "application:oauth.desktop"
|
||||
OAuthClientDesktopRedirectURI = "/callback/desktop"
|
||||
OAuthClientiOSGUID = "220db97a-44a3-44f7-99b6-d767262b4daa"
|
||||
OAuthClientiOSSecret = "1kxOW4IyVOkPlsKCnTwzfHyP8XrbpfaF"
|
||||
OAuthClientiOSName = "application:setting.iOSApp"
|
||||
OAuthClientiOSRedirectURI = "/callback/ios"
|
||||
)
|
||||
|
||||
func migrateOAuthClient(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
if err := migrateOAuthClientDesktop(l, client, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateOAuthClientiOS(l, client, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateOAuthClientiOS(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
if _, err := client.OAuthClient.Query().Where(oauthclient.GUID(OAuthClientiOSGUID)).First(ctx); err == nil {
|
||||
l.Info("Default OAuth client (GUID=%s) already exists, skip migrating.", OAuthClientiOSGUID)
|
||||
return nil
|
||||
}
|
||||
if _, err := client.OAuthClient.Create().
|
||||
SetGUID(OAuthClientiOSGUID).
|
||||
SetSecret(OAuthClientiOSSecret).
|
||||
SetName(OAuthClientiOSName).
|
||||
SetRedirectUris([]string{OAuthClientiOSRedirectURI}).
|
||||
SetScopes([]string{"profile", "email", "openid", "offline_access", "UserInfo.Write", "UserSecurityInfo.Write", "Workflow.Write", "Files.Write", "Shares.Write", "Finance.Write", "DavAccount.Write"}).
|
||||
SetProps(&types.OAuthClientProps{Icon: "/static/img/cloudreve_ios.svg", RefreshTokenTTL: 7776000}).
|
||||
SetIsEnabled(true).
|
||||
Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to create default OAuth client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateOAuthClientDesktop(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
if _, err := client.OAuthClient.Query().Where(oauthclient.GUID(OAuthClientDesktopGUID)).First(ctx); err == nil {
|
||||
l.Info("Default OAuth client (GUID=%s) already exists, skip migrating.", OAuthClientDesktopGUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := client.OAuthClient.Create().
|
||||
SetGUID(OAuthClientDesktopGUID).
|
||||
SetSecret(OAuthClientDesktopSecret).
|
||||
SetName(OAuthClientDesktopName).
|
||||
SetRedirectUris([]string{OAuthClientDesktopRedirectURI}).
|
||||
SetScopes([]string{"profile", "email", "openid", "offline_access", "UserInfo.Write", "Workflow.Write", "Files.Write", "Shares.Write"}).
|
||||
SetProps(&types.OAuthClientProps{Icon: "/static/img/cloudreve.svg", RefreshTokenTTL: 7776000}).
|
||||
SetIsEnabled(true).
|
||||
Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to create default OAuth client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
PatchFunc func(l logging.Logger, client *ent.Client, ctx context.Context) error
|
||||
Patch struct {
|
||||
Name string
|
||||
EndVersion string
|
||||
Func PatchFunc
|
||||
}
|
||||
)
|
||||
|
||||
var patches = []Patch{
|
||||
{
|
||||
Name: "apply_default_archive_viewer",
|
||||
EndVersion: "4.7.0",
|
||||
Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
fileViewersSetting, err := client.Setting.Query().Where(setting.Name("file_viewers")).First(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query file_viewers setting: %w", err)
|
||||
}
|
||||
|
||||
var fileViewers []types.ViewerGroup
|
||||
if err := json.Unmarshal([]byte(fileViewersSetting.Value), &fileViewers); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal file_viewers setting: %w", err)
|
||||
}
|
||||
|
||||
fileViewerExisted := false
|
||||
for _, viewer := range fileViewers[0].Viewers {
|
||||
if viewer.ID == "archive" {
|
||||
fileViewerExisted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2.2 If not existed, add it
|
||||
if !fileViewerExisted {
|
||||
// Found existing archive viewer default setting
|
||||
var defaultArchiveViewer types.Viewer
|
||||
for _, viewer := range defaultFileViewers[0].Viewers {
|
||||
if viewer.ID == "archive" {
|
||||
defaultArchiveViewer = viewer
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fileViewers[0].Viewers = append(fileViewers[0].Viewers, defaultArchiveViewer)
|
||||
newFileViewersSetting, err := json.Marshal(fileViewers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal file_viewers setting: %w", err)
|
||||
}
|
||||
|
||||
if _, err := client.Setting.UpdateOne(fileViewersSetting).SetValue(string(newFileViewersSetting)).Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to update file_viewers setting: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "apply_default_excalidraw_viewer",
|
||||
EndVersion: "4.1.0",
|
||||
Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
// 1. Apply excalidraw file icons
|
||||
// 1.1 Check if it's already applied
|
||||
iconSetting, err := client.Setting.Query().Where(setting.Name("explorer_icons")).First(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query explorer_icons setting: %w", err)
|
||||
}
|
||||
|
||||
var icons []types.FileTypeIconSetting
|
||||
if err := json.Unmarshal([]byte(iconSetting.Value), &icons); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal explorer_icons setting: %w", err)
|
||||
}
|
||||
|
||||
iconExisted := false
|
||||
for _, icon := range icons {
|
||||
if lo.Contains(icon.Exts, "excalidraw") {
|
||||
iconExisted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 1.2 If not existed, add it
|
||||
if !iconExisted {
|
||||
// Found existing excalidraw icon default setting
|
||||
var defaultExcalidrawIcon types.FileTypeIconSetting
|
||||
for _, icon := range defaultIcons {
|
||||
if lo.Contains(icon.Exts, "excalidraw") {
|
||||
defaultExcalidrawIcon = icon
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
icons = append(icons, defaultExcalidrawIcon)
|
||||
newIconSetting, err := json.Marshal(icons)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal explorer_icons setting: %w", err)
|
||||
}
|
||||
|
||||
if _, err := client.Setting.UpdateOne(iconSetting).SetValue(string(newIconSetting)).Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to update explorer_icons setting: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Apply default file viewers
|
||||
// 2.1 Check if it's already applied
|
||||
fileViewersSetting, err := client.Setting.Query().Where(setting.Name("file_viewers")).First(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query file_viewers setting: %w", err)
|
||||
}
|
||||
|
||||
var fileViewers []types.ViewerGroup
|
||||
if err := json.Unmarshal([]byte(fileViewersSetting.Value), &fileViewers); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal file_viewers setting: %w", err)
|
||||
}
|
||||
|
||||
fileViewerExisted := false
|
||||
for _, viewer := range fileViewers[0].Viewers {
|
||||
if viewer.ID == "excalidraw" {
|
||||
fileViewerExisted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2.2 If not existed, add it
|
||||
if !fileViewerExisted {
|
||||
// Found existing excalidraw viewer default setting
|
||||
var defaultExcalidrawViewer types.Viewer
|
||||
for _, viewer := range defaultFileViewers[0].Viewers {
|
||||
if viewer.ID == "excalidraw" {
|
||||
defaultExcalidrawViewer = viewer
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fileViewers[0].Viewers = append(fileViewers[0].Viewers, defaultExcalidrawViewer)
|
||||
newFileViewersSetting, err := json.Marshal(fileViewers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal file_viewers setting: %w", err)
|
||||
}
|
||||
|
||||
if _, err := client.Setting.UpdateOne(fileViewersSetting).SetValue(string(newFileViewersSetting)).Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to update file_viewers setting: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "apply_email_title_magic_var",
|
||||
EndVersion: "4.7.0",
|
||||
Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
// 1. Activate Template
|
||||
mailActivationTemplateSetting, err := client.Setting.Query().Where(setting.Name("mail_activation_template")).First(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query mail_activation_template setting: %w", err)
|
||||
}
|
||||
|
||||
var mailActivationTemplate []struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(mailActivationTemplateSetting.Value), &mailActivationTemplate); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal mail_activation_template setting: %w", err)
|
||||
}
|
||||
|
||||
for i, t := range mailActivationTemplate {
|
||||
mailActivationTemplate[i].Title = fmt.Sprintf("[{{ .CommonContext.SiteBasic.Name }}] %s", t.Title)
|
||||
}
|
||||
|
||||
newMailActivationTemplate, err := json.Marshal(mailActivationTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal mail_activation_template setting: %w", err)
|
||||
}
|
||||
|
||||
if _, err := client.Setting.UpdateOne(mailActivationTemplateSetting).SetValue(string(newMailActivationTemplate)).Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to update mail_activation_template setting: %w", err)
|
||||
}
|
||||
|
||||
// 2. Reset Password Template
|
||||
mailResetTemplateSetting, err := client.Setting.Query().Where(setting.Name("mail_reset_template")).First(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query mail_reset_template setting: %w", err)
|
||||
}
|
||||
|
||||
var mailResetTemplate []struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(mailResetTemplateSetting.Value), &mailResetTemplate); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal mail_reset_template setting: %w", err)
|
||||
}
|
||||
|
||||
for i, t := range mailResetTemplate {
|
||||
mailResetTemplate[i].Title = fmt.Sprintf("[{{ .CommonContext.SiteBasic.Name }}] %s", t.Title)
|
||||
}
|
||||
|
||||
newMailResetTemplate, err := json.Marshal(mailResetTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal mail_reset_template setting: %w", err)
|
||||
}
|
||||
|
||||
if _, err := client.Setting.UpdateOne(mailResetTemplateSetting).SetValue(string(newMailResetTemplate)).Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to update mail_reset_template setting: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "apply_thumb_path_magic_var",
|
||||
EndVersion: "4.10.0",
|
||||
Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
thumbSuffixSetting, err := client.Setting.Query().Where(setting.Name("thumb_entity_suffix")).First(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query thumb_entity_suffix setting: %w", err)
|
||||
}
|
||||
|
||||
newThumbSuffix := fmt.Sprintf("{blob_path}/{blob_name}%s", thumbSuffixSetting.Value)
|
||||
if _, err := client.Setting.UpdateOne(thumbSuffixSetting).SetValue(newThumbSuffix).Save(ctx); err != nil {
|
||||
return fmt.Errorf("failed to update thumb_entity_suffix setting: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "reset_secret_key",
|
||||
EndVersion: "4.13.0",
|
||||
Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error {
|
||||
newSecretKey := util.RandStringRunesCrypto(256)
|
||||
ctx = context.WithValue(ctx, debug.SkipDbLogging{}, true)
|
||||
if err := client.Setting.Update().Where(setting.Name("secret_key")).SetValue(newSecretKey).Exec(ctx); err != nil {
|
||||
return fmt.Errorf("failed to update secret_key setting: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func applyPatches(l logging.Logger, client *ent.Client, ctx context.Context, requiredDbVersion string) error {
|
||||
allVersionMarks, err := client.Setting.Query().Where(setting.NameHasPrefix(DBVersionPrefix)).All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requiredDbVersion = strings.TrimSuffix(requiredDbVersion, "-pro")
|
||||
|
||||
// Find the latest applied version
|
||||
var latestAppliedVersion *semver.Version
|
||||
for _, v := range allVersionMarks {
|
||||
v.Name = strings.TrimSuffix(v.Name, "-pro")
|
||||
version, err := semver.NewVersion(strings.TrimPrefix(v.Name, DBVersionPrefix))
|
||||
if err != nil {
|
||||
l.Warning("Failed to parse past version %s: %s", v.Name, err)
|
||||
continue
|
||||
}
|
||||
if latestAppliedVersion == nil || version.Compare(latestAppliedVersion) > 0 {
|
||||
latestAppliedVersion = version
|
||||
}
|
||||
}
|
||||
|
||||
requiredVersion, err := semver.NewVersion(requiredDbVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse required version %s: %w", requiredDbVersion, err)
|
||||
}
|
||||
|
||||
if latestAppliedVersion == nil || requiredVersion.Compare(requiredVersion) > 0 {
|
||||
latestAppliedVersion = requiredVersion
|
||||
}
|
||||
|
||||
for _, patch := range patches {
|
||||
if latestAppliedVersion.Compare(semver.MustParse(patch.EndVersion)) < 0 {
|
||||
l.Info("Applying schema patch %s...", patch.Name)
|
||||
if err := patch.Func(l, client, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/node"
|
||||
)
|
||||
|
||||
type (
|
||||
LoadNodeStoragePolicy struct{}
|
||||
NodeClient interface {
|
||||
TxOperator
|
||||
// ListActiveNodes returns the active nodes.
|
||||
ListActiveNodes(ctx context.Context, subset []int) ([]*ent.Node, error)
|
||||
// ListNodes returns the nodes with pagination.
|
||||
ListNodes(ctx context.Context, args *ListNodeParameters) (*ListNodeResult, error)
|
||||
// GetNodeById returns the node by id.
|
||||
GetNodeById(ctx context.Context, id int) (*ent.Node, error)
|
||||
// GetNodeByIds returns the nodes by ids.
|
||||
GetNodeByIds(ctx context.Context, ids []int) ([]*ent.Node, error)
|
||||
// Upsert upserts a node.
|
||||
Upsert(ctx context.Context, n *ent.Node) (*ent.Node, error)
|
||||
// Delete deletes a node.
|
||||
Delete(ctx context.Context, id int) error
|
||||
}
|
||||
ListNodeParameters struct {
|
||||
*PaginationArgs
|
||||
Status node.Status
|
||||
}
|
||||
ListNodeResult struct {
|
||||
*PaginationResults
|
||||
Nodes []*ent.Node
|
||||
}
|
||||
)
|
||||
|
||||
func NewNodeClient(client *ent.Client) NodeClient {
|
||||
return &nodeClient{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
type nodeClient struct {
|
||||
client *ent.Client
|
||||
}
|
||||
|
||||
func (c *nodeClient) SetClient(newClient *ent.Client) TxOperator {
|
||||
return &nodeClient{client: newClient}
|
||||
}
|
||||
|
||||
func (c *nodeClient) GetClient() *ent.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *nodeClient) ListActiveNodes(ctx context.Context, subset []int) ([]*ent.Node, error) {
|
||||
stm := c.client.Node.Query().Where(node.StatusEQ(node.StatusActive))
|
||||
if len(subset) > 0 {
|
||||
stm = stm.Where(node.IDIn(subset...))
|
||||
}
|
||||
return stm.All(ctx)
|
||||
}
|
||||
|
||||
func (c *nodeClient) GetNodeByIds(ctx context.Context, ids []int) ([]*ent.Node, error) {
|
||||
return withNodeEagerLoading(ctx, c.client.Node.Query().Where(node.IDIn(ids...))).All(ctx)
|
||||
}
|
||||
|
||||
func (c *nodeClient) GetNodeById(ctx context.Context, id int) (*ent.Node, error) {
|
||||
return withNodeEagerLoading(ctx, c.client.Node.Query().Where(node.IDEQ(id))).First(ctx)
|
||||
}
|
||||
|
||||
func (c *nodeClient) Delete(ctx context.Context, id int) error {
|
||||
return c.client.Node.DeleteOneID(id).Exec(ctx)
|
||||
}
|
||||
|
||||
func (c *nodeClient) ListNodes(ctx context.Context, args *ListNodeParameters) (*ListNodeResult, error) {
|
||||
query := c.client.Node.Query()
|
||||
if string(args.Status) != "" {
|
||||
query = query.Where(node.StatusEQ(args.Status))
|
||||
}
|
||||
query.Order(getNodeOrderOption(args)...)
|
||||
|
||||
// Count total items
|
||||
total, err := query.Clone().
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes, err := withNodeEagerLoading(ctx, query).Limit(args.PageSize).Offset(args.Page * args.PageSize).All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListNodeResult{
|
||||
PaginationResults: &PaginationResults{
|
||||
TotalItems: total,
|
||||
Page: args.Page,
|
||||
PageSize: args.PageSize,
|
||||
},
|
||||
Nodes: nodes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *nodeClient) Upsert(ctx context.Context, n *ent.Node) (*ent.Node, error) {
|
||||
if n.ID == 0 {
|
||||
return c.client.Node.Create().
|
||||
SetName(n.Name).
|
||||
SetServer(n.Server).
|
||||
SetSlaveKey(n.SlaveKey).
|
||||
SetStatus(n.Status).
|
||||
SetType(node.TypeSlave).
|
||||
SetSettings(n.Settings).
|
||||
SetCapabilities(n.Capabilities).
|
||||
SetWeight(n.Weight).
|
||||
Save(ctx)
|
||||
}
|
||||
|
||||
res, err := c.client.Node.UpdateOne(n).
|
||||
SetName(n.Name).
|
||||
SetServer(n.Server).
|
||||
SetSlaveKey(n.SlaveKey).
|
||||
SetStatus(n.Status).
|
||||
SetSettings(n.Settings).
|
||||
SetCapabilities(n.Capabilities).
|
||||
SetWeight(n.Weight).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func getNodeOrderOption(args *ListNodeParameters) []node.OrderOption {
|
||||
orderTerm := getOrderTerm(args.Order)
|
||||
switch args.OrderBy {
|
||||
case node.FieldName:
|
||||
return []node.OrderOption{node.ByName(orderTerm), node.ByID(orderTerm)}
|
||||
case node.FieldWeight:
|
||||
return []node.OrderOption{node.ByWeight(orderTerm), node.ByID(orderTerm)}
|
||||
case node.FieldUpdatedAt:
|
||||
return []node.OrderOption{node.ByUpdatedAt(orderTerm), node.ByID(orderTerm)}
|
||||
default:
|
||||
return []node.OrderOption{node.ByID(orderTerm)}
|
||||
}
|
||||
}
|
||||
|
||||
func withNodeEagerLoading(ctx context.Context, query *ent.NodeQuery) *ent.NodeQuery {
|
||||
if _, ok := ctx.Value(LoadNodeStoragePolicy{}).(bool); ok {
|
||||
query = query.WithStoragePolicy(func(gq *ent.StoragePolicyQuery) {
|
||||
withStoragePolicyEagerLoading(ctx, gq)
|
||||
})
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/oauthclient"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/oauthgrant"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
type (
|
||||
OAuthClientClient interface {
|
||||
TxOperator
|
||||
// GetByGUID returns the OAuth client by its GUID (client_id).
|
||||
GetByGUID(ctx context.Context, guid string) (*ent.OAuthClient, error)
|
||||
// GetByGUIDWithGrants returns the OAuth client by its GUID (client_id) with the grants for the user.
|
||||
GetByGUIDWithGrants(ctx context.Context, guid string, uid int) (*ent.OAuthClient, error)
|
||||
// UpsertGrant creates or updates an OAuth grant for a user and client.
|
||||
UpsertGrant(ctx context.Context, userID, clientID int, scopes []string) error
|
||||
// UpdateGrantLastUsedAt updates the last used at for an OAuth grant for a user and client.
|
||||
UpdateGrantLastUsedAt(ctx context.Context, userID, clientID int) error
|
||||
// DeleteGrantByUserAndClientGUID deletes an OAuth grant for a user by the client GUID.
|
||||
// Returns true if the grant was deleted, false if it was not found.
|
||||
DeleteGrantByUserAndClientGUID(ctx context.Context, userID int, clientGUID string) (bool, error)
|
||||
// List returns a paginated list of OAuth clients.
|
||||
List(ctx context.Context, args *ListOAuthClientArgs) (*ListOAuthClientResult, error)
|
||||
// GetByID returns the OAuth client by its ID.
|
||||
GetByID(ctx context.Context, id int) (*ent.OAuthClient, error)
|
||||
// Create creates a new OAuth client.
|
||||
Create(ctx context.Context, client *ent.OAuthClient) (*ent.OAuthClient, error)
|
||||
// Update updates an existing OAuth client.
|
||||
Update(ctx context.Context, client *ent.OAuthClient) (*ent.OAuthClient, error)
|
||||
// Delete deletes an OAuth client by its ID.
|
||||
Delete(ctx context.Context, id int) error
|
||||
// CountGrants returns the number of grants for an OAuth client.
|
||||
CountGrants(ctx context.Context, id int) (int, error)
|
||||
// GetGrantsByUserID returns the OAuth grants for a user.
|
||||
GetGrantsByUserID(ctx context.Context, userID int) ([]*ent.OAuthGrant, error)
|
||||
}
|
||||
|
||||
ListOAuthClientArgs struct {
|
||||
*PaginationArgs
|
||||
Name string
|
||||
IsEnabled *bool
|
||||
}
|
||||
|
||||
ListOAuthClientResult struct {
|
||||
*PaginationResults
|
||||
Clients []*ent.OAuthClient
|
||||
}
|
||||
|
||||
LoadOAuthGrantClient struct{}
|
||||
)
|
||||
|
||||
func NewOAuthClientClient(client *ent.Client, dbType conf.DBType) OAuthClientClient {
|
||||
return &oauthClientClient{
|
||||
client: client,
|
||||
maxSQlParam: sqlParamLimit(dbType),
|
||||
}
|
||||
}
|
||||
|
||||
type oauthClientClient struct {
|
||||
client *ent.Client
|
||||
maxSQlParam int
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) SetClient(newClient *ent.Client) TxOperator {
|
||||
return &oauthClientClient{client: newClient, maxSQlParam: c.maxSQlParam}
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) GetClient() *ent.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) GetByGUID(ctx context.Context, guid string) (*ent.OAuthClient, error) {
|
||||
return c.client.OAuthClient.Query().
|
||||
Where(oauthclient.GUID(guid), oauthclient.IsEnabled(true)).
|
||||
First(ctx)
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) GetByGUIDWithGrants(ctx context.Context, guid string, uid int) (*ent.OAuthClient, error) {
|
||||
stm := c.client.OAuthClient.Query().
|
||||
Where(oauthclient.GUID(guid), oauthclient.IsEnabled(true))
|
||||
if uid > 0 {
|
||||
stm.WithGrants(func(ogq *ent.OAuthGrantQuery) {
|
||||
ogq.Where(oauthgrant.UserID(uid))
|
||||
})
|
||||
}
|
||||
|
||||
return stm.First(ctx)
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) UpsertGrant(ctx context.Context, userID, clientID int, scopes []string) error {
|
||||
return c.client.OAuthGrant.Create().
|
||||
SetUserID(userID).
|
||||
SetClientID(clientID).
|
||||
SetScopes(scopes).
|
||||
SetLastUsedAt(time.Now()).
|
||||
OnConflict(
|
||||
sql.ConflictColumns(oauthgrant.FieldUserID, oauthgrant.FieldClientID),
|
||||
).
|
||||
UpdateScopes().
|
||||
UpdateLastUsedAt().
|
||||
Exec(ctx)
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) UpdateGrantLastUsedAt(ctx context.Context, userID, clientID int) error {
|
||||
return c.client.OAuthGrant.Update().
|
||||
Where(oauthgrant.UserID(userID), oauthgrant.ClientID(clientID)).
|
||||
SetLastUsedAt(time.Now()).
|
||||
Exec(ctx)
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) GetGrantsByUserID(ctx context.Context, userID int) ([]*ent.OAuthGrant, error) {
|
||||
return withOAuthGrantEagerLoadings(ctx, c.client.OAuthGrant.Query()).
|
||||
Where(oauthgrant.UserID(userID)).
|
||||
All(ctx)
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) DeleteGrantByUserAndClientGUID(ctx context.Context, userID int, clientGUID string) (bool, error) {
|
||||
// First, get the client by GUID to get its ID
|
||||
client, err := c.client.OAuthClient.Query().
|
||||
Where(oauthclient.GUID(clientGUID)).
|
||||
First(ctx)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("failed to get OAuth client: %w", err)
|
||||
}
|
||||
|
||||
// Delete the grant for this user and client
|
||||
deleted, err := c.client.OAuthGrant.Delete().
|
||||
Where(oauthgrant.UserID(userID), oauthgrant.ClientID(client.ID)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to delete OAuth grant: %w", err)
|
||||
}
|
||||
|
||||
return deleted > 0, nil
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) List(ctx context.Context, args *ListOAuthClientArgs) (*ListOAuthClientResult, error) {
|
||||
query := c.client.OAuthClient.Query()
|
||||
|
||||
if args.Name != "" {
|
||||
query.Where(oauthclient.NameContains(args.Name))
|
||||
}
|
||||
|
||||
if args.IsEnabled != nil {
|
||||
query.Where(oauthclient.IsEnabled(*args.IsEnabled))
|
||||
}
|
||||
|
||||
pageSize := capPageSize(c.maxSQlParam, args.PageSize, 1)
|
||||
|
||||
total, err := query.Clone().Count(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to count OAuth clients: %w", err)
|
||||
}
|
||||
|
||||
query.Order(getOAuthClientOrderOption(args)...)
|
||||
|
||||
clients, err := query.
|
||||
Limit(pageSize).
|
||||
Offset(args.Page * pageSize).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list OAuth clients: %w", err)
|
||||
}
|
||||
|
||||
return &ListOAuthClientResult{
|
||||
PaginationResults: &PaginationResults{
|
||||
TotalItems: total,
|
||||
Page: args.Page,
|
||||
PageSize: pageSize,
|
||||
},
|
||||
Clients: clients,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) GetByID(ctx context.Context, id int) (*ent.OAuthClient, error) {
|
||||
return c.client.OAuthClient.Query().
|
||||
Where(oauthclient.ID(id)).
|
||||
First(ctx)
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) Create(ctx context.Context, client *ent.OAuthClient) (*ent.OAuthClient, error) {
|
||||
if client.Props == nil {
|
||||
client.Props = &types.OAuthClientProps{}
|
||||
}
|
||||
|
||||
// Generate a new GUID and secret if not provided
|
||||
if client.GUID == "" {
|
||||
client.GUID = uuid.Must(uuid.NewV4()).String()
|
||||
}
|
||||
if client.Secret == "" {
|
||||
client.Secret = util.RandStringRunesCrypto(32)
|
||||
}
|
||||
|
||||
return c.client.OAuthClient.Create().
|
||||
SetGUID(client.GUID).
|
||||
SetSecret(client.Secret).
|
||||
SetName(client.Name).
|
||||
SetHomepageURL(client.HomepageURL).
|
||||
SetRedirectUris(client.RedirectUris).
|
||||
SetScopes(client.Scopes).
|
||||
SetProps(client.Props).
|
||||
SetIsEnabled(client.IsEnabled).
|
||||
Save(ctx)
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) Update(ctx context.Context, client *ent.OAuthClient) (*ent.OAuthClient, error) {
|
||||
if client.Props == nil {
|
||||
client.Props = &types.OAuthClientProps{}
|
||||
}
|
||||
|
||||
update := c.client.OAuthClient.UpdateOneID(client.ID).
|
||||
SetName(client.Name).
|
||||
SetHomepageURL(client.HomepageURL).
|
||||
SetRedirectUris(client.RedirectUris).
|
||||
SetScopes(client.Scopes).
|
||||
SetProps(client.Props).
|
||||
SetIsEnabled(client.IsEnabled)
|
||||
|
||||
// Only update secret if provided (non-empty)
|
||||
if client.Secret != "" {
|
||||
update.SetSecret(client.Secret)
|
||||
}
|
||||
|
||||
return update.Save(ctx)
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) Delete(ctx context.Context, id int) error {
|
||||
// Delete all grants first
|
||||
_, err := c.client.OAuthGrant.Delete().
|
||||
Where(oauthgrant.ClientID(id)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete OAuth grants: %w", err)
|
||||
}
|
||||
|
||||
// Delete the client
|
||||
return c.client.OAuthClient.DeleteOneID(id).Exec(ctx)
|
||||
}
|
||||
|
||||
func (c *oauthClientClient) CountGrants(ctx context.Context, id int) (int, error) {
|
||||
return c.client.OAuthGrant.Query().
|
||||
Where(oauthgrant.ClientID(id)).
|
||||
Count(ctx)
|
||||
}
|
||||
|
||||
func getOAuthClientOrderOption(args *ListOAuthClientArgs) []oauthclient.OrderOption {
|
||||
orderTerm := getOrderTerm(args.Order)
|
||||
switch args.OrderBy {
|
||||
case oauthclient.FieldName:
|
||||
return []oauthclient.OrderOption{oauthclient.ByName(orderTerm), oauthclient.ByID(orderTerm)}
|
||||
case oauthclient.FieldCreatedAt:
|
||||
return []oauthclient.OrderOption{oauthclient.ByCreatedAt(orderTerm), oauthclient.ByID(orderTerm)}
|
||||
case oauthclient.FieldIsEnabled:
|
||||
return []oauthclient.OrderOption{oauthclient.ByIsEnabled(orderTerm), oauthclient.ByID(orderTerm)}
|
||||
default:
|
||||
return []oauthclient.OrderOption{oauthclient.ByID(orderTerm)}
|
||||
}
|
||||
}
|
||||
|
||||
func withOAuthGrantEagerLoadings(ctx context.Context, q *ent.OAuthGrantQuery) *ent.OAuthGrantQuery {
|
||||
if v, ok := ctx.Value(LoadOAuthGrantClient{}).(bool); ok && v {
|
||||
q.WithClient(func(ocq *ent.OAuthClientQuery) {
|
||||
})
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
// StoragePolicyCacheKey is the cache key of storage policy.
|
||||
StoragePolicyCacheKey = "storage_policy_"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gob.Register(ent.StoragePolicy{})
|
||||
gob.Register([]ent.StoragePolicy{})
|
||||
}
|
||||
|
||||
type (
|
||||
LoadStoragePolicyGroup struct{}
|
||||
SkipStoragePolicyCache struct{}
|
||||
|
||||
StoragePolicyClient interface {
|
||||
TxOperator
|
||||
// GetByGroup returns the storage policies of the group.
|
||||
GetByGroup(ctx context.Context, group *ent.Group) (*ent.StoragePolicy, error)
|
||||
// GetPolicyByID returns the storage policy by id.
|
||||
GetPolicyByID(ctx context.Context, id int) (*ent.StoragePolicy, error)
|
||||
// UpdateAccessKey updates the access key of the storage policy. It also clear related cache in KV.
|
||||
UpdateAccessKey(ctx context.Context, policy *ent.StoragePolicy, token string) error
|
||||
// ListPolicyByType returns the storage policies by type.
|
||||
ListPolicyByType(ctx context.Context, t types.PolicyType) ([]*ent.StoragePolicy, error)
|
||||
// ListPolicies returns the storage policies with pagination.
|
||||
ListPolicies(ctx context.Context, args *ListPolicyParameters) (*ListPolicyResult, error)
|
||||
// Upsert upserts the storage policy.
|
||||
Upsert(ctx context.Context, policy *ent.StoragePolicy) (*ent.StoragePolicy, error)
|
||||
// Delete deletes the storage policy.
|
||||
Delete(ctx context.Context, policy *ent.StoragePolicy) error
|
||||
}
|
||||
|
||||
ListPolicyParameters struct {
|
||||
*PaginationArgs
|
||||
Type types.PolicyType
|
||||
}
|
||||
|
||||
ListPolicyResult struct {
|
||||
*PaginationResults
|
||||
Policies []*ent.StoragePolicy
|
||||
}
|
||||
)
|
||||
|
||||
// NewStoragePolicyClient returns a new StoragePolicyClient.
|
||||
func NewStoragePolicyClient(client *ent.Client, cache cache.Driver) StoragePolicyClient {
|
||||
return &storagePolicyClient{client: client, cache: cache}
|
||||
}
|
||||
|
||||
type storagePolicyClient struct {
|
||||
client *ent.Client
|
||||
cache cache.Driver
|
||||
}
|
||||
|
||||
func (c *storagePolicyClient) SetClient(newClient *ent.Client) TxOperator {
|
||||
return &storagePolicyClient{client: newClient, cache: c.cache}
|
||||
}
|
||||
|
||||
func (c *storagePolicyClient) GetClient() *ent.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *storagePolicyClient) Delete(ctx context.Context, policy *ent.StoragePolicy) error {
|
||||
if err := c.client.StoragePolicy.DeleteOne(policy).Exec(ctx); err != nil {
|
||||
return fmt.Errorf("failed to delete storage policy: %w", err)
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
if err := c.cache.Delete(StoragePolicyCacheKey, strconv.Itoa(policy.ID)); err != nil {
|
||||
return fmt.Errorf("failed to clear storage policy cache: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *storagePolicyClient) Upsert(ctx context.Context, policy *ent.StoragePolicy) (*ent.StoragePolicy, error) {
|
||||
var nodeId *int
|
||||
if policy.NodeID != 0 {
|
||||
nodeId = &policy.NodeID
|
||||
}
|
||||
if policy.ID == 0 {
|
||||
p, err := c.client.StoragePolicy.
|
||||
Create().
|
||||
SetName(policy.Name).
|
||||
SetType(policy.Type).
|
||||
SetServer(policy.Server).
|
||||
SetBucketName(policy.BucketName).
|
||||
SetIsPrivate(policy.IsPrivate).
|
||||
SetAccessKey(policy.AccessKey).
|
||||
SetSecretKey(policy.SecretKey).
|
||||
SetMaxSize(policy.MaxSize).
|
||||
SetDirNameRule(policy.DirNameRule).
|
||||
SetFileNameRule(policy.FileNameRule).
|
||||
SetSettings(policy.Settings).
|
||||
SetNillableNodeID(nodeId).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage policy: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
updateQuery := c.client.StoragePolicy.UpdateOne(policy).
|
||||
SetName(policy.Name).
|
||||
SetType(policy.Type).
|
||||
SetServer(policy.Server).
|
||||
SetBucketName(policy.BucketName).
|
||||
SetIsPrivate(policy.IsPrivate).
|
||||
SetSecretKey(policy.SecretKey).
|
||||
SetMaxSize(policy.MaxSize).
|
||||
SetDirNameRule(policy.DirNameRule).
|
||||
SetFileNameRule(policy.FileNameRule).
|
||||
SetSettings(policy.Settings).
|
||||
SetNillableNodeID(nodeId)
|
||||
|
||||
if policy.Type != types.PolicyTypeOd {
|
||||
updateQuery.SetAccessKey(policy.AccessKey)
|
||||
}
|
||||
|
||||
p, err := updateQuery.Save(ctx)
|
||||
|
||||
// Clear cache
|
||||
if err := c.cache.Delete(StoragePolicyCacheKey, strconv.Itoa(policy.ID)); err != nil {
|
||||
return nil, fmt.Errorf("failed to clear storage policy cache: %w", err)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update storage policy: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
|
||||
}
|
||||
|
||||
func (c *storagePolicyClient) GetByGroup(ctx context.Context, group *ent.Group) (*ent.StoragePolicy, error) {
|
||||
val, skipCache := ctx.Value(SkipStoragePolicyCache{}).(bool)
|
||||
skipCache = skipCache && val
|
||||
|
||||
res, err := withStoragePolicyEagerLoading(ctx, c.client.Group.QueryStoragePolicies(group)).WithNode().First(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get storage policies: %w", err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetPolicyByID returns the storage policy by id.
|
||||
func (c *storagePolicyClient) GetPolicyByID(ctx context.Context, id int) (*ent.StoragePolicy, error) {
|
||||
val, skipCache := ctx.Value(SkipStoragePolicyCache{}).(bool)
|
||||
skipCache = skipCache && val
|
||||
|
||||
// Try to read from cache
|
||||
if c.cache != nil && !skipCache {
|
||||
if res, ok := c.cache.Get(StoragePolicyCacheKey + strconv.Itoa(id)); ok {
|
||||
cached := res.(ent.StoragePolicy)
|
||||
return &cached, nil
|
||||
}
|
||||
}
|
||||
|
||||
res, err := withStoragePolicyEagerLoading(ctx, c.client.StoragePolicy.Query().Where(storagepolicy.ID(id))).WithNode().First(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get storage policy: %w", err)
|
||||
}
|
||||
|
||||
// Write to cache
|
||||
if c.cache != nil && !skipCache {
|
||||
_ = c.cache.Set(StoragePolicyCacheKey+strconv.Itoa(id), *res, -1)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (c *storagePolicyClient) ListPolicyByType(ctx context.Context, t types.PolicyType) ([]*ent.StoragePolicy, error) {
|
||||
policies, err := c.client.StoragePolicy.Query().Where(storagepolicy.TypeEQ(string(t))).All(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list storage policies: %w", err)
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
func (c *storagePolicyClient) UpdateAccessKey(ctx context.Context, policy *ent.StoragePolicy, token string) error {
|
||||
_, err := c.client.StoragePolicy.UpdateOne(policy).SetAccessKey(token).Save(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("faield to update access key in DB: %w", err)
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
if err := c.cache.Delete(StoragePolicyCacheKey, strconv.Itoa(policy.ID)); err != nil {
|
||||
return fmt.Errorf("failed to clear storage policy cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *storagePolicyClient) ListPolicies(ctx context.Context, args *ListPolicyParameters) (*ListPolicyResult, error) {
|
||||
query := c.client.StoragePolicy.Query().WithNode()
|
||||
if args.Type != "" {
|
||||
query = query.Where(storagepolicy.TypeEQ(string(args.Type)))
|
||||
}
|
||||
query.Order(getStoragePolicyOrderOption(args)...)
|
||||
|
||||
// Count total items
|
||||
total, err := query.Clone().
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policies, err := withStoragePolicyEagerLoading(ctx, query).Limit(args.PageSize).Offset(args.Page * args.PageSize).All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListPolicyResult{
|
||||
PaginationResults: &PaginationResults{
|
||||
TotalItems: total,
|
||||
Page: args.Page,
|
||||
PageSize: args.PageSize,
|
||||
},
|
||||
Policies: policies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getStoragePolicyOrderOption(args *ListPolicyParameters) []storagepolicy.OrderOption {
|
||||
orderTerm := getOrderTerm(args.Order)
|
||||
switch args.OrderBy {
|
||||
case storagepolicy.FieldUpdatedAt:
|
||||
return []storagepolicy.OrderOption{storagepolicy.ByUpdatedAt(orderTerm), storagepolicy.ByID(orderTerm)}
|
||||
default:
|
||||
return []storagepolicy.OrderOption{storagepolicy.ByID(orderTerm)}
|
||||
}
|
||||
}
|
||||
|
||||
func withStoragePolicyEagerLoading(ctx context.Context, query *ent.StoragePolicyQuery) *ent.StoragePolicyQuery {
|
||||
if _, ok := ctx.Value(LoadStoragePolicyGroup{}).(bool); ok {
|
||||
query = query.WithGroups(func(gq *ent.GroupQuery) {
|
||||
withGroupEagerLoading(ctx, gq)
|
||||
})
|
||||
}
|
||||
return query
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,434 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/file"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/predicate"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/share"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/user"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type (
|
||||
// Ctx keys for eager loading options.
|
||||
LoadShareFile struct{}
|
||||
LoadShareUser struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
ErrShareLinkExpired = fmt.Errorf("share link expired")
|
||||
ErrOwnerInactive = fmt.Errorf("owner is inactive")
|
||||
ErrSourceFileInvalid = fmt.Errorf("source file is deleted")
|
||||
)
|
||||
|
||||
type (
|
||||
ShareClient interface {
|
||||
TxOperator
|
||||
// GetByIDs returns the shares with given ids.
|
||||
GetByIDs(ctx context.Context, ids []int) ([]*ent.Share, error)
|
||||
// GetByID returns the share with given id.
|
||||
GetByID(ctx context.Context, id int) (*ent.Share, error)
|
||||
// GetByIDUser returns the share with given id and user id.
|
||||
GetByIDUser(ctx context.Context, id, uid int) (*ent.Share, error)
|
||||
// GetByHashID returns the share with given hash id.
|
||||
GetByHashID(ctx context.Context, idRaw string) (*ent.Share, error)
|
||||
// Upsert creates or update a new share record.
|
||||
Upsert(ctx context.Context, params *CreateShareParams) (*ent.Share, error)
|
||||
// Viewed increase the view count of the share.
|
||||
Viewed(ctx context.Context, share *ent.Share) error
|
||||
// Downloaded increase the download count of the share.
|
||||
Downloaded(ctx context.Context, share *ent.Share) error
|
||||
// Delete deletes the share.
|
||||
Delete(ctx context.Context, shareId int) error
|
||||
// DeleteBatch deletes the shares with the given ids.
|
||||
DeleteBatch(ctx context.Context, shareIds []int) error
|
||||
// DeleteBatchByUserID deletes the shares with the given ids and user id
|
||||
DeleteBatchByUserID(ctx context.Context, uid int, shareIds []int) error
|
||||
// List returns a list of shares with the given args.
|
||||
List(ctx context.Context, args *ListShareArgs) (*ListShareResult, error)
|
||||
// CountByTimeRange counts the number of shares created in the given time range.
|
||||
CountByTimeRange(ctx context.Context, start, end *time.Time) (int, error)
|
||||
}
|
||||
|
||||
CreateShareParams struct {
|
||||
Existed *ent.Share
|
||||
Password string
|
||||
RemainDownloads int
|
||||
Expires *time.Time
|
||||
OwnerID int
|
||||
FileID int
|
||||
Props *types.ShareProps
|
||||
}
|
||||
|
||||
ListShareArgs struct {
|
||||
*PaginationArgs
|
||||
UserID int
|
||||
FileID int
|
||||
PublicOnly bool
|
||||
ShareIDs []int
|
||||
}
|
||||
ListShareResult struct {
|
||||
*PaginationResults
|
||||
Shares []*ent.Share
|
||||
}
|
||||
)
|
||||
|
||||
func NewShareClient(client *ent.Client, dbType conf.DBType, hasher hashid.Encoder) ShareClient {
|
||||
return &shareClient{
|
||||
client: client,
|
||||
hasher: hasher,
|
||||
maxSQlParam: sqlParamLimit(dbType),
|
||||
}
|
||||
}
|
||||
|
||||
type shareClient struct {
|
||||
maxSQlParam int
|
||||
client *ent.Client
|
||||
hasher hashid.Encoder
|
||||
}
|
||||
|
||||
func (c *shareClient) SetClient(newClient *ent.Client) TxOperator {
|
||||
return &shareClient{client: newClient, hasher: c.hasher, maxSQlParam: c.maxSQlParam}
|
||||
}
|
||||
|
||||
func (c *shareClient) GetClient() *ent.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *shareClient) CountByTimeRange(ctx context.Context, start, end *time.Time) (int, error) {
|
||||
if start == nil || end == nil {
|
||||
return c.client.Share.Query().Count(ctx)
|
||||
}
|
||||
|
||||
return c.client.Share.Query().Where(share.CreatedAtGTE(*start), share.CreatedAtLT(*end)).Count(ctx)
|
||||
}
|
||||
|
||||
func (c *shareClient) Upsert(ctx context.Context, params *CreateShareParams) (*ent.Share, error) {
|
||||
if params.Existed != nil {
|
||||
createQuery := c.client.Share.
|
||||
UpdateOne(params.Existed)
|
||||
if params.RemainDownloads > 0 {
|
||||
createQuery.SetRemainDownloads(params.RemainDownloads)
|
||||
} else {
|
||||
createQuery.ClearRemainDownloads()
|
||||
}
|
||||
if params.Expires != nil {
|
||||
createQuery.SetNillableExpires(params.Expires)
|
||||
} else {
|
||||
createQuery.ClearExpires()
|
||||
}
|
||||
|
||||
if params.Props != nil {
|
||||
createQuery.SetProps(params.Props)
|
||||
}
|
||||
|
||||
return createQuery.Save(ctx)
|
||||
}
|
||||
|
||||
query := c.client.Share.
|
||||
Create().
|
||||
SetUserID(params.OwnerID).
|
||||
SetFileID(params.FileID)
|
||||
if params.Password != "" {
|
||||
query.SetPassword(params.Password)
|
||||
}
|
||||
if params.RemainDownloads > 0 {
|
||||
query.SetRemainDownloads(params.RemainDownloads)
|
||||
}
|
||||
if params.Expires != nil {
|
||||
query.SetNillableExpires(params.Expires)
|
||||
}
|
||||
if params.Props != nil {
|
||||
query.SetProps(params.Props)
|
||||
}
|
||||
|
||||
return query.Save(ctx)
|
||||
}
|
||||
|
||||
func (c *shareClient) GetByHashID(ctx context.Context, idRaw string) (*ent.Share, error) {
|
||||
id, err := c.hasher.Decode(idRaw, hashid.ShareID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode hash id %q: %w", idRaw, err)
|
||||
}
|
||||
|
||||
return c.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (c *shareClient) GetByID(ctx context.Context, id int) (*ent.Share, error) {
|
||||
s, err := withShareEagerLoading(ctx, c.client.Share.Query().Where(share.ID(id))).First(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query share %d: %w", id, err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *shareClient) GetByIDUser(ctx context.Context, id, uid int) (*ent.Share, error) {
|
||||
s, err := withShareEagerLoading(ctx, c.client.Share.Query().
|
||||
Where(share.ID(id))).
|
||||
Where(share.HasUserWith(user.ID(uid))).First(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query share %d: %w", id, err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *shareClient) GetByIDs(ctx context.Context, ids []int) ([]*ent.Share, error) {
|
||||
s, err := withShareEagerLoading(ctx, c.client.Share.Query().Where(share.IDIn(ids...))).All(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query shares %v: %w", ids, err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *shareClient) DeleteBatch(ctx context.Context, shareIds []int) error {
|
||||
_, err := c.client.Share.Delete().Where(share.IDIn(shareIds...)).Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *shareClient) DeleteBatchByUserID(ctx context.Context, uid int, shareIds []int) error {
|
||||
_, err := c.client.Share.Delete().Where(share.IDIn(shareIds...)).Where(share.HasUserWith(user.ID(uid))).Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *shareClient) Delete(ctx context.Context, shareId int) error {
|
||||
return c.client.Share.DeleteOneID(shareId).Exec(ctx)
|
||||
}
|
||||
|
||||
// Viewed increments the view count of the share.
|
||||
func (c *shareClient) Viewed(ctx context.Context, share *ent.Share) error {
|
||||
_, err := c.client.Share.UpdateOneID(share.ID).AddViews(1).Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// Downloaded increments the download count of the share.
|
||||
func (c *shareClient) Downloaded(ctx context.Context, share *ent.Share) error {
|
||||
stm := c.client.Share.
|
||||
UpdateOneID(share.ID).
|
||||
AddDownloads(1)
|
||||
if share.RemainDownloads != nil && *share.RemainDownloads >= 0 {
|
||||
stm.AddRemainDownloads(-1)
|
||||
}
|
||||
_, err := stm.Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func IsValidShare(share *ent.Share) error {
|
||||
// Check if share is expired
|
||||
if err := IsShareExpired(share); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check owner status
|
||||
owner, err := share.Edges.UserOrErr()
|
||||
if err != nil || owner.Status != user.StatusActive {
|
||||
// Owner already deleted, or not active.
|
||||
return ErrOwnerInactive
|
||||
}
|
||||
|
||||
// Check source file status
|
||||
file, err := share.Edges.FileOrErr()
|
||||
if err != nil || file.FileChildren == 0 || file.OwnerID != owner.ID {
|
||||
// Source file already deleted
|
||||
return ErrSourceFileInvalid
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsShareExpired(share *ent.Share) error {
|
||||
// Check if share is expired
|
||||
if (share.Expires != nil && share.Expires.Before(time.Now())) ||
|
||||
(share.RemainDownloads != nil && *share.RemainDownloads <= 0) {
|
||||
return ErrShareLinkExpired
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *shareClient) List(ctx context.Context, args *ListShareArgs) (*ListShareResult, error) {
|
||||
rawQuery := c.listQuery(args)
|
||||
query := withShareEagerLoading(ctx, rawQuery)
|
||||
|
||||
var (
|
||||
shares []*ent.Share
|
||||
err error
|
||||
paginationRes *PaginationResults
|
||||
)
|
||||
if args.UseCursorPagination {
|
||||
shares, paginationRes, err = c.cursorPagination(ctx, query, args, 10)
|
||||
} else {
|
||||
shares, paginationRes, err = c.offsetPagination(ctx, query, args, 10)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed with paginiation: %w", err)
|
||||
}
|
||||
|
||||
return &ListShareResult{
|
||||
Shares: shares,
|
||||
PaginationResults: paginationRes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *shareClient) cursorPagination(ctx context.Context, query *ent.ShareQuery, args *ListShareArgs, paramMargin int) ([]*ent.Share, *PaginationResults, error) {
|
||||
pageSize := capPageSize(c.maxSQlParam, args.PageSize, paramMargin)
|
||||
query.Order(getShareOrderOption(args)...)
|
||||
|
||||
var (
|
||||
pageToken *PageToken
|
||||
err error
|
||||
)
|
||||
if args.PageToken != "" {
|
||||
pageToken, err = pageTokenFromString(args.PageToken, c.hasher, hashid.ShareID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid page token %q: %w", args.PageToken, err)
|
||||
}
|
||||
}
|
||||
queryPaged := getShareCursorQuery(args, pageToken, query)
|
||||
|
||||
// Use page size + 1 to determine if there are more items to come
|
||||
queryPaged.Limit(pageSize + 1)
|
||||
|
||||
logs, err := queryPaged.
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// More items to come
|
||||
nextTokenStr := ""
|
||||
if len(logs) > pageSize {
|
||||
lastItem := logs[len(logs)-2]
|
||||
nextToken, err := getShareNextPageToken(c.hasher, lastItem, args)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate next page token: %w", err)
|
||||
}
|
||||
|
||||
nextTokenStr = nextToken
|
||||
}
|
||||
|
||||
return lo.Subset(logs, 0, uint(pageSize)), &PaginationResults{
|
||||
PageSize: pageSize,
|
||||
NextPageToken: nextTokenStr,
|
||||
IsCursor: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *shareClient) offsetPagination(ctx context.Context, query *ent.ShareQuery, args *ListShareArgs, paramMargin int) ([]*ent.Share, *PaginationResults, error) {
|
||||
pageSize := capPageSize(c.maxSQlParam, args.PageSize, paramMargin)
|
||||
query.Order(getShareOrderOption(args)...)
|
||||
|
||||
total, err := query.Clone().Count(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
logs, err := query.Limit(pageSize).Offset(args.Page * args.PageSize).All(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return logs, &PaginationResults{
|
||||
PageSize: pageSize,
|
||||
TotalItems: total,
|
||||
Page: args.Page,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *shareClient) listQuery(args *ListShareArgs) *ent.ShareQuery {
|
||||
query := c.client.Share.Query()
|
||||
if args.UserID > 0 {
|
||||
query.Where(share.HasUserWith(user.ID(args.UserID)))
|
||||
}
|
||||
|
||||
if args.PublicOnly {
|
||||
query.Where(share.PasswordIsNil())
|
||||
}
|
||||
|
||||
if args.FileID > 0 {
|
||||
query.Where(share.HasFileWith(file.ID(args.FileID)))
|
||||
}
|
||||
|
||||
if len(args.ShareIDs) > 0 {
|
||||
query.Where(share.IDIn(args.ShareIDs...))
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// getShareNextPageToken returns the next page token for the given last share.
|
||||
func getShareNextPageToken(hasher hashid.Encoder, last *ent.Share, args *ListShareArgs) (string, error) {
|
||||
token := &PageToken{
|
||||
ID: last.ID,
|
||||
}
|
||||
|
||||
return token.Encode(hasher, hashid.EncodeShareID)
|
||||
}
|
||||
|
||||
func getShareCursorQuery(args *ListShareArgs, token *PageToken, query *ent.ShareQuery) *ent.ShareQuery {
|
||||
o := &sql.OrderTermOptions{}
|
||||
getOrderTerm(args.Order)(o)
|
||||
|
||||
predicates, ok := shareCursorQuery[args.OrderBy]
|
||||
if !ok {
|
||||
predicates = shareCursorQuery[share.FieldID]
|
||||
}
|
||||
|
||||
if token != nil {
|
||||
query.Where(predicates[o.Desc](token))
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
var shareCursorQuery = map[string]map[bool]func(token *PageToken) predicate.Share{
|
||||
share.FieldID: {
|
||||
true: func(token *PageToken) predicate.Share {
|
||||
return share.IDLT(token.ID)
|
||||
},
|
||||
false: func(token *PageToken) predicate.Share {
|
||||
return share.IDGT(token.ID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func getShareOrderOption(args *ListShareArgs) []share.OrderOption {
|
||||
orderTerm := getOrderTerm(args.Order)
|
||||
switch args.OrderBy {
|
||||
case share.FieldViews:
|
||||
return []share.OrderOption{share.ByViews(orderTerm), share.ByID(orderTerm)}
|
||||
case share.FieldDownloads:
|
||||
return []share.OrderOption{share.ByDownloads(orderTerm), share.ByID(orderTerm)}
|
||||
case share.FieldRemainDownloads:
|
||||
return []share.OrderOption{share.ByRemainDownloads(orderTerm), share.ByID(orderTerm)}
|
||||
default:
|
||||
return []share.OrderOption{share.ByID(orderTerm)}
|
||||
}
|
||||
}
|
||||
|
||||
func withShareEagerLoading(ctx context.Context, q *ent.ShareQuery) *ent.ShareQuery {
|
||||
if v, ok := ctx.Value(LoadShareFile{}).(bool); ok && v {
|
||||
q.WithFile(func(q *ent.FileQuery) {
|
||||
withFileEagerLoading(ctx, q)
|
||||
})
|
||||
}
|
||||
if v, ok := ctx.Value(LoadShareUser{}).(bool); ok && v {
|
||||
q.WithUser(func(q *ent.UserQuery) {
|
||||
withUserEagerLoading(ctx, q)
|
||||
})
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/task"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type (
|
||||
// Ctx keys for eager loading options.
|
||||
LoadTaskUser struct{}
|
||||
|
||||
TaskArgs struct {
|
||||
Status task.Status
|
||||
Type string
|
||||
PublicState *types.TaskPublicState
|
||||
PrivateState string
|
||||
OwnerID int
|
||||
CorrelationID uuid.UUID
|
||||
}
|
||||
)
|
||||
|
||||
type TaskClient interface {
|
||||
TxOperator
|
||||
// New creates a new task with the given args.
|
||||
New(ctx context.Context, task *TaskArgs) (*ent.Task, error)
|
||||
// Update updates the task with the given args.
|
||||
Update(ctx context.Context, task *ent.Task, args *TaskArgs) (*ent.Task, error)
|
||||
// GetPendingTasks returns all pending tasks of given type.
|
||||
GetPendingTasks(ctx context.Context, taskType ...string) ([]*ent.Task, error)
|
||||
// GetTaskByID returns the task with the given ID.
|
||||
GetTaskByID(ctx context.Context, taskID int) (*ent.Task, error)
|
||||
// SetCompleteByID sets the task with the given ID to complete.
|
||||
SetCompleteByID(ctx context.Context, taskID int) error
|
||||
// List returns a list of tasks with the given args.
|
||||
List(ctx context.Context, args *ListTaskArgs) (*ListTaskResult, error)
|
||||
// DeleteByIDs deletes the tasks with the given IDs.
|
||||
DeleteByIDs(ctx context.Context, ids ...int) error
|
||||
// DeleteBy deletes the tasks with the given args.
|
||||
DeleteBy(ctx context.Context, args *DeleteTaskArgs) error
|
||||
}
|
||||
|
||||
type (
|
||||
ListTaskArgs struct {
|
||||
*PaginationArgs
|
||||
Types []string
|
||||
Status []task.Status
|
||||
UserID int
|
||||
CorrelationID *uuid.UUID
|
||||
}
|
||||
|
||||
ListTaskResult struct {
|
||||
*PaginationResults
|
||||
Tasks []*ent.Task
|
||||
}
|
||||
|
||||
DeleteTaskArgs struct {
|
||||
NotAfter time.Time
|
||||
Types []string
|
||||
Status []task.Status
|
||||
}
|
||||
)
|
||||
|
||||
func NewTaskClient(client *ent.Client, dbType conf.DBType, hasher hashid.Encoder) TaskClient {
|
||||
return &taskClient{client: client, maxSQlParam: sqlParamLimit(dbType), hasher: hasher}
|
||||
}
|
||||
|
||||
type taskClient struct {
|
||||
maxSQlParam int
|
||||
hasher hashid.Encoder
|
||||
client *ent.Client
|
||||
}
|
||||
|
||||
func (c *taskClient) SetClient(newClient *ent.Client) TxOperator {
|
||||
return &taskClient{client: newClient, maxSQlParam: c.maxSQlParam, hasher: c.hasher}
|
||||
}
|
||||
|
||||
func (c *taskClient) GetClient() *ent.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *taskClient) New(ctx context.Context, task *TaskArgs) (*ent.Task, error) {
|
||||
stm := c.client.Task.
|
||||
Create().
|
||||
SetType(task.Type).
|
||||
SetPublicState(task.PublicState)
|
||||
if task.PrivateState != "" {
|
||||
stm.SetPrivateState(task.PrivateState)
|
||||
}
|
||||
|
||||
if task.OwnerID != 0 {
|
||||
stm.SetUserID(task.OwnerID)
|
||||
}
|
||||
|
||||
if task.Status != "" {
|
||||
stm.SetStatus(task.Status)
|
||||
}
|
||||
|
||||
if task.CorrelationID.String() != uuid.Nil.String() {
|
||||
stm.SetCorrelationID(task.CorrelationID)
|
||||
}
|
||||
|
||||
newTask, err := stm.Save(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
return newTask, nil
|
||||
}
|
||||
|
||||
func (c *taskClient) DeleteByIDs(ctx context.Context, ids ...int) error {
|
||||
_, err := c.client.Task.Delete().Where(task.IDIn(ids...)).Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *taskClient) DeleteBy(ctx context.Context, args *DeleteTaskArgs) error {
|
||||
query := c.client.Task.
|
||||
Delete().
|
||||
Where(task.CreatedAtLTE(args.NotAfter))
|
||||
|
||||
if len(args.Status) > 0 {
|
||||
query.Where(task.StatusIn(args.Status...))
|
||||
}
|
||||
|
||||
if len(args.Types) > 0 {
|
||||
query.Where(task.TypeIn(args.Types...))
|
||||
}
|
||||
|
||||
_, err := query.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *taskClient) Update(ctx context.Context, task *ent.Task, args *TaskArgs) (*ent.Task, error) {
|
||||
stm := c.client.Task.UpdateOne(task).
|
||||
SetPublicState(args.PublicState)
|
||||
|
||||
task.PublicState = args.PublicState
|
||||
|
||||
if task.PrivateState != "" {
|
||||
stm.SetPrivateState(task.PrivateState)
|
||||
task.PrivateState = args.PrivateState
|
||||
}
|
||||
|
||||
if task.Status != "" {
|
||||
stm.SetStatus(args.Status)
|
||||
task.Status = args.Status
|
||||
}
|
||||
|
||||
if err := stm.Exec(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (c *taskClient) GetPendingTasks(ctx context.Context, taskType ...string) ([]*ent.Task, error) {
|
||||
tasks, err := withTaskEagerLoading(ctx, c.client.Task.Query()).
|
||||
Where(task.StatusIn(task.StatusProcessing, task.StatusQueued, task.StatusSuspending)).
|
||||
Where(task.TypeIn(taskType...)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Anonymous user is not loaded by default, so we need to load it manually.
|
||||
userClient := NewUserClient(c.client)
|
||||
anonymous, err := userClient.AnonymousUser(ctx)
|
||||
for _, t := range tasks {
|
||||
if t.UserTasks == 0 {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.SetUser(anonymous)
|
||||
}
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (c *taskClient) GetTaskByID(ctx context.Context, taskID int) (*ent.Task, error) {
|
||||
return withTaskEagerLoading(ctx, c.client.Task.Query()).
|
||||
Where(task.ID(taskID)).
|
||||
First(ctx)
|
||||
}
|
||||
|
||||
func (c *taskClient) SetCompleteByID(ctx context.Context, taskID int) error {
|
||||
_, err := c.client.Task.UpdateOneID(taskID).
|
||||
SetStatus(task.StatusCompleted).
|
||||
Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *taskClient) List(ctx context.Context, args *ListTaskArgs) (*ListTaskResult, error) {
|
||||
q := c.client.Task.Query()
|
||||
if args.UserID != 0 {
|
||||
q.Where(task.UserTasks(args.UserID))
|
||||
}
|
||||
|
||||
if args.Types != nil {
|
||||
q.Where(task.TypeIn(args.Types...))
|
||||
}
|
||||
|
||||
if args.Status != nil {
|
||||
q.Where(task.StatusIn(args.Status...))
|
||||
}
|
||||
|
||||
if args.CorrelationID != nil {
|
||||
q.Where(task.CorrelationID(*args.CorrelationID))
|
||||
}
|
||||
|
||||
q = withTaskEagerLoading(ctx, q)
|
||||
var (
|
||||
tasks []*ent.Task
|
||||
err error
|
||||
paginationRes *PaginationResults
|
||||
)
|
||||
|
||||
if args.UseCursorPagination {
|
||||
tasks, paginationRes, err = c.cursorPagination(ctx, q, args, 1)
|
||||
} else {
|
||||
tasks, paginationRes, err = c.offsetPagination(ctx, q, args, 1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed with paginiation: %w", err)
|
||||
}
|
||||
|
||||
return &ListTaskResult{
|
||||
Tasks: tasks,
|
||||
PaginationResults: paginationRes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *taskClient) cursorPagination(ctx context.Context, query *ent.TaskQuery, args *ListTaskArgs, paramMargin int) ([]*ent.Task, *PaginationResults, error) {
|
||||
pageSize := capPageSize(c.maxSQlParam, args.PageSize, paramMargin)
|
||||
query.Order(task.ByID(sql.OrderDesc()))
|
||||
|
||||
var (
|
||||
pageToken *PageToken
|
||||
err error
|
||||
queryPaged = query
|
||||
)
|
||||
if args.PageToken != "" {
|
||||
pageToken, err = pageTokenFromString(args.PageToken, c.hasher, hashid.TaskID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid page token %q: %w", args.PageToken, err)
|
||||
}
|
||||
|
||||
queryPaged = query.Where(task.IDLT(pageToken.ID))
|
||||
}
|
||||
|
||||
// Use page size + 1 to determine if there are more items to come
|
||||
queryPaged.Limit(pageSize + 1)
|
||||
|
||||
tasks, err := queryPaged.
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// More items to come
|
||||
nextTokenStr := ""
|
||||
if len(tasks) > pageSize {
|
||||
lastItem := tasks[len(tasks)-2]
|
||||
nextToken, err := getTaskNextPageToken(c.hasher, lastItem)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate next page token: %w", err)
|
||||
}
|
||||
|
||||
nextTokenStr = nextToken
|
||||
}
|
||||
|
||||
return lo.Subset(tasks, 0, uint(pageSize)), &PaginationResults{
|
||||
PageSize: pageSize,
|
||||
NextPageToken: nextTokenStr,
|
||||
IsCursor: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *taskClient) offsetPagination(ctx context.Context, query *ent.TaskQuery, args *ListTaskArgs, paramMargin int) ([]*ent.Task, *PaginationResults, error) {
|
||||
pageSize := capPageSize(c.maxSQlParam, args.PageSize, paramMargin)
|
||||
query.Order(getTaskOrderOption(args)...)
|
||||
|
||||
// Count total items
|
||||
total, err := query.Clone().Count(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
logs, err := query.
|
||||
Limit(pageSize).
|
||||
Offset(args.Page * args.PageSize).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return logs, &PaginationResults{
|
||||
PageSize: pageSize,
|
||||
TotalItems: total,
|
||||
Page: args.Page,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func getTaskOrderOption(args *ListTaskArgs) []task.OrderOption {
|
||||
orderTerm := getOrderTerm(args.Order)
|
||||
switch args.OrderBy {
|
||||
default:
|
||||
return []task.OrderOption{task.ByID(orderTerm)}
|
||||
}
|
||||
}
|
||||
|
||||
// getTaskNextPageToken returns the next page token for the given last task.
|
||||
func getTaskNextPageToken(hasher hashid.Encoder, last *ent.Task) (string, error) {
|
||||
token := &PageToken{
|
||||
ID: last.ID,
|
||||
}
|
||||
|
||||
return token.Encode(hasher, hashid.EncodeTaskID)
|
||||
}
|
||||
|
||||
func withTaskEagerLoading(ctx context.Context, q *ent.TaskQuery) *ent.TaskQuery {
|
||||
if v, ok := ctx.Value(LoadTaskUser{}).(bool); ok && v {
|
||||
q.WithUser(func(q *ent.UserQuery) {
|
||||
withUserEagerLoading(ctx, q)
|
||||
})
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
)
|
||||
|
||||
type TxOperator interface {
|
||||
SetClient(newClient *ent.Client) TxOperator
|
||||
GetClient() *ent.Client
|
||||
}
|
||||
|
||||
type (
|
||||
Tx struct {
|
||||
tx *ent.Tx
|
||||
parent *Tx
|
||||
inherited bool
|
||||
finished bool
|
||||
storageDiff StorageDiff
|
||||
}
|
||||
|
||||
// TxCtx is the context key for inherited transaction
|
||||
TxCtx struct{}
|
||||
)
|
||||
|
||||
// ReserveStorage atomically adds `size` bytes to user `uid`'s storage inside
|
||||
// this transaction, enforcing storage + size <= maxTotal at the database
|
||||
// level (when maxTotal > 0). This is the correct API for grabbing the
|
||||
// per-user storage quota:
|
||||
//
|
||||
// - It MUST be called as the first write of the transaction (or at least
|
||||
// before any other WRITE to files/entities/etc.). That way the users-row
|
||||
// X lock is acquired before any INSERT into files/entities, so
|
||||
// concurrent uploads for the same owner serialize on the users row from
|
||||
// the start and cannot deadlock via foreign-key S locks on the shared
|
||||
// parent folder / storage-policy rows.
|
||||
// - A matching compensating negative diff is appended to storageDiff so
|
||||
// the pre-reservation is netted against the positive diff emitted later
|
||||
// by CreateEntity / fc.Copy / etc. No amount is double-applied.
|
||||
// - On failure (quota exceeded or any other DB error) the caller MUST
|
||||
// Rollback the transaction: any placeholder writes done afterwards
|
||||
// would otherwise be committed without a matching storage reservation.
|
||||
//
|
||||
// It is safe to call this from an inherited-tx wrapper (returned by
|
||||
// InheritTx) — AppendStorageDiff routes to the root tx.
|
||||
func (t *Tx) ReserveStorage(ctx context.Context, uc UserClient, uid int, size, maxTotal int64) error {
|
||||
if size <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
txUc, _ := InheritTx(ctx, uc)
|
||||
if err := txUc.ReserveStorage(ctx, uid, size, maxTotal); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Net out the pre-reservation against any positive diff that
|
||||
// CreateEntity / fc.Copy / ... will append later in the same tx.
|
||||
t.AppendStorageDiff(StorageDiff{uid: -size})
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppendStorageDiff appends the given storage diff to the transaction.
|
||||
func (t *Tx) AppendStorageDiff(diff StorageDiff) {
|
||||
root := t
|
||||
for root.inherited {
|
||||
root = root.parent
|
||||
}
|
||||
|
||||
if root.storageDiff == nil {
|
||||
root.storageDiff = diff
|
||||
} else {
|
||||
root.storageDiff.Merge(diff)
|
||||
}
|
||||
}
|
||||
|
||||
// WithTx wraps the given inventory client with a transaction.
|
||||
func WithTx[T TxOperator](ctx context.Context, c T) (T, *Tx, context.Context, error) {
|
||||
var txClient *ent.Client
|
||||
var txWrapper *Tx
|
||||
|
||||
if txInherited, ok := ctx.Value(TxCtx{}).(*Tx); ok && !txInherited.finished {
|
||||
txWrapper = &Tx{inherited: true, tx: txInherited.tx, parent: txInherited}
|
||||
} else {
|
||||
tx, err := c.GetClient().Tx(ctx)
|
||||
if err != nil {
|
||||
return c, nil, ctx, fmt.Errorf("failed to create transaction: %w", err)
|
||||
}
|
||||
|
||||
txWrapper = &Tx{inherited: false, tx: tx}
|
||||
ctx = context.WithValue(ctx, TxCtx{}, txWrapper)
|
||||
}
|
||||
|
||||
txClient = txWrapper.tx.Client()
|
||||
return c.SetClient(txClient).(T), txWrapper, ctx, nil
|
||||
}
|
||||
|
||||
// InheritTx wraps the given inventory client with a transaction.
|
||||
// If the transaction is already in the context, it will be inherited.
|
||||
// Otherwise, original client will be returned.
|
||||
func InheritTx[T TxOperator](ctx context.Context, c T) (T, *Tx) {
|
||||
var txClient *ent.Client
|
||||
var txWrapper *Tx
|
||||
|
||||
if txInherited, ok := ctx.Value(TxCtx{}).(*Tx); ok && !txInherited.finished {
|
||||
txWrapper = &Tx{inherited: true, tx: txInherited.tx, parent: txInherited}
|
||||
txClient = txWrapper.tx.Client()
|
||||
return c.SetClient(txClient).(T), txWrapper
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func Rollback(tx *Tx) error {
|
||||
if !tx.inherited {
|
||||
tx.finished = true
|
||||
return tx.tx.Rollback()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func commit(tx *Tx) (bool, error) {
|
||||
if !tx.inherited {
|
||||
tx.finished = true
|
||||
return true, tx.tx.Commit()
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func Commit(tx *Tx) error {
|
||||
_, err := commit(tx)
|
||||
return err
|
||||
}
|
||||
|
||||
// CommitWithStorageDiff commits the transaction and applies the accumulated
|
||||
// storage diff. Only the outermost (non-inherited) transaction performs the
|
||||
// commit and the storage mutation.
|
||||
//
|
||||
// Quota enforcement is done UPFRONT via Tx.ReserveStorage, not here. This
|
||||
// function's role is to (a) commit the tx, and (b) apply any residual
|
||||
// storage diff produced by CreateEntity / CapEntities / etc. Tx.ReserveStorage
|
||||
// pre-emits a matching negative diff, so pre-reserved sizes are netted out
|
||||
// against the positive diff CreateEntity appends later. Residual is applied
|
||||
// AFTER commit via ApplyStorageDiff (auto-commit, safe to retry on
|
||||
// deadlock).
|
||||
func CommitWithStorageDiff(ctx context.Context, tx *Tx, l logging.Logger, uc UserClient) error {
|
||||
committed, err := commit(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := uc.ApplyStorageDiff(ctx, tx.storageDiff); err != nil {
|
||||
l.Error("Failed to apply storage diff", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserSetting 用户其他配置
|
||||
type (
|
||||
UserSetting struct {
|
||||
ProfileOff bool `json:"profile_off,omitempty"`
|
||||
PreferredTheme string `json:"preferred_theme,omitempty"`
|
||||
VersionRetention bool `json:"version_retention,omitempty"`
|
||||
VersionRetentionExt []string `json:"version_retention_ext,omitempty"`
|
||||
VersionRetentionMax int `json:"version_retention_max,omitempty"`
|
||||
Pined []PinedFile `json:"pined,omitempty"`
|
||||
Language string `json:"email_language,omitempty"`
|
||||
DisableViewSync bool `json:"disable_view_sync,omitempty"`
|
||||
FsViewMap map[string]ExplorerView `json:"fs_view_map,omitempty"`
|
||||
ShareLinksInProfile ShareLinksInProfileLevel `json:"share_links_in_profile,omitempty"`
|
||||
}
|
||||
|
||||
ShareLinksInProfileLevel string
|
||||
|
||||
PinedFile struct {
|
||||
Uri string `json:"uri"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// GroupSetting 用户组其他配置
|
||||
GroupSetting struct {
|
||||
CompressSize int64 `json:"compress_size,omitempty"` // 可压缩大小
|
||||
DecompressSize int64 `json:"decompress_size,omitempty"`
|
||||
RemoteDownloadOptions map[string]interface{} `json:"remote_download_options,omitempty"` // 离线下载用户组配置
|
||||
SourceBatchSize int `json:"source_batch,omitempty"`
|
||||
Aria2BatchSize int `json:"aria2_batch,omitempty"`
|
||||
MaxWalkedFiles int `json:"max_walked_files,omitempty"`
|
||||
TrashRetention int `json:"trash_retention,omitempty"`
|
||||
RedirectedSource bool `json:"redirected_source,omitempty"`
|
||||
}
|
||||
|
||||
// PolicySetting 非公有的存储策略属性
|
||||
PolicySetting struct {
|
||||
// Upyun访问Token
|
||||
Token string `json:"token"`
|
||||
// 允许的文件扩展名
|
||||
FileType []string `json:"file_type"`
|
||||
// IsFileTypeDenyList Whether above list is a deny list.
|
||||
IsFileTypeDenyList bool `json:"is_file_type_deny_list,omitempty"`
|
||||
// FileRegexp 文件扩展名正则表达式
|
||||
NameRegexp string `json:"file_regexp,omitempty"`
|
||||
// IsNameRegexp Whether above regexp is a deny list.
|
||||
IsNameRegexpDenyList bool `json:"is_name_regexp_deny_list,omitempty"`
|
||||
// OauthRedirect Oauth 重定向地址
|
||||
OauthRedirect string `json:"od_redirect,omitempty"`
|
||||
// CustomProxy whether to use custom-proxy to get file content
|
||||
CustomProxy bool `json:"custom_proxy,omitempty"`
|
||||
// ProxyServer 反代地址
|
||||
ProxyServer string `json:"proxy_server,omitempty"`
|
||||
// InternalProxy whether to use Cloudreve internal proxy to get file content
|
||||
InternalProxy bool `json:"internal_proxy,omitempty"`
|
||||
// OdDriver OneDrive 驱动器定位符
|
||||
OdDriver string `json:"od_driver,omitempty"`
|
||||
// Region 区域代码
|
||||
Region string `json:"region,omitempty"`
|
||||
// ServerSideEndpoint 服务端请求使用的 Endpoint,为空时使用 Policy.Server 字段
|
||||
ServerSideEndpoint string `json:"server_side_endpoint,omitempty"`
|
||||
// 分片上传的分片大小
|
||||
ChunkSize int64 `json:"chunk_size,omitempty"`
|
||||
// 每秒对存储端的 API 请求上限
|
||||
TPSLimit float64 `json:"tps_limit,omitempty"`
|
||||
// 每秒 API 请求爆发上限
|
||||
TPSLimitBurst int `json:"tps_limit_burst,omitempty"`
|
||||
// Set this to `true` to force the request to use path-style addressing,
|
||||
// i.e., `http://s3.amazonaws.com/BUCKET/KEY `
|
||||
S3ForcePathStyle bool `json:"s3_path_style"`
|
||||
// File extensions that support thumbnail generation using native policy API.
|
||||
ThumbExts []string `json:"thumb_exts,omitempty"`
|
||||
// Whether to support all file extensions for thumbnail generation.
|
||||
ThumbSupportAllExts bool `json:"thumb_support_all_exts,omitempty"`
|
||||
// ThumbMaxSize indicates the maximum allowed size of a thumbnail. 0 indicates that no limit is set.
|
||||
ThumbMaxSize int64 `json:"thumb_max_size,omitempty"`
|
||||
// Whether to upload file through server's relay.
|
||||
Relay bool `json:"relay,omitempty"`
|
||||
// Whether to pre allocate space for file before upload in physical disk.
|
||||
PreAllocate bool `json:"pre_allocate,omitempty"`
|
||||
// MediaMetaExts file extensions that support media meta generation using native policy API.
|
||||
MediaMetaExts []string `json:"media_meta_exts,omitempty"`
|
||||
// MediaMetaGeneratorProxy whether to use local proxy to generate media meta.
|
||||
MediaMetaGeneratorProxy bool `json:"media_meta_generator_proxy,omitempty"`
|
||||
// ThumbGeneratorProxy whether to use local proxy to generate thumbnail.
|
||||
ThumbGeneratorProxy bool `json:"thumb_generator_proxy,omitempty"`
|
||||
// NativeMediaProcessing whether to use native media processing API from storage provider.
|
||||
NativeMediaProcessing bool `json:"native_media_processing"`
|
||||
// S3DeleteBatchSize the number of objects to delete in each batch.
|
||||
S3DeleteBatchSize int `json:"s3_delete_batch_size,omitempty"`
|
||||
// StreamSaver whether to use stream saver to download file in Web.
|
||||
StreamSaver bool `json:"stream_saver,omitempty"`
|
||||
// UseCname whether to use CNAME for endpoint (OSS).
|
||||
UseCname bool `json:"use_cname,omitempty"`
|
||||
// CDN domain does not need to be signed.
|
||||
SourceAuth bool `json:"source_auth,omitempty"`
|
||||
// QiniuUploadCdn whether to use CDN for Qiniu upload.
|
||||
QiniuUploadCdn bool `json:"qiniu_upload_cdn,omitempty"`
|
||||
// ChunkConcurrency the number of chunks to upload concurrently.
|
||||
ChunkConcurrency int `json:"chunk_concurrency,omitempty"`
|
||||
// Whether to enable file encryption.
|
||||
Encryption bool `json:"encryption,omitempty"`
|
||||
}
|
||||
|
||||
FileType int
|
||||
EntityType int
|
||||
GroupPermission int
|
||||
FilePermission int
|
||||
DavAccountOption int
|
||||
NodeCapability int
|
||||
|
||||
NodeSetting struct {
|
||||
Provider DownloaderProvider `json:"provider,omitempty"`
|
||||
*QBittorrentSetting `json:"qbittorrent,omitempty"`
|
||||
*Aria2Setting `json:"aria2,omitempty"`
|
||||
// 下载监控间隔
|
||||
Interval int `json:"interval,omitempty"`
|
||||
WaitForSeeding bool `json:"wait_for_seeding,omitempty"`
|
||||
// URLValidation controls SSRF policy applied to user-supplied URLs
|
||||
// fetched by this node's downloader. nil means the secure default
|
||||
// (validation on, no extra allowlist) — existing nodes upgraded in
|
||||
// place stay protected without admin action.
|
||||
URLValidation *URLValidationSetting `json:"url_validation,omitempty"`
|
||||
}
|
||||
|
||||
URLValidationSetting struct {
|
||||
// Disabled turns the SSRF check off entirely on this node. Only set
|
||||
// this when the downloader runs in a network segment that cannot
|
||||
// reach any internal asset (e.g. dedicated egress namespace).
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
// AllowedHosts is a list of hostnames or IP literals that bypass all
|
||||
// checks. Exact, case-insensitive match against url.Hostname().
|
||||
AllowedHosts []string `json:"allowed_hosts,omitempty"`
|
||||
// AllowedCIDRs is a list of CIDR blocks (IPv4 or IPv6) whose IPs are
|
||||
// treated as safe even if they would otherwise be rejected (private,
|
||||
// link-local, etc.). Use this to whitelist a LAN range like
|
||||
// "192.168.10.0/24" so a local NAS can be fetched.
|
||||
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
|
||||
}
|
||||
|
||||
DownloaderProvider string
|
||||
|
||||
QBittorrentSetting struct {
|
||||
Server string `json:"server,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
TempPath string `json:"temp_path,omitempty"`
|
||||
}
|
||||
|
||||
Aria2Setting struct {
|
||||
Server string `json:"server,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
TempPath string `json:"temp_path,omitempty"`
|
||||
}
|
||||
|
||||
TaskPublicState struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorHistory []string `json:"error_history,omitempty"`
|
||||
ExecutedDuration time.Duration `json:"executed_duration,omitempty"`
|
||||
RetryCount int `json:"retry_count,omitempty"`
|
||||
ResumeTime int64 `json:"resume_time,omitempty"`
|
||||
SlaveTaskProps *SlaveTaskProps `json:"slave_task_props,omitempty"`
|
||||
}
|
||||
|
||||
SlaveTaskProps struct {
|
||||
NodeID int `json:"node_id,omitempty"`
|
||||
MasterSiteURl string `json:"master_site_u_rl,omitempty"`
|
||||
MasterSiteID string `json:"master_site_id,omitempty"`
|
||||
MasterSiteVersion string `json:"master_site_version,omitempty"`
|
||||
}
|
||||
|
||||
EntityProps struct {
|
||||
UnlinkOnly bool `json:"unlink_only,omitempty"`
|
||||
EncryptMetadata *EncryptMetadata `json:"encrypt_metadata,omitempty"`
|
||||
}
|
||||
|
||||
Cipher string
|
||||
|
||||
EncryptMetadata struct {
|
||||
Algorithm Cipher `json:"algorithm"`
|
||||
Key []byte `json:"key"`
|
||||
KeyPlainText []byte `json:"key_plain_text,omitempty"`
|
||||
IV []byte `json:"iv"`
|
||||
}
|
||||
|
||||
DavAccountProps struct {
|
||||
}
|
||||
|
||||
PolicyType string
|
||||
|
||||
FileProps struct {
|
||||
View *ExplorerView `json:"view,omitempty"`
|
||||
}
|
||||
|
||||
ExplorerView struct {
|
||||
PageSize int `json:"page_size" binding:"min=50"`
|
||||
Order string `json:"order,omitempty" binding:"max=255"`
|
||||
OrderDirection string `json:"order_direction,omitempty" binding:"eq=asc|eq=desc"`
|
||||
View string `json:"view,omitempty" binding:"eq=list|eq=grid|eq=gallery"`
|
||||
Thumbnail bool `json:"thumbnail,omitempty"`
|
||||
GalleryWidth int `json:"gallery_width,omitempty" binding:"min=50,max=500"`
|
||||
Columns []ListViewColumn `json:"columns,omitempty" binding:"max=1000"`
|
||||
}
|
||||
|
||||
ListViewColumn struct {
|
||||
Type int `json:"type" binding:"min=0"`
|
||||
Width *int `json:"width,omitempty"`
|
||||
Props *ColumTypeProps `json:"props,omitempty"`
|
||||
}
|
||||
|
||||
ColumTypeProps struct {
|
||||
MetadataKey string `json:"metadata_key,omitempty" binding:"max=255"`
|
||||
CustomPropsID string `json:"custom_props_id,omitempty" binding:"max=255"`
|
||||
}
|
||||
|
||||
ShareProps struct {
|
||||
// Whether to share view setting from owner
|
||||
ShareView bool `json:"share_view,omitempty"`
|
||||
// Whether to automatically show readme file in share view
|
||||
ShowReadMe bool `json:"show_read_me,omitempty"`
|
||||
}
|
||||
|
||||
OAuthClientProps struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
RefreshTokenTTL int64 `json:"refresh_token_ttl,omitempty"` // in seconds, 0 means default
|
||||
}
|
||||
|
||||
FileTypeIconSetting struct {
|
||||
Exts []string `json:"exts"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
ColorDark string `json:"color_dark,omitempty"`
|
||||
Img string `json:"img,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
GroupPermissionIsAdmin = GroupPermission(iota)
|
||||
GroupPermissionIsAnonymous
|
||||
GroupPermissionShare
|
||||
GroupPermissionWebDAV
|
||||
GroupPermissionArchiveDownload
|
||||
GroupPermissionArchiveTask
|
||||
GroupPermissionWebDAVProxy
|
||||
GroupPermissionShareDownload
|
||||
GroupPermission_CommunityPlaceholder1
|
||||
GroupPermissionRemoteDownload
|
||||
GroupPermission_CommunityPlaceholder2
|
||||
GroupPermissionRedirectedSource // not used
|
||||
GroupPermissionAdvanceDelete
|
||||
GroupPermission_CommunityPlaceholder3
|
||||
GroupPermission_CommunityPlaceholder4
|
||||
GroupPermissionSetExplicitUser_placeholder
|
||||
GroupPermissionIgnoreFileOwnership // not used
|
||||
GroupPermissionUniqueRedirectDirectLink
|
||||
)
|
||||
|
||||
const (
|
||||
NodeCapabilityNone NodeCapability = iota
|
||||
NodeCapabilityCreateArchive
|
||||
NodeCapabilityExtractArchive
|
||||
NodeCapabilityRemoteDownload
|
||||
NodeCapability_CommunityPlaceholder
|
||||
)
|
||||
|
||||
const (
|
||||
FileTypeFile FileType = iota
|
||||
FileTypeFolder
|
||||
)
|
||||
|
||||
const (
|
||||
EntityTypeVersion EntityType = iota
|
||||
EntityTypeThumbnail
|
||||
EntityTypeLivePhoto
|
||||
)
|
||||
|
||||
func FileTypeFromString(s string) FileType {
|
||||
switch s {
|
||||
case "file":
|
||||
return FileTypeFile
|
||||
case "folder":
|
||||
return FileTypeFolder
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
const (
|
||||
DavAccountReadOnly DavAccountOption = iota
|
||||
DavAccountProxy
|
||||
DavAccountDisableSysFiles
|
||||
)
|
||||
|
||||
const (
|
||||
PolicyTypeLocal = "local"
|
||||
PolicyTypeQiniu = "qiniu"
|
||||
PolicyTypeUpyun = "upyun"
|
||||
PolicyTypeOss = "oss"
|
||||
PolicyTypeCos = "cos"
|
||||
PolicyTypeS3 = "s3"
|
||||
PolicyTypeKs3 = "ks3"
|
||||
PolicyTypeOd = "onedrive"
|
||||
PolicyTypeRemote = "remote"
|
||||
PolicyTypeObs = "obs"
|
||||
)
|
||||
|
||||
const (
|
||||
DownloaderProviderAria2 = DownloaderProvider("aria2")
|
||||
DownloaderProviderQBittorrent = DownloaderProvider("qbittorrent")
|
||||
)
|
||||
|
||||
type (
|
||||
ViewerAction string
|
||||
ViewerType string
|
||||
)
|
||||
|
||||
const (
|
||||
ViewerActionView = "view"
|
||||
ViewerActionEdit = "edit"
|
||||
|
||||
ViewerTypeBuiltin = "builtin"
|
||||
ViewerTypeWopi = "wopi"
|
||||
ViewerTypeCustom = "custom"
|
||||
)
|
||||
|
||||
type (
|
||||
Viewer struct {
|
||||
ID string `json:"id"`
|
||||
Type ViewerType `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Exts []string `json:"exts"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
WopiActions map[string]map[ViewerAction]string `json:"wopi_actions,omitempty"`
|
||||
Props map[string]string `json:"props,omitempty"`
|
||||
MaxSize int64 `json:"max_size,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
Templates []NewFileTemplate `json:"templates,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
RequiredGroupPermission []GroupPermission `json:"required_group_permission,omitempty"`
|
||||
}
|
||||
ViewerGroup struct {
|
||||
Viewers []Viewer `json:"viewers"`
|
||||
}
|
||||
|
||||
DefaultViewerMapping map[string]string
|
||||
|
||||
NewFileTemplate struct {
|
||||
Ext string `json:"ext"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
CustomPropsType string
|
||||
CustomProps struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type CustomPropsType `json:"type"`
|
||||
Max int `json:"max,omitempty"`
|
||||
Min int `json:"min,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
CustomPropsTypeText = "text"
|
||||
CustomPropsTypeNumber = "number"
|
||||
CustomPropsTypeBoolean = "boolean"
|
||||
CustomPropsTypeSelect = "select"
|
||||
CustomPropsTypeMultiSelect = "multi_select"
|
||||
CustomPropsTypeLink = "link"
|
||||
CustomPropsTypeRating = "rating"
|
||||
)
|
||||
|
||||
const (
|
||||
ProfilePublicShareOnly = ShareLinksInProfileLevel("")
|
||||
ProfileAllShare = ShareLinksInProfileLevel("all_share")
|
||||
ProfileHideShare = ShareLinksInProfileLevel("hide_share")
|
||||
)
|
||||
|
||||
const (
|
||||
CipherAES256CTR Cipher = "aes-256-ctr"
|
||||
)
|
||||
|
||||
const (
|
||||
ScopeProfile = "profile"
|
||||
ScopeEmail = "email"
|
||||
ScopeOpenID = "openid"
|
||||
ScopeOfflineAccess = "offline_access"
|
||||
ScopeUserInfoRead = "UserInfo.Read"
|
||||
ScopeUserInfoWrite = "UserInfo.Write"
|
||||
ScopeUserSecurityInfoRead = "UserSecurityInfo.Read"
|
||||
ScopeUserSecurityInfoWrite = "UserSecurityInfo.Write"
|
||||
ScopeWorkflowRead = "Workflow.Read"
|
||||
ScopeWorkflowWrite = "Workflow.Write"
|
||||
ScopeAdminRead = "Admin.Read"
|
||||
ScopeAdminWrite = "Admin.Write"
|
||||
ScopeFilesRead = "Files.Read"
|
||||
ScopeFilesWrite = "Files.Write"
|
||||
ScopeSharesRead = "Shares.Read"
|
||||
ScopeSharesWrite = "Shares.Write"
|
||||
ScopeFinanceRead = "Finance.Read"
|
||||
ScopeFinanceWrite = "Finance.Write"
|
||||
ScopeDavAccountRead = "DavAccount.Read"
|
||||
ScopeDavAccountWrite = "DavAccount.Write"
|
||||
)
|
||||
@@ -0,0 +1,691 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/davaccount"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/file"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/oauthgrant"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/passkey"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/schema"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/task"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/user"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
type (
|
||||
// Ctx keys for eager loading options.
|
||||
LoadUserGroup struct{}
|
||||
LoadUserPasskey struct{}
|
||||
|
||||
UserCtx struct{}
|
||||
UserIDCtx struct{}
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserEmailExisted = errors.New("user email has been registered")
|
||||
ErrInactiveUserExisted = errors.New("email already registered but not activated")
|
||||
ErrorUnknownPasswordType = errors.New("unknown password type")
|
||||
ErrorIncorrectPassword = errors.New("incorrect password")
|
||||
ErrInsufficientPoints = errors.New("insufficient points")
|
||||
// ErrInsufficientCapacity is returned by ReserveStorage when the atomic
|
||||
// UPDATE would push the user's storage past their quota. The FS layer
|
||||
// translates this into fs.ErrInsufficientCapacity for API consumers.
|
||||
ErrInsufficientCapacity = errors.New("insufficient storage capacity")
|
||||
)
|
||||
|
||||
type (
|
||||
UserClient interface {
|
||||
TxOperator
|
||||
// New creates a new user. If user email registered, existed User will be returned.
|
||||
Create(ctx context.Context, args *NewUserArgs) (*ent.User, error)
|
||||
// GetByEmail get the user with given email, user status is ignored.
|
||||
GetByEmail(ctx context.Context, email string) (*ent.User, error)
|
||||
// GetByID get user by its ID, user status is ignored.
|
||||
GetByID(ctx context.Context, id int) (*ent.User, error)
|
||||
// GetActiveByID get user by its ID, only active user will be returned.
|
||||
GetActiveByID(ctx context.Context, id int) (*ent.User, error)
|
||||
// SetStatus Set user to given status
|
||||
SetStatus(ctx context.Context, u *ent.User, status user.Status) (*ent.User, error)
|
||||
// AnonymousUser returns the anonymous user.
|
||||
AnonymousUser(ctx context.Context) (*ent.User, error)
|
||||
// GetLoginUserByID returns the login user by its ID. It emits some errors and fallback to anonymous user.
|
||||
GetLoginUserByID(ctx context.Context, uid int) (*ent.User, error)
|
||||
// GetLoginUserByEmail returns the login user by its WebDAV credentials.
|
||||
GetActiveByDavAccount(ctx context.Context, email, pwd string) (*ent.User, error)
|
||||
// SaveSettings saves user settings.
|
||||
SaveSettings(ctx context.Context, u *ent.User) error
|
||||
// SearchActive search active users by Email or nickname.
|
||||
SearchActive(ctx context.Context, limit int, keyword string) ([]*ent.User, error)
|
||||
// ApplyStorageDiff apply storage diff to user.
|
||||
ApplyStorageDiff(ctx context.Context, diffs StorageDiff) error
|
||||
// ReserveStorage atomically adds size bytes to user uid's storage,
|
||||
// enforcing storage + size <= maxTotal at the database level. When
|
||||
// maxTotal <= 0 the guard is skipped (unlimited quota). Returns
|
||||
// ErrInsufficientCapacity when the guard fails (0 rows affected).
|
||||
// Negative size is treated as an unconditional release.
|
||||
ReserveStorage(ctx context.Context, uid int, size int64, maxTotal int64) error
|
||||
// ReleaseStorage unconditionally subtracts size bytes from user uid's
|
||||
// storage. Used to undo a previous ReserveStorage on failure paths.
|
||||
ReleaseStorage(ctx context.Context, uid int, size int64) error
|
||||
// UpdateAvatar updates user avatar.
|
||||
UpdateAvatar(ctx context.Context, u *ent.User, avatar string) (*ent.User, error)
|
||||
// UpdateNickname updates user nickname.
|
||||
UpdateNickname(ctx context.Context, u *ent.User, name string) (*ent.User, error)
|
||||
// UpdatePassword updates user password.
|
||||
UpdatePassword(ctx context.Context, u *ent.User, newPassword string) (*ent.User, error)
|
||||
// UpdateTwoFASecret updates user two factor secret.
|
||||
UpdateTwoFASecret(ctx context.Context, u *ent.User, secret string) (*ent.User, error)
|
||||
// ListPasskeys list user's passkeys.
|
||||
ListPasskeys(ctx context.Context, uid int) ([]*ent.Passkey, error)
|
||||
// AddPasskey add passkey to user.
|
||||
AddPasskey(ctx context.Context, uid int, name string, credential *webauthn.Credential) (*ent.Passkey, error)
|
||||
// RemovePasskey remove passkey from user.
|
||||
RemovePasskey(ctx context.Context, uid int, keyId string) error
|
||||
// MarkPasskeyUsed updates passkey used at.
|
||||
MarkPasskeyUsed(ctx context.Context, uid int, keyId string) error
|
||||
// CountByTimeRange count users by time range. Will return all records if start or end is nil.
|
||||
CountByTimeRange(ctx context.Context, start, end *time.Time) (int, error)
|
||||
// ListUsers list users with pagination.
|
||||
ListUsers(ctx context.Context, args *ListUserParameters) (*ListUserResult, error)
|
||||
// Upsert upserts a user.
|
||||
Upsert(ctx context.Context, u *ent.User, password, twoFa string) (*ent.User, error)
|
||||
// Delete deletes a user.
|
||||
Delete(ctx context.Context, uid int) error
|
||||
// CalculateStorage calculate user's storage from scratch and update user's storage.
|
||||
CalculateStorage(ctx context.Context, uid int) (int64, error)
|
||||
}
|
||||
ListUserParameters struct {
|
||||
*PaginationArgs
|
||||
GroupID int
|
||||
Status user.Status
|
||||
Nick string
|
||||
Email string
|
||||
}
|
||||
ListUserResult struct {
|
||||
*PaginationResults
|
||||
Users []*ent.User
|
||||
}
|
||||
)
|
||||
|
||||
func NewUserClient(client *ent.Client) UserClient {
|
||||
return &userClient{client: client}
|
||||
}
|
||||
|
||||
type userClient struct {
|
||||
client *ent.Client
|
||||
}
|
||||
|
||||
type (
|
||||
// NewUserArgs args to create a new user
|
||||
NewUserArgs struct {
|
||||
Email string
|
||||
Nick string // Optional
|
||||
PlainPassword string
|
||||
Status user.Status
|
||||
GroupID int
|
||||
Avatar string // Optional
|
||||
Language string // Optional
|
||||
}
|
||||
CreateStoragePackArgs struct {
|
||||
UserID int
|
||||
Name string
|
||||
Size int64
|
||||
ExpireAt time.Time
|
||||
}
|
||||
)
|
||||
|
||||
func (c *userClient) CountByTimeRange(ctx context.Context, start, end *time.Time) (int, error) {
|
||||
if start == nil || end == nil {
|
||||
return c.client.User.Query().Count(ctx)
|
||||
}
|
||||
return c.client.User.Query().Where(user.CreatedAtGTE(*start), user.CreatedAtLT(*end)).Count(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) UpdateNickname(ctx context.Context, u *ent.User, name string) (*ent.User, error) {
|
||||
return c.client.User.UpdateOne(u).SetNick(name).Save(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) UpdateAvatar(ctx context.Context, u *ent.User, avatar string) (*ent.User, error) {
|
||||
return c.client.User.UpdateOne(u).SetAvatar(avatar).Save(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) UpdateTwoFASecret(ctx context.Context, u *ent.User, secret string) (*ent.User, error) {
|
||||
if secret == "" {
|
||||
return c.client.User.UpdateOne(u).ClearTwoFactorSecret().Save(ctx)
|
||||
}
|
||||
return c.client.User.UpdateOne(u).SetTwoFactorSecret(secret).Save(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) UpdatePassword(ctx context.Context, u *ent.User, newPassword string) (*ent.User, error) {
|
||||
digest, err := digestPassword(newPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.client.User.UpdateOne(u).SetPassword(digest).Save(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) SetClient(newClient *ent.Client) TxOperator {
|
||||
return &userClient{client: newClient}
|
||||
}
|
||||
|
||||
func (c *userClient) GetClient() *ent.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *userClient) ListPasskeys(ctx context.Context, uid int) ([]*ent.Passkey, error) {
|
||||
return c.client.Passkey.Query().Where(passkey.UserID(uid)).All(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) AddPasskey(ctx context.Context, uid int, name string, credential *webauthn.Credential) (*ent.Passkey, error) {
|
||||
return c.client.Passkey.Create().
|
||||
SetName(name).
|
||||
SetCredentialID(base64.StdEncoding.EncodeToString(credential.ID)).
|
||||
SetUserID(uid).
|
||||
SetCredential(credential).
|
||||
Save(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) RemovePasskey(ctx context.Context, uid int, keyId string) error {
|
||||
ctx = schema.SkipSoftDelete(ctx)
|
||||
_, err := c.client.Passkey.Delete().Where(passkey.UserID(uid), passkey.CredentialID(keyId)).Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *userClient) MarkPasskeyUsed(ctx context.Context, uid int, keyId string) error {
|
||||
_, err := c.client.Passkey.Update().Where(passkey.UserID(uid), passkey.CredentialID(keyId)).SetUsedAt(time.Now()).Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *userClient) Delete(ctx context.Context, uid int) error {
|
||||
// Dav accounts
|
||||
if _, err := c.client.DavAccount.Delete().Where(davaccount.OwnerID(uid)).Exec(schema.SkipSoftDelete(ctx)); err != nil {
|
||||
return fmt.Errorf("failed to delete dav accounts: %w", err)
|
||||
}
|
||||
|
||||
// Passkeys
|
||||
if _, err := c.client.Passkey.Delete().Where(passkey.UserID(uid)).Exec(schema.SkipSoftDelete(ctx)); err != nil {
|
||||
return fmt.Errorf("failed to delete passkeys: %w", err)
|
||||
}
|
||||
|
||||
// Tasks
|
||||
if _, err := c.client.Task.Delete().Where(task.UserTasks(uid)).Exec(ctx); err != nil {
|
||||
return fmt.Errorf("failed to delete tasks: %w", err)
|
||||
}
|
||||
|
||||
// OAuth grants
|
||||
if _, err := c.client.OAuthGrant.Delete().Where(oauthgrant.UserID(uid)).Exec(schema.SkipSoftDelete(ctx)); err != nil {
|
||||
return fmt.Errorf("failed to delete oauth grants: %w", err)
|
||||
}
|
||||
|
||||
return c.client.User.DeleteOneID(uid).Exec(schema.SkipSoftDelete(ctx))
|
||||
}
|
||||
|
||||
func (c *userClient) ApplyStorageDiff(ctx context.Context, diffs StorageDiff) error {
|
||||
ae := serializer.NewAggregateError()
|
||||
for uid, diff := range diffs {
|
||||
err := runWithDeadlockRetry(func() error {
|
||||
return c.client.User.Update().Where(user.ID(uid)).AddStorage(diff).Exec(ctx)
|
||||
})
|
||||
if err != nil {
|
||||
ae.Add(fmt.Sprintf("%d", uid), fmt.Errorf("failed to apply storage diff for user %d: %w", uid, err))
|
||||
}
|
||||
}
|
||||
|
||||
return ae.Aggregate()
|
||||
}
|
||||
|
||||
// isMySQLDeadlock reports whether err is a MySQL Error 1213 deadlock.
|
||||
func isMySQLDeadlock(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "Error 1213")
|
||||
}
|
||||
|
||||
// runWithDeadlockRetry runs fn up to maxRetries times, backing off between
|
||||
// attempts on MySQL Error 1213 (deadlock) errors. Any other error is returned
|
||||
// immediately.
|
||||
//
|
||||
// CAUTION: Only safe for statements that run in AUTO-COMMIT mode. A deadlock
|
||||
// on a statement running inside an explicit BEGIN/COMMIT causes InnoDB to
|
||||
// roll back the ENTIRE transaction server-side; re-issuing the same statement
|
||||
// against the dead tx handle will not resurrect the transaction — it will
|
||||
// either error out or silently apply outside the tx. Use this helper only
|
||||
// for ApplyStorageDiff (which issues one auto-commit UPDATE per user).
|
||||
func runWithDeadlockRetry(fn func() error) error {
|
||||
const maxRetries = 3
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isMySQLDeadlock(err) || attempt == maxRetries-1 {
|
||||
return err
|
||||
}
|
||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// ReserveStorage atomically adds size bytes to user uid's storage while
|
||||
// enforcing an optional quota. See UserClient.ReserveStorage.
|
||||
//
|
||||
// This method deliberately does NOT retry on MySQL deadlocks. Callers that
|
||||
// invoke it via a tx-scoped client (see Tx.ReserveStorage) must let a
|
||||
// deadlock propagate so the tx is aborted and the placeholder writes are
|
||||
// rolled back with it.
|
||||
func (c *userClient) ReserveStorage(ctx context.Context, uid int, size, maxTotal int64) error {
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
if size < 0 {
|
||||
return c.ReleaseStorage(ctx, uid, -size)
|
||||
}
|
||||
|
||||
q := c.client.User.Update().Where(user.ID(uid))
|
||||
if maxTotal > 0 {
|
||||
// storage + size <= maxTotal <=> storage <= maxTotal - size.
|
||||
// When maxTotal < size the predicate is unsatisfiable (returns
|
||||
// zero rows) and we correctly report ErrInsufficientCapacity.
|
||||
q = q.Where(user.StorageLTE(maxTotal - size))
|
||||
}
|
||||
n, err := q.AddStorage(size).Save(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrInsufficientCapacity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseStorage unconditionally subtracts size bytes from user uid's
|
||||
// storage. Like ReserveStorage, it does not retry on MySQL deadlocks.
|
||||
func (c *userClient) ReleaseStorage(ctx context.Context, uid int, size int64) error {
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.client.User.Update().Where(user.ID(uid)).AddStorage(-size).Exec(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) CalculateStorage(ctx context.Context, uid int) (int64, error) {
|
||||
var sum int64
|
||||
batchSize := 30000
|
||||
offset := 0
|
||||
|
||||
for {
|
||||
allFiles, err := c.client.File.Query().
|
||||
Where(file.HasOwnerWith(user.ID(uid))).
|
||||
Where(file.Type(int(types.FileTypeFile))).
|
||||
WithEntities().
|
||||
Offset(offset).
|
||||
Limit(batchSize).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to list user files: %w", err)
|
||||
}
|
||||
|
||||
if len(allFiles) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, file := range allFiles {
|
||||
for _, entity := range file.Edges.Entities {
|
||||
sum += entity.Size
|
||||
}
|
||||
}
|
||||
|
||||
offset += batchSize
|
||||
}
|
||||
|
||||
if _, err := c.client.User.UpdateOneID(uid).SetStorage(sum).Save(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
func (c *userClient) SetStatus(ctx context.Context, u *ent.User, status user.Status) (*ent.User, error) {
|
||||
return c.client.User.UpdateOne(u).SetStatus(status).Save(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) Create(ctx context.Context, args *NewUserArgs) (*ent.User, error) {
|
||||
// Try to check if there's user with same email.
|
||||
if existedUser, err := c.GetByEmail(ctx, args.Email); err == nil {
|
||||
if existedUser.Status == user.StatusInactive {
|
||||
return existedUser, ErrInactiveUserExisted
|
||||
}
|
||||
return existedUser, ErrUserEmailExisted
|
||||
}
|
||||
|
||||
nick := args.Nick
|
||||
if nick == "" {
|
||||
nick = strings.Split(args.Email, "@")[0]
|
||||
}
|
||||
|
||||
userSetting := &types.UserSetting{VersionRetention: true, VersionRetentionMax: 10}
|
||||
query := c.client.User.Create().
|
||||
SetEmail(args.Email).
|
||||
SetNick(nick).
|
||||
SetStatus(args.Status).
|
||||
SetGroupID(args.GroupID).
|
||||
SetAvatar(args.Avatar)
|
||||
|
||||
if args.PlainPassword != "" {
|
||||
pwdDigest, err := digestPassword(args.PlainPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sha256 password: %w", err)
|
||||
}
|
||||
query.SetPassword(pwdDigest)
|
||||
}
|
||||
|
||||
if args.Language != "" {
|
||||
userSetting.Language = args.Language
|
||||
}
|
||||
query.SetSettings(userSetting)
|
||||
|
||||
// Create user
|
||||
newUser, err := query.
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
if newUser.ID == 1 {
|
||||
// For the first user registered, elevate it to admin group.
|
||||
if _, err := newUser.Update().SetGroupID(1).Save(ctx); err != nil {
|
||||
return newUser, fmt.Errorf("failed to elevate user to admin: %w", err)
|
||||
}
|
||||
}
|
||||
return newUser, nil
|
||||
}
|
||||
|
||||
func (c *userClient) GetByEmail(ctx context.Context, email string) (*ent.User, error) {
|
||||
return withUserEagerLoading(ctx, c.client.User.Query().Where(user.EmailEqualFold(email))).First(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) GetByID(ctx context.Context, id int) (*ent.User, error) {
|
||||
return withUserEagerLoading(ctx, c.client.User.Query().Where(user.ID(id))).First(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) GetActiveByID(ctx context.Context, id int) (*ent.User, error) {
|
||||
return withUserEagerLoading(
|
||||
ctx,
|
||||
c.client.User.Query().
|
||||
Where(user.ID(id)).
|
||||
Where(user.StatusEQ(user.StatusActive)),
|
||||
).First(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) GetActiveByDavAccount(ctx context.Context, email, pwd string) (*ent.User, error) {
|
||||
ctx = context.WithValue(ctx, LoadUserGroup{}, true)
|
||||
return withUserEagerLoading(
|
||||
ctx,
|
||||
c.client.User.Query().
|
||||
Where(user.EmailEqualFold(email)).
|
||||
Where(user.StatusEQ(user.StatusActive)).
|
||||
WithDavAccounts(func(q *ent.DavAccountQuery) {
|
||||
q.Where(davaccount.Password(pwd))
|
||||
}),
|
||||
).First(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) GetLoginUserByID(ctx context.Context, uid int) (*ent.User, error) {
|
||||
ctx = context.WithValue(ctx, LoadUserGroup{}, true)
|
||||
if uid > 0 {
|
||||
expectedUser, err := c.GetActiveByID(ctx, uid)
|
||||
if err == nil {
|
||||
return expectedUser, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to get user by id: %w", err)
|
||||
}
|
||||
|
||||
anonymous, err := c.AnonymousUser(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to construct anonymous user: %w", err)
|
||||
}
|
||||
|
||||
return anonymous, nil
|
||||
}
|
||||
|
||||
func (c *userClient) SearchActive(ctx context.Context, limit int, keyword string) ([]*ent.User, error) {
|
||||
ctx = context.WithValue(ctx, LoadUserGroup{}, true)
|
||||
return withUserEagerLoading(
|
||||
ctx,
|
||||
c.client.User.Query().
|
||||
Where(user.Or(user.EmailContainsFold(keyword), user.NickContainsFold(keyword))).
|
||||
Where(user.StatusEQ(user.StatusActive)).
|
||||
Limit(limit),
|
||||
).All(ctx)
|
||||
}
|
||||
|
||||
func (c *userClient) SaveSettings(ctx context.Context, u *ent.User) error {
|
||||
return c.client.User.UpdateOne(u).SetSettings(u.Settings).Exec(ctx)
|
||||
}
|
||||
|
||||
// UserFromContext get user from context
|
||||
func UserFromContext(ctx context.Context) *ent.User {
|
||||
u, _ := ctx.Value(UserCtx{}).(*ent.User)
|
||||
return u
|
||||
}
|
||||
|
||||
// UserIDFromContext get user id from context.
|
||||
func UserIDFromContext(ctx context.Context) int {
|
||||
uid, ok := ctx.Value(UserIDCtx{}).(int)
|
||||
if !ok {
|
||||
u := UserFromContext(ctx)
|
||||
if u != nil {
|
||||
uid = u.ID
|
||||
}
|
||||
}
|
||||
|
||||
return uid
|
||||
}
|
||||
|
||||
func (c *userClient) AnonymousUser(ctx context.Context) (*ent.User, error) {
|
||||
groupClient := NewGroupClient(c.client, "", nil)
|
||||
anonymousGroup, err := groupClient.AnonymousGroup(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("anyonymous group not found: %w", err)
|
||||
}
|
||||
|
||||
// TODO: save into cache
|
||||
anonymous := &ent.User{
|
||||
Settings: &types.UserSetting{},
|
||||
}
|
||||
anonymous.SetGroup(anonymousGroup)
|
||||
return anonymous, nil
|
||||
}
|
||||
|
||||
func (c *userClient) ListUsers(ctx context.Context, args *ListUserParameters) (*ListUserResult, error) {
|
||||
query := c.client.User.Query()
|
||||
if args.GroupID != 0 {
|
||||
query = query.Where(user.GroupUsers(args.GroupID))
|
||||
}
|
||||
if args.Status != "" {
|
||||
query = query.Where(user.StatusEQ(args.Status))
|
||||
}
|
||||
if args.Nick != "" {
|
||||
query = query.Where(user.NickContainsFold(args.Nick))
|
||||
}
|
||||
if args.Email != "" {
|
||||
query = query.Where(user.EmailContainsFold(args.Email))
|
||||
}
|
||||
query.Order(getUserOrderOption(args)...)
|
||||
|
||||
// Count total items
|
||||
total, err := query.Clone().Count(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users, err := withUserEagerLoading(ctx, query).Limit(args.PageSize).Offset(args.Page * args.PageSize).All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListUserResult{
|
||||
PaginationResults: &PaginationResults{
|
||||
TotalItems: total,
|
||||
Page: args.Page,
|
||||
PageSize: args.PageSize,
|
||||
},
|
||||
Users: users,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *userClient) Upsert(ctx context.Context, u *ent.User, password, twoFa string) (*ent.User, error) {
|
||||
if u.ID == 0 {
|
||||
q := c.client.User.Create().
|
||||
SetEmail(u.Email).
|
||||
SetNick(u.Nick).
|
||||
SetAvatar(u.Avatar).
|
||||
SetStatus(u.Status).
|
||||
SetGroupID(u.GroupUsers).
|
||||
SetPassword(u.Password).
|
||||
SetSettings(&types.UserSetting{})
|
||||
|
||||
if password != "" {
|
||||
pwdDigest, err := digestPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sha256 password: %w", err)
|
||||
}
|
||||
q.SetPassword(pwdDigest)
|
||||
}
|
||||
|
||||
return q.Save(ctx)
|
||||
}
|
||||
|
||||
q := c.client.User.UpdateOne(u).
|
||||
SetEmail(u.Email).
|
||||
SetNick(u.Nick).
|
||||
SetAvatar(u.Avatar).
|
||||
SetStatus(u.Status).
|
||||
SetGroupID(u.GroupUsers)
|
||||
|
||||
if password != "" {
|
||||
pwdDigest, err := digestPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sha256 password: %w", err)
|
||||
}
|
||||
q.SetPassword(pwdDigest)
|
||||
}
|
||||
|
||||
if twoFa != "" {
|
||||
q.ClearTwoFactorSecret()
|
||||
}
|
||||
|
||||
return q.Save(ctx)
|
||||
}
|
||||
|
||||
func getUserOrderOption(args *ListUserParameters) []user.OrderOption {
|
||||
orderTerm := getOrderTerm(args.Order)
|
||||
switch args.OrderBy {
|
||||
case user.FieldNick:
|
||||
return []user.OrderOption{user.ByNick(orderTerm), user.ByID(orderTerm)}
|
||||
case user.FieldStorage:
|
||||
return []user.OrderOption{user.ByStorage(orderTerm), user.ByID(orderTerm)}
|
||||
case user.FieldEmail:
|
||||
return []user.OrderOption{user.ByEmail(orderTerm), user.ByID(orderTerm)}
|
||||
case user.FieldUpdatedAt:
|
||||
return []user.OrderOption{user.ByUpdatedAt(orderTerm), user.ByID(orderTerm)}
|
||||
default:
|
||||
return []user.OrderOption{user.ByID(orderTerm)}
|
||||
}
|
||||
}
|
||||
|
||||
// IsAnonymousUser check if given user is anonymous user.
|
||||
func IsAnonymousUser(u *ent.User) bool {
|
||||
return u.ID == 0
|
||||
}
|
||||
|
||||
// CheckPassword 根据明文校验密码
|
||||
func CheckPassword(u *ent.User, password string) error {
|
||||
// 根据存储密码拆分为 Salt 和 Digest
|
||||
passwordStore := strings.Split(u.Password, ":")
|
||||
if len(passwordStore) != 2 && len(passwordStore) != 3 {
|
||||
return ErrorUnknownPasswordType
|
||||
}
|
||||
|
||||
// 兼容V2密码,升级后存储格式为: md5:$HASH:$SALT
|
||||
if len(passwordStore) == 3 {
|
||||
if passwordStore[0] != "md5" {
|
||||
return ErrorUnknownPasswordType
|
||||
}
|
||||
hash := md5.New()
|
||||
_, err := hash.Write([]byte(passwordStore[2] + password))
|
||||
bs := hex.EncodeToString(hash.Sum(nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bs != passwordStore[1] {
|
||||
return ErrorIncorrectPassword
|
||||
}
|
||||
}
|
||||
|
||||
//计算 Salt 和密码组合的SHA1摘要
|
||||
var hasher hash.Hash
|
||||
if len(passwordStore[1]) == 64 {
|
||||
hasher = sha256.New()
|
||||
} else {
|
||||
// Compatible with V3
|
||||
hasher = sha1.New()
|
||||
}
|
||||
|
||||
_, err := hasher.Write([]byte(password + passwordStore[0]))
|
||||
bs := hex.EncodeToString(hasher.Sum(nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bs != passwordStore[1] {
|
||||
return ErrorIncorrectPassword
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func withUserEagerLoading(ctx context.Context, q *ent.UserQuery) *ent.UserQuery {
|
||||
if v, ok := ctx.Value(LoadUserGroup{}).(bool); ok && v {
|
||||
q.WithGroup(func(gq *ent.GroupQuery) {
|
||||
withGroupEagerLoading(ctx, gq)
|
||||
})
|
||||
}
|
||||
if v, ok := ctx.Value(LoadUserPasskey{}).(bool); ok && v {
|
||||
q.WithPasskey()
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func digestPassword(password string) (string, error) {
|
||||
//生成16位 Salt
|
||||
salt := util.RandStringRunesCrypto(32)
|
||||
|
||||
//计算 Salt 和密码组合的SHA1摘要
|
||||
hash := sha256.New()
|
||||
_, err := hash.Write([]byte(password + salt))
|
||||
bs := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
//存储 Salt 值和摘要, ":"分割
|
||||
return salt + ":" + string(bs), nil
|
||||
}
|
||||
Reference in New Issue
Block a user