Compare commits

..

29 Commits
4.2.0 ... 4.5.0

Author SHA1 Message Date
Aaron Liu
f73583b370 update submodule 2025-08-12 13:27:33 +08:00
Aaron Liu
c0132a10cb feat(dashboard): upgrade promotion 2025-08-12 13:27:07 +08:00
Aaron Liu
927c3bff00 fix(dep): remove undefined dependency 2025-08-12 13:12:54 +08:00
Aaron Liu
bb9b42eb10 feat(audit): flush audit logs into DB in a standalone goroutine 2025-08-12 13:10:55 +08:00
Aaron Liu
5f18d277c8 fix(conf): ProxyHeader should be optional (#2760) 2025-08-12 09:53:15 +08:00
Aaron Liu
b0057fe92f feat(profile): options to select why kind of share links to show in user's profile (#2453) 2025-08-12 09:52:47 +08:00
Darren Yu
bb3db2e326 fix(middleware): left deafult ProxyHeader config item as blank to reduce risk of fake xff (#2760) 2025-08-12 09:35:36 +08:00
Aaron Liu
8deeadb1e5 fix(middleware): only select first client IP from X-Forwarded-For (#2748) 2025-08-10 10:47:29 +08:00
Aaron Liu
8688069fac refactor(mail): migrate to wneessen/go-mail (#2738) 2025-08-10 10:40:21 +08:00
Aaron Liu
4c08644b05 fix(dbfs): generate thumbnail blob should not update file modification date 2025-08-10 09:38:27 +08:00
Aaron Liu
4c976b8627 feat(blob path): diable {path} magic var for blob path 2025-08-07 11:35:28 +08:00
Aaron Liu
b0375f5a24 fix(recycle): nil pointer if failed to found files in trash (#2750) 2025-08-07 11:03:02 +08:00
Aaron Liu
48e9719336 fix(dbfs): deadlock in SQLite while creating upload session 2025-08-07 10:30:44 +08:00
Darren Yu
7654ce889c fix(blob path): Random variables in blob save path be wrongly fixed (#2741)
* fix(blob path): Random variables in blob save path be wrongly fixed

* feat(blob path): Use regex to match all magic variables
2025-08-05 20:29:14 +08:00
Aaron Liu
80b25e88ee fix(dbfs): file modified_at should not be updated by ent 2025-08-05 15:11:32 +08:00
Aaron Liu
e31a6cbcb3 fix(workflow): concurrent read&write to progress map while transfer files in batch (#2737) 2025-08-05 12:02:17 +08:00
Curious
51d9e06f21 chore(docker compose): pin postgres to major version (#2723) 2025-08-04 14:52:21 +08:00
Git'Fellow
36be9b7a19 Fix typos on README (#2693) 2025-07-31 11:18:48 +08:00
Aaron Liu
c8c2a60adb feat(storage policy): set deny/allow list for file extension and custom regexp (#2695) 2025-07-25 11:32:04 +08:00
Aaron Liu
60bf0e02b3 fix(qbittorrent): download task option not working (#2666) 2025-07-25 10:15:55 +08:00
omiku
488f32512d Add Kingsoft Cloud object storage policy to solve the cross-domain and friendly file name incompatibility problem of s3 compatible storage policy. (#2665)
* 新增金山云对象存储策略,解决s3兼容存储策略的跨域及友好文件名不兼容问题

* fix bug&add download Expire time args

* Handling of expiration times when they may be empty
2025-07-21 16:08:22 +08:00
Aaron Liu
1cdccf5fc9 feat(thumb): adding option to define custom input argument for FFmpeg (#2657) 2025-07-15 14:11:42 +08:00
Aaron Liu
15762cb393 feat(thumb): support output webp thumbnails for vips generator (#2657) 2025-07-15 13:51:23 +08:00
Aaron Liu
e96b595622 feat(direct link): add option to get direct link with download enforced (#2651) 2025-07-15 13:22:04 +08:00
Aaron Liu
d19fc0e75c feat(remote download): sanitize file names with special characters (#2648) 2025-07-15 12:00:39 +08:00
Aaron Liu
195d68c535 chore(docker): add LibRAW into docker image (#2645) 2025-07-15 11:01:44 +08:00
Aaron Liu
000124f6c7 feat(ui): custom HTML content in predefined locations (#2621) 2025-07-15 10:45:32 +08:00
Aaron Liu
ca57ca1ba0 feat(custom): custom sidebar items 2025-07-15 10:41:13 +08:00
Aaron Liu
3cda4d1ef7 feat(fs): custom properties for files (#2407) 2025-07-12 11:15:33 +08:00
60 changed files with 1519 additions and 655 deletions

View File

@@ -3,7 +3,7 @@ FROM alpine:latest
WORKDIR /cloudreve
RUN apk update \
&& apk add --no-cache tzdata vips-tools ffmpeg libreoffice aria2 supervisor font-noto font-noto-cjk libheif\
&& apk add --no-cache tzdata vips-tools ffmpeg libreoffice aria2 supervisor font-noto font-noto-cjk libheif libraw-tools\
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& mkdir -p ./data/temp/aria2 \
@@ -13,7 +13,8 @@ ENV CR_ENABLE_ARIA2=1 \
CR_SETTING_DEFAULT_thumb_ffmpeg_enabled=1 \
CR_SETTING_DEFAULT_thumb_vips_enabled=1 \
CR_SETTING_DEFAULT_thumb_libreoffice_enabled=1 \
CR_SETTING_DEFAULT_media_meta_ffprobe=1
CR_SETTING_DEFAULT_media_meta_ffprobe=1 \
CR_SETTING_DEFAULT_thumb_libraw_enabled=1
COPY .build/aria2.supervisor.conf .build/entrypoint.sh ./
COPY cloudreve ./cloudreve

View File

@@ -7,7 +7,7 @@
Cloudreve
<br>
</h1>
<h4 align="center">Self-hosted file management system with muilt-cloud support.</h4>
<h4 align="center">Self-hosted file management system with multi-cloud support.</h4>
<p align="center">
<a href="https://dev.azure.com/abslantliu/Cloudreve/_build?definitionId=6">
@@ -43,13 +43,13 @@
- 💾 Integrate with Aria2/qBittorrent to download files in background, use multiple download nodes to share the load.
- 📚 Compress/Extract files, download files in batch.
- 💻 WebDAV support covering all storage providers.
- :zap:Drag&Drop to upload files or folders, with resumeable upload support.
- :zap:Drag&Drop to upload files or folders, with resumable upload support.
- :card_file_box: Extract media metadata from files, search files by metadata or tags.
- :family_woman_girl_boy: Multi-users with multi-groups.
- :link: Create share links for files and folders with expiration date.
- :eye_speech_bubble: Preview videos, images, audios, ePub files online; edit texts, diagrams, Markdown, images, Office documents online.
- :art: Customize theme colors, dark mode, PWA application, SPA, i18n.
- :rocket: All-In-One packing, with all features out-of-the-box.
- :rocket: All-in-one packaging, with all features out of the box.
- 🌈 ... ...
## :hammer_and_wrench: Deploy

View File

@@ -27,8 +27,8 @@ type system struct {
Debug bool
SessionSecret string
HashIDSalt string
GracePeriod int `validate:"gte=0"`
ProxyHeader string `validate:"required_with=Listen"`
GracePeriod int `validate:"gte=0"`
ProxyHeader string
}
type ssl struct {

View File

@@ -22,7 +22,7 @@ var SystemConfig = &system{
Debug: false,
Mode: "master",
Listen: ":5212",
ProxyHeader: "X-Forwarded-For",
ProxyHeader: "",
}
// CORSConfig 跨域配置

2
assets

Submodule assets updated: e9b91c4e03...f7aa0a09e2

View File

@@ -1,5 +1,5 @@
services:
pro:
cloudreve:
image: cloudreve/cloudreve:latest
container_name: cloudreve-backend
depends_on:
@@ -19,7 +19,10 @@ services:
- backend_data:/cloudreve/data
postgresql:
image: postgres:latest
# Best practice: Pin to major version.
# NOTE: For major version jumps:
# backup & consult https://www.postgresql.org/docs/current/pgupgrade.html
image: postgres:17
container_name: postgresql
environment:
- POSTGRES_USER=cloudreve

View File

@@ -1034,8 +1034,7 @@ func (c *FileClient) Hooks() []Hook {
// Interceptors returns the client interceptors.
func (c *FileClient) Interceptors() []Interceptor {
inters := c.inters.File
return append(inters[:len(inters):len(inters)], file.Interceptors[:]...)
return c.inters.File
}
func (c *FileClient) mutate(ctx context.Context, m *FileMutation) (Value, error) {

View File

@@ -25,8 +25,6 @@ type File struct {
CreatedAt time.Time `json:"created_at,omitempty"`
// UpdatedAt holds the value of the "updated_at" field.
UpdatedAt time.Time `json:"updated_at,omitempty"`
// DeletedAt holds the value of the "deleted_at" field.
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// Type holds the value of the "type" field.
Type int `json:"type,omitempty"`
// Name holds the value of the "name" field.
@@ -171,7 +169,7 @@ func (*File) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullInt64)
case file.FieldName:
values[i] = new(sql.NullString)
case file.FieldCreatedAt, file.FieldUpdatedAt, file.FieldDeletedAt:
case file.FieldCreatedAt, file.FieldUpdatedAt:
values[i] = new(sql.NullTime)
default:
values[i] = new(sql.UnknownType)
@@ -206,13 +204,6 @@ func (f *File) assignValues(columns []string, values []any) error {
} else if value.Valid {
f.UpdatedAt = value.Time
}
case file.FieldDeletedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field deleted_at", values[i])
} else if value.Valid {
f.DeletedAt = new(time.Time)
*f.DeletedAt = value.Time
}
case file.FieldType:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field type", values[i])
@@ -351,11 +342,6 @@ func (f *File) String() string {
builder.WriteString("updated_at=")
builder.WriteString(f.UpdatedAt.Format(time.ANSIC))
builder.WriteString(", ")
if v := f.DeletedAt; v != nil {
builder.WriteString("deleted_at=")
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
builder.WriteString("type=")
builder.WriteString(fmt.Sprintf("%v", f.Type))
builder.WriteString(", ")

View File

@@ -19,8 +19,6 @@ const (
FieldCreatedAt = "created_at"
// FieldUpdatedAt holds the string denoting the updated_at field in the database.
FieldUpdatedAt = "updated_at"
// FieldDeletedAt holds the string denoting the deleted_at field in the database.
FieldDeletedAt = "deleted_at"
// FieldType holds the string denoting the type field in the database.
FieldType = "type"
// FieldName holds the string denoting the name field in the database.
@@ -112,7 +110,6 @@ var Columns = []string{
FieldID,
FieldCreatedAt,
FieldUpdatedAt,
FieldDeletedAt,
FieldType,
FieldName,
FieldOwnerID,
@@ -146,14 +143,11 @@ func ValidColumn(column string) bool {
//
// import _ "github.com/cloudreve/Cloudreve/v4/ent/runtime"
var (
Hooks [1]ent.Hook
Interceptors [1]ent.Interceptor
Hooks [1]ent.Hook
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
DefaultCreatedAt func() time.Time
// DefaultUpdatedAt holds the default value on creation for the "updated_at" field.
DefaultUpdatedAt func() time.Time
// UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field.
UpdateDefaultUpdatedAt func() time.Time
// DefaultSize holds the default value on creation for the "size" field.
DefaultSize int64
// DefaultIsSymbolic holds the default value on creation for the "is_symbolic" field.
@@ -178,11 +172,6 @@ func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc()
}
// ByDeletedAt orders the results by the deleted_at field.
func ByDeletedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldDeletedAt, opts...).ToFunc()
}
// ByType orders the results by the type field.
func ByType(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldType, opts...).ToFunc()

View File

@@ -65,11 +65,6 @@ func UpdatedAt(v time.Time) predicate.File {
return predicate.File(sql.FieldEQ(FieldUpdatedAt, v))
}
// DeletedAt applies equality check predicate on the "deleted_at" field. It's identical to DeletedAtEQ.
func DeletedAt(v time.Time) predicate.File {
return predicate.File(sql.FieldEQ(FieldDeletedAt, v))
}
// Type applies equality check predicate on the "type" field. It's identical to TypeEQ.
func Type(v int) predicate.File {
return predicate.File(sql.FieldEQ(FieldType, v))
@@ -190,56 +185,6 @@ func UpdatedAtLTE(v time.Time) predicate.File {
return predicate.File(sql.FieldLTE(FieldUpdatedAt, v))
}
// DeletedAtEQ applies the EQ predicate on the "deleted_at" field.
func DeletedAtEQ(v time.Time) predicate.File {
return predicate.File(sql.FieldEQ(FieldDeletedAt, v))
}
// DeletedAtNEQ applies the NEQ predicate on the "deleted_at" field.
func DeletedAtNEQ(v time.Time) predicate.File {
return predicate.File(sql.FieldNEQ(FieldDeletedAt, v))
}
// DeletedAtIn applies the In predicate on the "deleted_at" field.
func DeletedAtIn(vs ...time.Time) predicate.File {
return predicate.File(sql.FieldIn(FieldDeletedAt, vs...))
}
// DeletedAtNotIn applies the NotIn predicate on the "deleted_at" field.
func DeletedAtNotIn(vs ...time.Time) predicate.File {
return predicate.File(sql.FieldNotIn(FieldDeletedAt, vs...))
}
// DeletedAtGT applies the GT predicate on the "deleted_at" field.
func DeletedAtGT(v time.Time) predicate.File {
return predicate.File(sql.FieldGT(FieldDeletedAt, v))
}
// DeletedAtGTE applies the GTE predicate on the "deleted_at" field.
func DeletedAtGTE(v time.Time) predicate.File {
return predicate.File(sql.FieldGTE(FieldDeletedAt, v))
}
// DeletedAtLT applies the LT predicate on the "deleted_at" field.
func DeletedAtLT(v time.Time) predicate.File {
return predicate.File(sql.FieldLT(FieldDeletedAt, v))
}
// DeletedAtLTE applies the LTE predicate on the "deleted_at" field.
func DeletedAtLTE(v time.Time) predicate.File {
return predicate.File(sql.FieldLTE(FieldDeletedAt, v))
}
// DeletedAtIsNil applies the IsNil predicate on the "deleted_at" field.
func DeletedAtIsNil() predicate.File {
return predicate.File(sql.FieldIsNull(FieldDeletedAt))
}
// DeletedAtNotNil applies the NotNil predicate on the "deleted_at" field.
func DeletedAtNotNil() predicate.File {
return predicate.File(sql.FieldNotNull(FieldDeletedAt))
}
// TypeEQ applies the EQ predicate on the "type" field.
func TypeEQ(v int) predicate.File {
return predicate.File(sql.FieldEQ(FieldType, v))

View File

@@ -57,20 +57,6 @@ func (fc *FileCreate) SetNillableUpdatedAt(t *time.Time) *FileCreate {
return fc
}
// SetDeletedAt sets the "deleted_at" field.
func (fc *FileCreate) SetDeletedAt(t time.Time) *FileCreate {
fc.mutation.SetDeletedAt(t)
return fc
}
// SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil.
func (fc *FileCreate) SetNillableDeletedAt(t *time.Time) *FileCreate {
if t != nil {
fc.SetDeletedAt(*t)
}
return fc
}
// SetType sets the "type" field.
func (fc *FileCreate) SetType(i int) *FileCreate {
fc.mutation.SetType(i)
@@ -413,10 +399,6 @@ func (fc *FileCreate) createSpec() (*File, *sqlgraph.CreateSpec) {
_spec.SetField(file.FieldUpdatedAt, field.TypeTime, value)
_node.UpdatedAt = value
}
if value, ok := fc.mutation.DeletedAt(); ok {
_spec.SetField(file.FieldDeletedAt, field.TypeTime, value)
_node.DeletedAt = &value
}
if value, ok := fc.mutation.GetType(); ok {
_spec.SetField(file.FieldType, field.TypeInt, value)
_node.Type = value
@@ -636,24 +618,6 @@ func (u *FileUpsert) UpdateUpdatedAt() *FileUpsert {
return u
}
// SetDeletedAt sets the "deleted_at" field.
func (u *FileUpsert) SetDeletedAt(v time.Time) *FileUpsert {
u.Set(file.FieldDeletedAt, v)
return u
}
// UpdateDeletedAt sets the "deleted_at" field to the value that was provided on create.
func (u *FileUpsert) UpdateDeletedAt() *FileUpsert {
u.SetExcluded(file.FieldDeletedAt)
return u
}
// ClearDeletedAt clears the value of the "deleted_at" field.
func (u *FileUpsert) ClearDeletedAt() *FileUpsert {
u.SetNull(file.FieldDeletedAt)
return u
}
// SetType sets the "type" field.
func (u *FileUpsert) SetType(v int) *FileUpsert {
u.Set(file.FieldType, v)
@@ -863,27 +827,6 @@ func (u *FileUpsertOne) UpdateUpdatedAt() *FileUpsertOne {
})
}
// SetDeletedAt sets the "deleted_at" field.
func (u *FileUpsertOne) SetDeletedAt(v time.Time) *FileUpsertOne {
return u.Update(func(s *FileUpsert) {
s.SetDeletedAt(v)
})
}
// UpdateDeletedAt sets the "deleted_at" field to the value that was provided on create.
func (u *FileUpsertOne) UpdateDeletedAt() *FileUpsertOne {
return u.Update(func(s *FileUpsert) {
s.UpdateDeletedAt()
})
}
// ClearDeletedAt clears the value of the "deleted_at" field.
func (u *FileUpsertOne) ClearDeletedAt() *FileUpsertOne {
return u.Update(func(s *FileUpsert) {
s.ClearDeletedAt()
})
}
// SetType sets the "type" field.
func (u *FileUpsertOne) SetType(v int) *FileUpsertOne {
return u.Update(func(s *FileUpsert) {
@@ -1289,27 +1232,6 @@ func (u *FileUpsertBulk) UpdateUpdatedAt() *FileUpsertBulk {
})
}
// SetDeletedAt sets the "deleted_at" field.
func (u *FileUpsertBulk) SetDeletedAt(v time.Time) *FileUpsertBulk {
return u.Update(func(s *FileUpsert) {
s.SetDeletedAt(v)
})
}
// UpdateDeletedAt sets the "deleted_at" field to the value that was provided on create.
func (u *FileUpsertBulk) UpdateDeletedAt() *FileUpsertBulk {
return u.Update(func(s *FileUpsert) {
s.UpdateDeletedAt()
})
}
// ClearDeletedAt clears the value of the "deleted_at" field.
func (u *FileUpsertBulk) ClearDeletedAt() *FileUpsertBulk {
return u.Update(func(s *FileUpsert) {
s.ClearDeletedAt()
})
}
// SetType sets the "type" field.
func (u *FileUpsertBulk) SetType(v int) *FileUpsertBulk {
return u.Update(func(s *FileUpsert) {

View File

@@ -41,26 +41,14 @@ func (fu *FileUpdate) SetUpdatedAt(t time.Time) *FileUpdate {
return fu
}
// SetDeletedAt sets the "deleted_at" field.
func (fu *FileUpdate) SetDeletedAt(t time.Time) *FileUpdate {
fu.mutation.SetDeletedAt(t)
return fu
}
// SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil.
func (fu *FileUpdate) SetNillableDeletedAt(t *time.Time) *FileUpdate {
// SetNillableUpdatedAt sets the "updated_at" field if the given value is not nil.
func (fu *FileUpdate) SetNillableUpdatedAt(t *time.Time) *FileUpdate {
if t != nil {
fu.SetDeletedAt(*t)
fu.SetUpdatedAt(*t)
}
return fu
}
// ClearDeletedAt clears the value of the "deleted_at" field.
func (fu *FileUpdate) ClearDeletedAt() *FileUpdate {
fu.mutation.ClearDeletedAt()
return fu
}
// SetType sets the "type" field.
func (fu *FileUpdate) SetType(i int) *FileUpdate {
fu.mutation.ResetType()
@@ -472,9 +460,6 @@ func (fu *FileUpdate) RemoveDirectLinks(d ...*DirectLink) *FileUpdate {
// Save executes the query and returns the number of nodes affected by the update operation.
func (fu *FileUpdate) Save(ctx context.Context) (int, error) {
if err := fu.defaults(); err != nil {
return 0, err
}
return withHooks(ctx, fu.sqlSave, fu.mutation, fu.hooks)
}
@@ -500,18 +485,6 @@ func (fu *FileUpdate) ExecX(ctx context.Context) {
}
}
// defaults sets the default values of the builder before save.
func (fu *FileUpdate) defaults() error {
if _, ok := fu.mutation.UpdatedAt(); !ok {
if file.UpdateDefaultUpdatedAt == nil {
return fmt.Errorf("ent: uninitialized file.UpdateDefaultUpdatedAt (forgotten import ent/runtime?)")
}
v := file.UpdateDefaultUpdatedAt()
fu.mutation.SetUpdatedAt(v)
}
return nil
}
// check runs all checks and user-defined validators on the builder.
func (fu *FileUpdate) check() error {
if _, ok := fu.mutation.OwnerID(); fu.mutation.OwnerCleared() && !ok {
@@ -535,12 +508,6 @@ func (fu *FileUpdate) sqlSave(ctx context.Context) (n int, err error) {
if value, ok := fu.mutation.UpdatedAt(); ok {
_spec.SetField(file.FieldUpdatedAt, field.TypeTime, value)
}
if value, ok := fu.mutation.DeletedAt(); ok {
_spec.SetField(file.FieldDeletedAt, field.TypeTime, value)
}
if fu.mutation.DeletedAtCleared() {
_spec.ClearField(file.FieldDeletedAt, field.TypeTime)
}
if value, ok := fu.mutation.GetType(); ok {
_spec.SetField(file.FieldType, field.TypeInt, value)
}
@@ -912,26 +879,14 @@ func (fuo *FileUpdateOne) SetUpdatedAt(t time.Time) *FileUpdateOne {
return fuo
}
// SetDeletedAt sets the "deleted_at" field.
func (fuo *FileUpdateOne) SetDeletedAt(t time.Time) *FileUpdateOne {
fuo.mutation.SetDeletedAt(t)
return fuo
}
// SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil.
func (fuo *FileUpdateOne) SetNillableDeletedAt(t *time.Time) *FileUpdateOne {
// SetNillableUpdatedAt sets the "updated_at" field if the given value is not nil.
func (fuo *FileUpdateOne) SetNillableUpdatedAt(t *time.Time) *FileUpdateOne {
if t != nil {
fuo.SetDeletedAt(*t)
fuo.SetUpdatedAt(*t)
}
return fuo
}
// ClearDeletedAt clears the value of the "deleted_at" field.
func (fuo *FileUpdateOne) ClearDeletedAt() *FileUpdateOne {
fuo.mutation.ClearDeletedAt()
return fuo
}
// SetType sets the "type" field.
func (fuo *FileUpdateOne) SetType(i int) *FileUpdateOne {
fuo.mutation.ResetType()
@@ -1356,9 +1311,6 @@ func (fuo *FileUpdateOne) Select(field string, fields ...string) *FileUpdateOne
// Save executes the query and returns the updated File entity.
func (fuo *FileUpdateOne) Save(ctx context.Context) (*File, error) {
if err := fuo.defaults(); err != nil {
return nil, err
}
return withHooks(ctx, fuo.sqlSave, fuo.mutation, fuo.hooks)
}
@@ -1384,18 +1336,6 @@ func (fuo *FileUpdateOne) ExecX(ctx context.Context) {
}
}
// defaults sets the default values of the builder before save.
func (fuo *FileUpdateOne) defaults() error {
if _, ok := fuo.mutation.UpdatedAt(); !ok {
if file.UpdateDefaultUpdatedAt == nil {
return fmt.Errorf("ent: uninitialized file.UpdateDefaultUpdatedAt (forgotten import ent/runtime?)")
}
v := file.UpdateDefaultUpdatedAt()
fuo.mutation.SetUpdatedAt(v)
}
return nil
}
// check runs all checks and user-defined validators on the builder.
func (fuo *FileUpdateOne) check() error {
if _, ok := fuo.mutation.OwnerID(); fuo.mutation.OwnerCleared() && !ok {
@@ -1436,12 +1376,6 @@ func (fuo *FileUpdateOne) sqlSave(ctx context.Context) (_node *File, err error)
if value, ok := fuo.mutation.UpdatedAt(); ok {
_spec.SetField(file.FieldUpdatedAt, field.TypeTime, value)
}
if value, ok := fuo.mutation.DeletedAt(); ok {
_spec.SetField(file.FieldDeletedAt, field.TypeTime, value)
}
if fuo.mutation.DeletedAtCleared() {
_spec.ClearField(file.FieldDeletedAt, field.TypeTime)
}
if value, ok := fuo.mutation.GetType(); ok {
_spec.SetField(file.FieldType, field.TypeInt, value)
}

File diff suppressed because one or more lines are too long

View File

@@ -107,7 +107,6 @@ var (
{Name: "id", Type: field.TypeInt, Increment: true},
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "type", Type: field.TypeInt},
{Name: "name", Type: field.TypeString},
{Name: "size", Type: field.TypeInt64, Default: 0},
@@ -126,19 +125,19 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "files_files_children",
Columns: []*schema.Column{FilesColumns[10]},
Columns: []*schema.Column{FilesColumns[9]},
RefColumns: []*schema.Column{FilesColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "files_storage_policies_files",
Columns: []*schema.Column{FilesColumns[11]},
Columns: []*schema.Column{FilesColumns[10]},
RefColumns: []*schema.Column{StoragePoliciesColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "files_users_files",
Columns: []*schema.Column{FilesColumns[12]},
Columns: []*schema.Column{FilesColumns[11]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
@@ -147,17 +146,17 @@ var (
{
Name: "file_file_children_name",
Unique: true,
Columns: []*schema.Column{FilesColumns[10], FilesColumns[5]},
Columns: []*schema.Column{FilesColumns[9], FilesColumns[4]},
},
{
Name: "file_file_children_type_updated_at",
Unique: false,
Columns: []*schema.Column{FilesColumns[10], FilesColumns[4], FilesColumns[2]},
Columns: []*schema.Column{FilesColumns[9], FilesColumns[3], FilesColumns[2]},
},
{
Name: "file_file_children_type_size",
Unique: false,
Columns: []*schema.Column{FilesColumns[10], FilesColumns[4], FilesColumns[6]},
Columns: []*schema.Column{FilesColumns[9], FilesColumns[3], FilesColumns[5]},
},
},
}

View File

@@ -2972,7 +2972,6 @@ type FileMutation struct {
id *int
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
_type *int
add_type *int
name *string
@@ -3179,55 +3178,6 @@ func (m *FileMutation) ResetUpdatedAt() {
m.updated_at = nil
}
// SetDeletedAt sets the "deleted_at" field.
func (m *FileMutation) SetDeletedAt(t time.Time) {
m.deleted_at = &t
}
// DeletedAt returns the value of the "deleted_at" field in the mutation.
func (m *FileMutation) DeletedAt() (r time.Time, exists bool) {
v := m.deleted_at
if v == nil {
return
}
return *v, true
}
// OldDeletedAt returns the old "deleted_at" field's value of the File entity.
// If the File object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *FileMutation) OldDeletedAt(ctx context.Context) (v *time.Time, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldDeletedAt is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldDeletedAt requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldDeletedAt: %w", err)
}
return oldValue.DeletedAt, nil
}
// ClearDeletedAt clears the value of the "deleted_at" field.
func (m *FileMutation) ClearDeletedAt() {
m.deleted_at = nil
m.clearedFields[file.FieldDeletedAt] = struct{}{}
}
// DeletedAtCleared returns if the "deleted_at" field was cleared in this mutation.
func (m *FileMutation) DeletedAtCleared() bool {
_, ok := m.clearedFields[file.FieldDeletedAt]
return ok
}
// ResetDeletedAt resets all changes to the "deleted_at" field.
func (m *FileMutation) ResetDeletedAt() {
m.deleted_at = nil
delete(m.clearedFields, file.FieldDeletedAt)
}
// SetType sets the "type" field.
func (m *FileMutation) SetType(i int) {
m._type = &i
@@ -4076,16 +4026,13 @@ func (m *FileMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *FileMutation) Fields() []string {
fields := make([]string, 0, 12)
fields := make([]string, 0, 11)
if m.created_at != nil {
fields = append(fields, file.FieldCreatedAt)
}
if m.updated_at != nil {
fields = append(fields, file.FieldUpdatedAt)
}
if m.deleted_at != nil {
fields = append(fields, file.FieldDeletedAt)
}
if m._type != nil {
fields = append(fields, file.FieldType)
}
@@ -4125,8 +4072,6 @@ func (m *FileMutation) Field(name string) (ent.Value, bool) {
return m.CreatedAt()
case file.FieldUpdatedAt:
return m.UpdatedAt()
case file.FieldDeletedAt:
return m.DeletedAt()
case file.FieldType:
return m.GetType()
case file.FieldName:
@@ -4158,8 +4103,6 @@ func (m *FileMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldCreatedAt(ctx)
case file.FieldUpdatedAt:
return m.OldUpdatedAt(ctx)
case file.FieldDeletedAt:
return m.OldDeletedAt(ctx)
case file.FieldType:
return m.OldType(ctx)
case file.FieldName:
@@ -4201,13 +4144,6 @@ func (m *FileMutation) SetField(name string, value ent.Value) error {
}
m.SetUpdatedAt(v)
return nil
case file.FieldDeletedAt:
v, ok := value.(time.Time)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetDeletedAt(v)
return nil
case file.FieldType:
v, ok := value.(int)
if !ok {
@@ -4340,9 +4276,6 @@ func (m *FileMutation) AddField(name string, value ent.Value) error {
// mutation.
func (m *FileMutation) ClearedFields() []string {
var fields []string
if m.FieldCleared(file.FieldDeletedAt) {
fields = append(fields, file.FieldDeletedAt)
}
if m.FieldCleared(file.FieldPrimaryEntity) {
fields = append(fields, file.FieldPrimaryEntity)
}
@@ -4369,9 +4302,6 @@ func (m *FileMutation) FieldCleared(name string) bool {
// error if the field is not defined in the schema.
func (m *FileMutation) ClearField(name string) error {
switch name {
case file.FieldDeletedAt:
m.ClearDeletedAt()
return nil
case file.FieldPrimaryEntity:
m.ClearPrimaryEntity()
return nil
@@ -4398,9 +4328,6 @@ func (m *FileMutation) ResetField(name string) error {
case file.FieldUpdatedAt:
m.ResetUpdatedAt()
return nil
case file.FieldDeletedAt:
m.ResetDeletedAt()
return nil
case file.FieldType:
m.ResetType()
return nil

View File

@@ -87,31 +87,24 @@ func init() {
entityDescReferenceCount := entityFields[3].Descriptor()
// entity.DefaultReferenceCount holds the default value on creation for the reference_count field.
entity.DefaultReferenceCount = entityDescReferenceCount.Default.(int)
fileMixin := schema.File{}.Mixin()
fileMixinHooks0 := fileMixin[0].Hooks()
file.Hooks[0] = fileMixinHooks0[0]
fileMixinInters0 := fileMixin[0].Interceptors()
file.Interceptors[0] = fileMixinInters0[0]
fileMixinFields0 := fileMixin[0].Fields()
_ = fileMixinFields0
fileHooks := schema.File{}.Hooks()
file.Hooks[0] = fileHooks[0]
fileFields := schema.File{}.Fields()
_ = fileFields
// fileDescCreatedAt is the schema descriptor for created_at field.
fileDescCreatedAt := fileMixinFields0[0].Descriptor()
fileDescCreatedAt := fileFields[0].Descriptor()
// file.DefaultCreatedAt holds the default value on creation for the created_at field.
file.DefaultCreatedAt = fileDescCreatedAt.Default.(func() time.Time)
// fileDescUpdatedAt is the schema descriptor for updated_at field.
fileDescUpdatedAt := fileMixinFields0[1].Descriptor()
fileDescUpdatedAt := fileFields[1].Descriptor()
// file.DefaultUpdatedAt holds the default value on creation for the updated_at field.
file.DefaultUpdatedAt = fileDescUpdatedAt.Default.(func() time.Time)
// file.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
file.UpdateDefaultUpdatedAt = fileDescUpdatedAt.UpdateDefault.(func() time.Time)
// fileDescSize is the schema descriptor for size field.
fileDescSize := fileFields[3].Descriptor()
fileDescSize := fileFields[5].Descriptor()
// file.DefaultSize holds the default value on creation for the size field.
file.DefaultSize = fileDescSize.Default.(int64)
// fileDescIsSymbolic is the schema descriptor for is_symbolic field.
fileDescIsSymbolic := fileFields[6].Descriptor()
fileDescIsSymbolic := fileFields[8].Descriptor()
// file.DefaultIsSymbolic holds the default value on creation for the is_symbolic field.
file.DefaultIsSymbolic = fileDescIsSymbolic.Default.(bool)
groupMixin := schema.Group{}.Mixin()

View File

@@ -1,10 +1,15 @@
package schema
import (
"context"
"time"
"entgo.io/ent"
"entgo.io/ent/dialect"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
"github.com/cloudreve/Cloudreve/v4/ent/hook"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
)
@@ -16,6 +21,17 @@ type File struct {
// Fields of the File.
func (File) Fields() []ent.Field {
return []ent.Field{
field.Time("created_at").
Immutable().
Default(time.Now).
SchemaType(map[string]string{
dialect.MySQL: "datetime",
}),
field.Time("updated_at").
Default(time.Now).
SchemaType(map[string]string{
dialect.MySQL: "datetime",
}),
field.Int("type"),
field.String("name"),
field.Int("owner_id"),
@@ -66,8 +82,19 @@ func (File) Indexes() []ent.Index {
}
}
func (File) Mixin() []ent.Mixin {
return []ent.Mixin{
CommonMixin{},
func (f File) Hooks() []ent.Hook {
return []ent.Hook{
hook.On(func(next ent.Mutator) ent.Mutator {
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
if s, ok := m.(interface{ SetUpdatedAt(time.Time) }); ok {
_, set := m.Field("updated_at")
if !set {
s.SetUpdatedAt(time.Now())
}
}
v, err := next.Mutate(ctx, m)
return v, err
})
}, ent.OpUpdate|ent.OpUpdateOne),
}
}

5
go.mod
View File

@@ -22,7 +22,6 @@ require (
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2
github.com/gin-gonic/gin v1.10.0
github.com/go-ini/ini v1.50.0
github.com/go-mail/mail v2.3.1+incompatible
github.com/go-playground/validator/v10 v10.20.0
github.com/go-sql-driver/mysql v1.6.0
github.com/go-webauthn/webauthn v0.11.2
@@ -38,6 +37,7 @@ require (
github.com/jinzhu/gorm v1.9.11
github.com/jpillora/backoff v1.0.0
github.com/juju/ratelimit v1.0.1
github.com/ks3sdklib/aws-sdk-go v1.6.2
github.com/lib/pq v1.10.9
github.com/mholt/archiver/v4 v4.0.0-alpha.6
github.com/mojocn/base64Captcha v0.0.0-20190801020520-752b1cd608b2
@@ -53,6 +53,7 @@ require (
github.com/tencentyun/cos-go-sdk-v5 v0.7.54
github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90
github.com/upyun/go-sdk v2.1.0+incompatible
github.com/wneessen/go-mail v0.6.2
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e
golang.org/x/image v0.0.0-20211028202545-6944b10bf410
golang.org/x/text v0.23.0
@@ -138,8 +139,6 @@ require (
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/mail.v2 v2.3.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect

52
go.sum
View File

@@ -274,7 +274,6 @@ github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DP
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/etcd-io/gofail v0.0.0-20190801230047-ad7f989257ca/go.mod h1:49H/RkXP8pKaZy4h0d+NW16rSLhyVBt4o6VLJbmOqDE=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
@@ -326,8 +325,6 @@ github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgO
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-mail/mail v2.3.1+incompatible h1:UzNOn0k5lpfVtO31cK3hn6I4VEVGhe3lX8AJBAxXExM=
github.com/go-mail/mail v2.3.1+incompatible/go.mod h1:VPWjmmNyRsWXQZHVHT3g0YbIINUkSmuKOiLIDkWbL6M=
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@@ -636,6 +633,8 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ks3sdklib/aws-sdk-go v1.6.2 h1:nxtaaU3hDD5x6gmoxs/qijSJqZrjFapYYuTiVCEgobA=
github.com/ks3sdklib/aws-sdk-go v1.6.2/go.mod h1:jGcsV0dJgMmStAyqjkKVUu6F167pAXYZAS3LqoZMmtM=
github.com/kylelemons/go-gypsy v1.0.0/go.mod h1:chkXM0zjdpXOiqkCW1XcCHDfjfk14PH2KKkQWxfJUcU=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
@@ -660,8 +659,6 @@ github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaO
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc=
@@ -968,6 +965,8 @@ github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+
github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
github.com/weppos/publicsuffix-go v0.13.1-0.20210123135404-5fd73613514e/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
github.com/wneessen/go-mail v0.6.2 h1:c6V7c8D2mz868z9WJ+8zDKtUyLfZ1++uAZmo2GRFji8=
github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy531oVmeb4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/go-gitlab v0.31.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug=
@@ -1052,6 +1051,10 @@ golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf/go.mod h1:P+XmwS30IXTQdn5
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -1096,6 +1099,10 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1152,6 +1159,11 @@ golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -1182,6 +1194,11 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1258,12 +1275,24 @@ golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1274,6 +1303,12 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1349,6 +1384,9 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1485,8 +1523,6 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -1502,8 +1538,6 @@ gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/R
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g=

View File

@@ -59,11 +59,17 @@ type (
StoragePolicyID int
}
MetadataFilter struct {
Key string
Value string
Exact bool
}
SearchFileParameters struct {
Name []string
// NameOperatorOr is true if the name should match any of the given names, false if all of them
NameOperatorOr bool
Metadata map[string]string
Metadata []MetadataFilter
Type *types.FileType
UseFullText bool
CaseFolding bool
@@ -211,6 +217,8 @@ type FileClient interface {
ListEntities(ctx context.Context, args *ListEntityParameters) (*ListEntityResult, error)
// UpdateProps updates props of a file
UpdateProps(ctx context.Context, file *ent.File, props *types.FileProps) (*ent.File, error)
// UpdateModifiedAt updates modified at of a file
UpdateModifiedAt(ctx context.Context, file *ent.File, modifiedAt time.Time) error
}
func NewFileClient(client *ent.Client, dbType conf.DBType, hasher hashid.Encoder) FileClient {
@@ -640,6 +648,10 @@ func (f *fileClient) Copy(ctx context.Context, files []*ent.File, dstMap map[int
return newDstMap, map[int]int64{dstMap[files[0].FileChildren][0].OwnerID: sizeDiff}, nil
}
func (f *fileClient) UpdateModifiedAt(ctx context.Context, file *ent.File, modifiedAt time.Time) error {
return f.client.File.UpdateOne(file).SetUpdatedAt(modifiedAt).Exec(ctx)
}
func (f *fileClient) UpsertMetadata(ctx context.Context, file *ent.File, data map[string]string, privateMask map[string]bool) error {
// Validate value length
for key, value := range data {
@@ -712,10 +724,15 @@ func (f *fileClient) UpgradePlaceholder(ctx context.Context, file *ent.File, mod
}
if entityType == types.EntityTypeVersion {
if err := f.client.File.UpdateOne(file).
stm := f.client.File.UpdateOne(file).
SetSize(placeholder.Size).
SetPrimaryEntity(placeholder.ID).
Exec(ctx); err != nil {
SetPrimaryEntity(placeholder.ID)
if modifiedAt != nil {
stm.SetUpdatedAt(*modifiedAt)
}
if err := stm.Exec(ctx); err != nil {
return fmt.Errorf("failed to upgrade file primary entity: %v", err)
}
}
@@ -890,7 +907,7 @@ func (f *fileClient) CreateEntity(ctx context.Context, file *ent.File, args *Ent
diff := map[int]int64{file.OwnerID: created.Size}
if err := f.client.File.UpdateOne(file).AddEntities(created).Exec(ctx); err != nil {
if err := f.client.Entity.UpdateOne(created).AddFile(file).Exec(ctx); err != nil {
return nil, diff, fmt.Errorf("failed to add file entity: %v", err)
}

View File

@@ -16,6 +16,10 @@ import (
"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))
@@ -69,13 +73,17 @@ func (f *fileClient) searchQuery(q *ent.FileQuery, args *SearchFileParameters, p
}
if len(args.Metadata) > 0 {
metaPredicates := lo.MapToSlice(args.Metadata, func(name string, value string) predicate.Metadata {
nameEq := metadata.NameEQ(value)
if name == "" {
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.NameEQ(item.Key)
if item.Value == "" {
return nameEq
} else {
valueContain := metadata.ValueContainsFold(value)
return metadata.And(metadata.NameEQ(name), valueContain)
valueContain := metadata.ValueContainsFold(item.Value)
return metadata.And(nameEq, valueContain)
}
})
metaPredicates = append(metaPredicates, metadata.IsPublic(true))

View File

@@ -324,6 +324,22 @@ var (
},
},
}
defaultFileProps = []types.CustomProps{
{
ID: "description",
Type: types.CustomPropsTypeText,
Name: "fileManager.description",
Icon: "fluent:slide-text-24-filled",
},
{
ID: "rating",
Type: types.CustomPropsTypeRating,
Name: "fileManager.rating",
Icon: "fluent:data-bar-vertical-star-24-filled",
Max: 5,
},
}
)
var DefaultSettings = map[string]string{
@@ -414,6 +430,7 @@ var DefaultSettings = map[string]string{
"thumb_ffmpeg_max_size": "10737418240", // 10 GB
"thumb_ffmpeg_exts": "3g2,3gp,asf,asx,avi,divx,flv,m2ts,m2v,m4v,mkv,mov,mp4,mpeg,mpg,mts,mxf,ogv,rm,swf,webm,wmv",
"thumb_ffmpeg_seek": "00:00:01.00",
"thumb_ffmpeg_extra_args": "-hwaccel auto",
"thumb_libreoffice_path": "soffice",
"thumb_libreoffice_max_size": "78643200", // 75 MB
"thumb_libreoffice_enabled": "0",
@@ -501,6 +518,10 @@ var DefaultSettings = map[string]string{
"qq_login": `0`,
"qq_login_config": `{"direct_sign_in":false}`,
"license": "",
"custom_nav_items": "[]",
"headless_footer_html": "",
"headless_bottom_html": "",
"sidebar_bottom_html": "",
}
func init() {
@@ -516,4 +537,10 @@ func init() {
}
DefaultSettings["file_viewers"] = string(viewers)
customProps, err := json.Marshal(defaultFileProps)
if err != nil {
panic(err)
}
DefaultSettings["custom_props"] = string(customProps)
}

View File

@@ -3,6 +3,7 @@ package inventory
import (
"context"
"fmt"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
)
@@ -60,6 +61,22 @@ func WithTx[T TxOperator](ctx context.Context, c T) (T, *Tx, context.Context, er
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

View File

@@ -7,17 +7,20 @@ import (
// 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"`
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"`
@@ -41,6 +44,12 @@ type (
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
@@ -173,7 +182,8 @@ type (
}
ColumTypeProps struct {
MetadataKey string `json:"metadata_key,omitempty" binding:"max=255"`
MetadataKey string `json:"metadata_key,omitempty" binding:"max=255"`
CustomPropsID string `json:"custom_props_id,omitempty" binding:"max=255"`
}
ShareProps struct {
@@ -254,6 +264,7 @@ const (
PolicyTypeOss = "oss"
PolicyTypeCos = "cos"
PolicyTypeS3 = "s3"
PolicyTypeKs3 = "ks3"
PolicyTypeOd = "onedrive"
PolicyTypeRemote = "remote"
PolicyTypeObs = "obs"
@@ -278,26 +289,57 @@ const (
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"`
}
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"`
}
ViewerGroup struct {
Viewers []Viewer `json:"viewers"`
}
type ViewerGroup struct {
Viewers []Viewer `json:"viewers"`
}
NewFileTemplate struct {
Ext string `json:"ext"`
DisplayName string `json:"display_name"`
}
)
type 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")
)

View File

@@ -220,8 +220,29 @@ func (c *userClient) Delete(ctx context.Context, uid int) error {
func (c *userClient) ApplyStorageDiff(ctx context.Context, diffs StorageDiff) error {
ae := serializer.NewAggregateError()
for uid, diff := range diffs {
if err := c.client.User.Update().Where(user.ID(uid)).AddStorage(diff).Exec(ctx); err != nil {
ae.Add(fmt.Sprintf("%d", uid), fmt.Errorf("failed to apply storage diff for user %d: %w", uid, err))
// Retry logic for MySQL deadlock (Error 1213)
// This is a temporary workaround. TODO: optimize storage mutation
maxRetries := 3
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if err := c.client.User.Update().Where(user.ID(uid)).AddStorage(diff).Exec(ctx); err != nil {
lastErr = err
// Check if it's a MySQL deadlock error (Error 1213)
if strings.Contains(err.Error(), "Error 1213") && attempt < maxRetries-1 {
// Wait a bit before retrying with exponential backoff
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
continue
}
ae.Add(fmt.Sprintf("%d", uid), fmt.Errorf("failed to apply storage diff for user %d: %w", uid, err))
break
}
// Success, break out of retry loop
lastErr = nil
break
}
if lastErr != nil {
ae.Add(fmt.Sprintf("%d", uid), fmt.Errorf("failed to apply storage diff for user %d: %w", uid, lastErr))
}
}

View File

@@ -3,6 +3,10 @@ package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/auth/requestinfo"
@@ -14,8 +18,6 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"net/http"
"time"
)
// HashID 将给定对象的HashID转换为真实ID
@@ -92,8 +94,13 @@ func MobileRequestOnly() gin.HandlerFunc {
// 2. Generate and inject correlation ID for diagnostic.
func InitializeHandling(dep dependency.Dep) gin.HandlerFunc {
return func(c *gin.Context) {
clientIp := c.ClientIP()
if idx := strings.Index(clientIp, ","); idx > 0 {
clientIp = clientIp[:idx]
}
reqInfo := &requestinfo.RequestInfo{
IP: c.ClientIP(),
IP: clientIp,
Host: c.Request.Host,
UserAgent: c.Request.UserAgent(),
}

View File

@@ -46,7 +46,7 @@ type System struct {
SessionSecret string
HashIDSalt string // deprecated
GracePeriod int `validate:"gte=0"`
ProxyHeader string `validate:"required_with=Listen"`
ProxyHeader string
LogLevel string `validate:"oneof=debug info warning error"`
}
@@ -114,7 +114,7 @@ var SystemConfig = &System{
Debug: false,
Mode: MasterMode,
Listen: ":5212",
ProxyHeader: "X-Forwarded-For",
ProxyHeader: "",
LogLevel: "info",
}

View File

@@ -32,18 +32,18 @@ const (
)
var (
supportDownloadOptions = map[string]bool{
"cookie": true,
"skip_checking": true,
"root_folder": true,
"rename": true,
"upLimit": true,
"dlLimit": true,
"ratioLimit": true,
"seedingTimeLimit": true,
"autoTMM": true,
"sequentialDownload": true,
"firstLastPiecePrio": true,
downloadOptionFormatTypes = map[string]string{
"cookie": "%s",
"skip_checking": "%s",
"root_folder": "%s",
"rename": "%s",
"upLimit": "%.0f",
"dlLimit": "%.0f",
"ratioLimit": "%f",
"seedingTimeLimit": "%.0f",
"autoTMM": "%t",
"sequentialDownload": "%s",
"firstLastPiecePrio": "%t",
}
)
@@ -271,15 +271,15 @@ func (c *qbittorrentClient) CreateTask(ctx context.Context, url string, options
// Apply global options
for k, v := range c.options.Options {
if _, ok := supportDownloadOptions[k]; ok {
_ = formWriter.WriteField(k, fmt.Sprintf("%s", v))
if _, ok := downloadOptionFormatTypes[k]; ok {
_ = formWriter.WriteField(k, fmt.Sprintf(downloadOptionFormatTypes[k], v))
}
}
// Apply group options
for k, v := range options {
if _, ok := supportDownloadOptions[k]; ok {
_ = formWriter.WriteField(k, fmt.Sprintf("%s", v))
if _, ok := downloadOptionFormatTypes[k]; ok {
_ = formWriter.WriteField(k, fmt.Sprintf(downloadOptionFormatTypes[k], v))
}
}

View File

@@ -9,8 +9,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/go-mail/mail"
"github.com/gofrs/uuid"
"github.com/wneessen/go-mail"
)
// SMTPPool SMTP协议发送邮件
@@ -38,9 +37,11 @@ type SMTPConfig struct {
}
type message struct {
msg *mail.Message
cid string
userID int
msg *mail.Msg
to string
subject string
cid string
userID int
}
// NewSMTPPool initializes a new SMTP based email sending queue.
@@ -81,17 +82,21 @@ func (client *SMTPPool) Send(ctx context.Context, to, title, body string) error
return nil
}
m := mail.NewMessage()
m.SetAddressHeader("From", client.config.From, client.config.FromName)
m.SetAddressHeader("Reply-To", client.config.ReplyTo, client.config.FromName)
m.SetHeader("To", to)
m.SetHeader("Subject", title)
m.SetHeader("Message-ID", fmt.Sprintf("<%s@%s>", uuid.Must(uuid.NewV4()).String(), "cloudreve"))
m.SetBody("text/html", body)
m := mail.NewMsg()
if err := m.FromFormat(client.config.FromName, client.config.From); err != nil {
return err
}
m.ReplyToFormat(client.config.FromName, client.config.ReplyTo)
m.To(to)
m.Subject(title)
m.SetMessageID()
m.SetBodyString(mail.TypeTextHTML, body)
client.ch <- &message{
msg: m,
cid: logging.CorrelationID(ctx).String(),
userID: inventory.UserIDFromContext(ctx),
msg: m,
subject: title,
to: to,
cid: logging.CorrelationID(ctx).String(),
userID: inventory.UserIDFromContext(ctx),
}
return nil
}
@@ -116,17 +121,24 @@ func (client *SMTPPool) Init() {
}
}()
d := mail.NewDialer(client.config.Host, client.config.Port, client.config.User, client.config.Password)
d.Timeout = time.Duration(client.config.Keepalive+5) * time.Second
client.chOpen = true
// 是否启用 SSL
d.SSL = false
tlsPolicy := mail.TLSOpportunistic
if client.config.ForceEncryption {
d.SSL = true
tlsPolicy = mail.TLSMandatory
}
d.StartTLSPolicy = mail.OpportunisticStartTLS
var s mail.SendCloser
d, diaErr := mail.NewClient(client.config.Host,
mail.WithPort(client.config.Port),
mail.WithTimeout(time.Duration(client.config.Keepalive+5)*time.Second),
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(tlsPolicy),
mail.WithUsername(client.config.User), mail.WithPassword(client.config.Password),
)
if diaErr != nil {
client.l.Panic("Failed to create SMTP client: %s", diaErr)
return
}
client.chOpen = true
var err error
open := false
for {
@@ -139,22 +151,22 @@ func (client *SMTPPool) Init() {
}
if !open {
if s, err = d.Dial(); err != nil {
if err = d.DialWithContext(context.Background()); err != nil {
panic(err)
}
open = true
}
l := client.l.CopyWithPrefix(fmt.Sprintf("[Cid: %s]", m.cid))
if err := mail.Send(s, m.msg); err != nil {
if err := d.Send(m.msg); err != nil {
l.Warning("Failed to send email: %s, Cid=%s", err, m.cid)
} else {
l.Info("Email sent to %q, title: %q.", m.msg.GetHeader("To"), m.msg.GetHeader("Subject"))
l.Info("Email sent to %q, title: %q.", m.to, m.subject)
}
// 长时间没有新邮件则关闭SMTP连接
case <-time.After(time.Duration(client.config.Keepalive) * time.Second):
if open {
if err := s.Close(); err != nil {
if err := d.Close(); err != nil {
client.l.Warning("Failed to close SMTP connection: %s", err)
}
open = false

View File

@@ -0,0 +1,551 @@
package ks3
import (
"context"
"errors"
"fmt"
"github.com/aws/aws-sdk-go/aws/request"
"io"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"strconv"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/chunk"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/chunk/backoff"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/mime"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/ks3sdklib/aws-sdk-go/aws/awserr"
"github.com/ks3sdklib/aws-sdk-go/service/s3/s3manager"
"github.com/samber/lo"
"github.com/ks3sdklib/aws-sdk-go/aws"
"github.com/ks3sdklib/aws-sdk-go/aws/credentials"
"github.com/ks3sdklib/aws-sdk-go/service/s3"
)
// Driver KS3 compatible driver
type Driver struct {
policy *ent.StoragePolicy
chunkSize int64
settings setting.Provider
l logging.Logger
config conf.ConfigProvider
mime mime.MimeDetector
sess *aws.Config
svc *s3.S3
}
// UploadPolicy KS3上传策略
type UploadPolicy struct {
Expiration string `json:"expiration"`
Conditions []interface{} `json:"conditions"`
}
type Session struct {
Config *aws.Config
Handlers request.Handlers
}
// MetaData 文件信息
type MetaData struct {
Size int64
Etag string
}
var (
features = &boolset.BooleanSet{}
)
func init() {
boolset.Sets(map[driver.HandlerCapability]bool{
driver.HandlerCapabilityUploadSentinelRequired: true,
}, features)
}
func Int64(v int64) *int64 {
return &v
}
func New(ctx context.Context, policy *ent.StoragePolicy, settings setting.Provider,
config conf.ConfigProvider, l logging.Logger, mime mime.MimeDetector) (*Driver, error) {
chunkSize := policy.Settings.ChunkSize
if policy.Settings.ChunkSize == 0 {
chunkSize = 25 << 20 // 25 MB
}
driver := &Driver{
policy: policy,
settings: settings,
chunkSize: chunkSize,
config: config,
l: l,
mime: mime,
}
sess := aws.Config{
Credentials: credentials.NewStaticCredentials(policy.AccessKey, policy.SecretKey, ""),
Endpoint: policy.Server,
Region: policy.Settings.Region,
S3ForcePathStyle: policy.Settings.S3ForcePathStyle,
}
driver.sess = &sess
driver.svc = s3.New(&sess)
return driver, nil
}
// List 列出给定路径下的文件
func (handler *Driver) List(ctx context.Context, base string, onProgress driver.ListProgressFunc, recursive bool) ([]fs.PhysicalObject, error) {
// 初始化列目录参数
base = strings.TrimPrefix(base, "/")
if base != "" {
base += "/"
}
opt := &s3.ListObjectsInput{
Bucket: &handler.policy.BucketName,
Prefix: &base,
MaxKeys: Int64(1000),
}
// 是否为递归列出
if !recursive {
opt.Delimiter = aws.String("/")
}
var (
objects []*s3.Object
commons []*s3.CommonPrefix
)
for {
res, err := handler.svc.ListObjectsWithContext(ctx, opt)
if err != nil {
return nil, err
}
objects = append(objects, res.Contents...)
commons = append(commons, res.CommonPrefixes...)
// 如果本次未列取完则继续使用marker获取结果
if *res.IsTruncated {
opt.Marker = res.NextMarker
} else {
break
}
}
// 处理列取结果
res := make([]fs.PhysicalObject, 0, len(objects)+len(commons))
// 处理目录
for _, object := range commons {
rel, err := filepath.Rel(*opt.Prefix, *object.Prefix)
if err != nil {
continue
}
res = append(res, fs.PhysicalObject{
Name: path.Base(*object.Prefix),
RelativePath: filepath.ToSlash(rel),
Size: 0,
IsDir: true,
LastModify: time.Now(),
})
}
onProgress(len(commons))
// 处理文件
for _, object := range objects {
rel, err := filepath.Rel(*opt.Prefix, *object.Key)
if err != nil {
continue
}
res = append(res, fs.PhysicalObject{
Name: path.Base(*object.Key),
Source: *object.Key,
RelativePath: filepath.ToSlash(rel),
Size: *object.Size,
IsDir: false,
LastModify: time.Now(),
})
}
onProgress(len(objects))
return res, nil
}
// Open 打开文件
func (handler *Driver) Open(ctx context.Context, path string) (*os.File, error) {
return nil, errors.New("not implemented")
}
// Put 将文件流保存到指定目录
func (handler *Driver) Put(ctx context.Context, file *fs.UploadRequest) error {
defer file.Close()
// 是否允许覆盖
overwrite := file.Mode&fs.ModeOverwrite == fs.ModeOverwrite
if !overwrite {
// Check for duplicated file
if _, err := handler.Meta(ctx, file.Props.SavePath); err == nil {
return fs.ErrFileExisted
}
}
// 初始化配置
uploader := s3manager.NewUploader(&s3manager.UploadOptions{
S3: handler.svc, // S3Client实例必填
PartSize: handler.chunkSize, // 分块大小默认为5MB非必填
})
mimeType := file.Props.MimeType
if mimeType == "" {
handler.mime.TypeByName(file.Props.Uri.Name())
}
_, err := uploader.UploadWithContext(ctx, &s3manager.UploadInput{
Bucket: &handler.policy.BucketName,
Key: &file.Props.SavePath,
Body: io.LimitReader(file, file.Props.Size),
ContentType: aws.String(mimeType),
})
if err != nil {
return err
}
return nil
}
// Delete 删除文件
func (handler *Driver) Delete(ctx context.Context, files ...string) ([]string, error) {
failed := make([]string, 0, len(files))
batchSize := handler.policy.Settings.S3DeleteBatchSize
if batchSize == 0 {
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
// The request can contain a list of up to 1000 keys that you want to delete.
batchSize = 1000
}
var lastErr error
groups := lo.Chunk(files, batchSize)
for _, group := range groups {
if len(group) == 1 {
// Invoke single file delete API
_, err := handler.svc.DeleteObjectWithContext(ctx, &s3.DeleteObjectInput{
Bucket: &handler.policy.BucketName,
Key: &group[0],
})
if err != nil {
if aerr, ok := err.(awserr.Error); ok {
// Ignore NoSuchKey error
if aerr.Code() == s3.ErrCodeNoSuchKey {
continue
}
}
failed = append(failed, group[0])
lastErr = err
}
} else {
// Invoke batch delete API
res, err := handler.svc.DeleteObjects(
&s3.DeleteObjectsInput{
Bucket: &handler.policy.BucketName,
Delete: &s3.Delete{
Objects: lo.Map(group, func(s string, i int) *s3.ObjectIdentifier {
return &s3.ObjectIdentifier{Key: &s}
}),
},
})
if err != nil {
failed = append(failed, group...)
lastErr = err
continue
}
for _, v := range res.Errors {
handler.l.Debug("Failed to delete file: %s, Code:%s, Message:%s", v.Key, v.Code, v.Key)
failed = append(failed, *v.Key)
}
}
}
return failed, lastErr
}
// Thumb 获取缩略图URL
func (handler *Driver) Thumb(ctx context.Context, expire *time.Time, ext string, e fs.Entity) (string, error) {
return "", errors.New("not implemented")
}
// Source 获取文件外链
func (handler *Driver) Source(ctx context.Context, e fs.Entity, args *driver.GetSourceArgs) (string, error) {
var contentDescription *string
if args.IsDownload {
encodedFilename := url.PathEscape(args.DisplayName)
contentDescription = aws.String(fmt.Sprintf(`attachment; filename="%s"`, encodedFilename))
}
// 确保过期时间不小于 0 ,如果小于则设置为 7 天
var ttl int64
if args.Expire != nil {
ttl = int64(time.Until(*args.Expire).Seconds())
} else {
ttl = 604800
}
downloadUrl, err := handler.svc.GeneratePresignedUrl(&s3.GeneratePresignedUrlInput{
HTTPMethod: s3.GET, // 请求方法
Bucket: &handler.policy.BucketName, // 存储空间名称
Key: aws.String(e.Source()), // 对象的key
Expires: ttl, // 过期时间,转换为秒数
ResponseContentDisposition: contentDescription, // 设置响应头部 Content-Disposition
})
if err != nil {
return "", err
}
// 将最终生成的签名URL域名换成用户自定义的加速域名如果有
finalURL, err := url.Parse(downloadUrl)
if err != nil {
return "", err
}
// 公有空间替换掉Key及不支持的头
if !handler.policy.IsPrivate {
finalURL.RawQuery = ""
}
return finalURL.String(), nil
}
// Token 获取上传凭证
func (handler *Driver) Token(ctx context.Context, uploadSession *fs.UploadSession, file *fs.UploadRequest) (*fs.UploadCredential, error) {
// Check for duplicated file
if _, err := handler.Meta(ctx, file.Props.SavePath); err == nil {
return nil, fs.ErrFileExisted
}
// 生成回调地址
siteURL := handler.settings.SiteURL(setting.UseFirstSiteUrl(ctx))
// 在从机端创建上传会话
uploadSession.ChunkSize = handler.chunkSize
uploadSession.Callback = routes.MasterSlaveCallbackUrl(siteURL, types.PolicyTypeKs3, uploadSession.Props.UploadSessionID, uploadSession.CallbackSecret).String()
mimeType := file.Props.MimeType
if mimeType == "" {
handler.mime.TypeByName(file.Props.Uri.Name())
}
// 创建分片上传
res, err := handler.svc.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{
Bucket: &handler.policy.BucketName,
Key: &uploadSession.Props.SavePath,
Expires: &uploadSession.Props.ExpireAt,
ContentType: aws.String(mimeType),
})
if err != nil {
return nil, fmt.Errorf("failed to create multipart upload: %w", err)
}
uploadSession.UploadID = *res.UploadID
// 为每个分片签名上传 URL
chunks := chunk.NewChunkGroup(file, handler.chunkSize, &backoff.ConstantBackoff{}, false, handler.l, "")
urls := make([]string, chunks.Num())
for chunks.Next() {
err := chunks.Process(func(c *chunk.ChunkGroup, chunk io.Reader) error {
// 计算过期时间(秒)
expireSeconds := int(time.Until(uploadSession.Props.ExpireAt).Seconds())
partNumber := c.Index() + 1
// 生成预签名URL
signedURL, err := handler.svc.GeneratePresignedUrl(&s3.GeneratePresignedUrlInput{
HTTPMethod: s3.PUT,
Bucket: &handler.policy.BucketName,
Key: &uploadSession.Props.SavePath,
Expires: int64(expireSeconds),
Parameters: map[string]*string{
"partNumber": aws.String(strconv.Itoa(partNumber)),
"uploadId": res.UploadID,
},
ContentType: aws.String("application/octet-stream"),
})
if err != nil {
return fmt.Errorf("failed to generate presigned upload url for chunk %d: %w", partNumber, err)
}
urls[c.Index()] = signedURL
return nil
})
if err != nil {
return nil, err
}
}
// 签名完成分片上传的请求URL
expireSeconds := int(time.Until(uploadSession.Props.ExpireAt).Seconds())
signedURL, err := handler.svc.GeneratePresignedUrl(&s3.GeneratePresignedUrlInput{
HTTPMethod: s3.POST,
Bucket: &handler.policy.BucketName,
Key: &file.Props.SavePath,
Expires: int64(expireSeconds),
Parameters: map[string]*string{
"uploadId": res.UploadID,
},
ContentType: aws.String("application/octet-stream"),
})
if err != nil {
return nil, err
}
// 生成上传凭证
return &fs.UploadCredential{
UploadID: *res.UploadID,
UploadURLs: urls,
CompleteURL: signedURL,
SessionID: uploadSession.Props.UploadSessionID,
ChunkSize: handler.chunkSize,
}, nil
}
// CancelToken 取消上传凭证
func (handler *Driver) CancelToken(ctx context.Context, uploadSession *fs.UploadSession) error {
_, err := handler.svc.AbortMultipartUploadWithContext(ctx, &s3.AbortMultipartUploadInput{
UploadID: &uploadSession.UploadID,
Bucket: &handler.policy.BucketName,
Key: &uploadSession.Props.SavePath,
})
return err
}
// cancelUpload 取消分片上传
func (handler *Driver) cancelUpload(key, id *string) {
if _, err := handler.svc.AbortMultipartUpload(&s3.AbortMultipartUploadInput{
Bucket: &handler.policy.BucketName,
UploadID: id,
Key: key,
}); err != nil {
handler.l.Warning("failed to abort multipart upload: %s", err)
}
}
// Capabilities 获取存储能力
func (handler *Driver) Capabilities() *driver.Capabilities {
return &driver.Capabilities{
StaticFeatures: features,
MediaMetaProxy: handler.policy.Settings.MediaMetaGeneratorProxy,
ThumbProxy: handler.policy.Settings.ThumbGeneratorProxy,
MaxSourceExpire: time.Duration(604800) * time.Second,
}
}
// MediaMeta 获取媒体元信息
func (handler *Driver) MediaMeta(ctx context.Context, path, ext string) ([]driver.MediaMeta, error) {
return nil, errors.New("not implemented")
}
// LocalPath 获取本地路径
func (handler *Driver) LocalPath(ctx context.Context, path string) string {
return ""
}
// CompleteUpload 完成上传
func (handler *Driver) CompleteUpload(ctx context.Context, session *fs.UploadSession) error {
if session.SentinelTaskID == 0 {
return nil
}
// Make sure uploaded file size is correct
res, err := handler.Meta(ctx, session.Props.SavePath)
if err != nil {
return fmt.Errorf("failed to get uploaded file size: %w", err)
}
if res.Size != session.Props.Size {
return serializer.NewError(
serializer.CodeMetaMismatch,
fmt.Sprintf("File size not match, expected: %d, actual: %d", session.Props.Size, res.Size),
nil,
)
}
return nil
}
// Meta 获取文件元信息
func (handler *Driver) Meta(ctx context.Context, path string) (*MetaData, error) {
res, err := handler.svc.HeadObjectWithContext(ctx,
&s3.HeadObjectInput{
Bucket: &handler.policy.BucketName,
Key: &path,
})
if err != nil {
return nil, err
}
return &MetaData{
Size: *res.ContentLength,
Etag: *res.ETag,
}, nil
}
// CORS 设置CORS规则
func (handler *Driver) CORS() error {
rule := s3.CORSRule{
AllowedMethod: []string{
"GET",
"POST",
"PUT",
"DELETE",
"HEAD",
},
AllowedOrigin: []string{"*"},
AllowedHeader: []string{"*"},
ExposeHeader: []string{"ETag"},
MaxAgeSeconds: 3600,
}
_, err := handler.svc.PutBucketCORS(&s3.PutBucketCORSInput{
Bucket: &handler.policy.BucketName,
CORSConfiguration: &s3.CORSConfiguration{
Rules: []*s3.CORSRule{&rule},
},
})
return err
}
// Reader 读取器
type Reader struct {
r io.Reader
}
// Read 读取数据
func (r Reader) Read(p []byte) (int, error) {
return r.r.Read(p)
}

View File

@@ -7,6 +7,7 @@ import (
"math/rand"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -651,7 +652,8 @@ func (f *DBFS) getPreferredPolicy(ctx context.Context, file *File) (*ent.Storage
return nil, fmt.Errorf("owner group not loaded")
}
groupPolicy, err := f.storagePolicyClient.GetByGroup(ctx, ownerGroup)
sc, _ := inventory.InheritTx(ctx, f.storagePolicyClient)
groupPolicy, err := sc.GetByGroup(ctx, ownerGroup)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get available storage policies", err)
}
@@ -765,44 +767,72 @@ func (f *DBFS) navigatorId(path *fs.URI) string {
// generateSavePath generates the physical save path for the upload request.
func generateSavePath(policy *ent.StoragePolicy, req *fs.UploadRequest, user *ent.User) string {
baseTable := map[string]string{
"{randomkey16}": util.RandStringRunes(16),
"{randomkey8}": util.RandStringRunes(8),
"{timestamp}": strconv.FormatInt(time.Now().Unix(), 10),
"{timestamp_nano}": strconv.FormatInt(time.Now().UnixNano(), 10),
"{randomnum2}": strconv.Itoa(rand.Intn(2)),
"{randomnum3}": strconv.Itoa(rand.Intn(3)),
"{randomnum4}": strconv.Itoa(rand.Intn(4)),
"{randomnum8}": strconv.Itoa(rand.Intn(8)),
"{uid}": strconv.Itoa(user.ID),
"{datetime}": time.Now().Format("20060102150405"),
"{date}": time.Now().Format("20060102"),
"{year}": time.Now().Format("2006"),
"{month}": time.Now().Format("01"),
"{day}": time.Now().Format("02"),
"{hour}": time.Now().Format("15"),
"{minute}": time.Now().Format("04"),
"{second}": time.Now().Format("05"),
currentTime := time.Now()
originName := req.Props.Uri.Name()
dynamicReplace := func(regPattern string, rule string, pathAvailable bool) string {
re := regexp.MustCompile(regPattern)
return re.ReplaceAllStringFunc(rule, func(match string) string {
switch match {
case "{timestamp}":
return strconv.FormatInt(currentTime.Unix(), 10)
case "{timestamp_nano}":
return strconv.FormatInt(currentTime.UnixNano(), 10)
case "{datetime}":
return currentTime.Format("20060102150405")
case "{date}":
return currentTime.Format("20060102")
case "{year}":
return currentTime.Format("2006")
case "{month}":
return currentTime.Format("01")
case "{day}":
return currentTime.Format("02")
case "{hour}":
return currentTime.Format("15")
case "{minute}":
return currentTime.Format("04")
case "{second}":
return currentTime.Format("05")
case "{uid}":
return strconv.Itoa(user.ID)
case "{randomkey16}":
return util.RandStringRunes(16)
case "{randomkey8}":
return util.RandStringRunes(8)
case "{randomnum8}":
return strconv.Itoa(rand.Intn(8))
case "{randomnum4}":
return strconv.Itoa(rand.Intn(4))
case "{randomnum3}":
return strconv.Itoa(rand.Intn(3))
case "{randomnum2}":
return strconv.Itoa(rand.Intn(2))
case "{uuid}":
return uuid.Must(uuid.NewV4()).String()
case "{path}":
if pathAvailable {
return req.Props.Uri.Dir() + fs.Separator
}
return match
case "{originname}":
return originName
case "{ext}":
return filepath.Ext(originName)
case "{originname_without_ext}":
return strings.TrimSuffix(originName, filepath.Ext(originName))
default:
return match
}
})
}
dirRule := policy.DirNameRule
dirRule = filepath.ToSlash(dirRule)
dirRule = util.Replace(baseTable, dirRule)
dirRule = util.Replace(map[string]string{
"{path}": req.Props.Uri.Dir() + fs.Separator,
}, dirRule)
originName := req.Props.Uri.Name()
nameTable := map[string]string{
"{originname}": originName,
"{ext}": filepath.Ext(originName),
"{originname_without_ext}": strings.TrimSuffix(originName, filepath.Ext(originName)),
"{uuid}": uuid.Must(uuid.NewV4()).String(),
}
dirRule = dynamicReplace(`\{[^{}]+\}`, dirRule, true)
nameRule := policy.FileNameRule
nameRule = util.Replace(baseTable, nameRule)
nameRule = util.Replace(nameTable, nameRule)
nameRule = dynamicReplace(`\{[^{}]+\}`, nameRule, false)
return path.Join(path.Clean(dirRule), nameRule)
}

View File

@@ -120,6 +120,20 @@ func (f *DBFS) Create(ctx context.Context, path *fs.URI, fileType types.FileType
ancestor = newFile(ancestor, newFolder)
} else {
// valide file name
policy, err := f.getPreferredPolicy(ctx, ancestor)
if err != nil {
return nil, err
}
if err := validateExtension(desired[i], policy); err != nil {
return nil, fs.ErrIllegalObjectName.WithError(err)
}
if err := validateFileNameRegexp(desired[i], policy); err != nil {
return nil, fs.ErrIllegalObjectName.WithError(err)
}
file, err := f.createFile(ctx, ancestor, desired[i], fileType, o)
if err != nil {
return nil, err
@@ -170,6 +184,10 @@ func (f *DBFS) Rename(ctx context.Context, path *fs.URI, newName string) (fs.Fil
if err := validateExtension(newName, policy); err != nil {
return nil, fs.ErrIllegalObjectName.WithError(err)
}
if err := validateFileNameRegexp(newName, policy); err != nil {
return nil, fs.ErrIllegalObjectName.WithError(err)
}
}
// Lock target

View File

@@ -3,6 +3,7 @@ package dbfs
import (
"context"
"fmt"
"time"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
@@ -100,6 +101,7 @@ func (f *DBFS) PatchMetadata(ctx context.Context, path []*fs.URI, metas ...fs.Me
metadataMap := make(map[string]string)
privateMap := make(map[string]bool)
deleted := make([]string, 0)
updateModifiedAt := false
for _, meta := range metas {
if meta.Remove {
deleted = append(deleted, meta.Key)
@@ -109,6 +111,9 @@ func (f *DBFS) PatchMetadata(ctx context.Context, path []*fs.URI, metas ...fs.Me
if meta.Private {
privateMap[meta.Key] = meta.Private
}
if meta.UpdateModifiedAt {
updateModifiedAt = true
}
}
fc, tx, ctx, err := inventory.WithTx(ctx, f.fileClient)
@@ -128,6 +133,13 @@ func (f *DBFS) PatchMetadata(ctx context.Context, path []*fs.URI, metas ...fs.Me
return fmt.Errorf("failed to remove metadata: %w", err)
}
}
if updateModifiedAt {
if err := fc.UpdateModifiedAt(ctx, target.Model, time.Now()); err != nil {
_ = inventory.Rollback(tx)
return fmt.Errorf("failed to update file modified at: %w", err)
}
}
}
if err := inventory.Commit(tx); err != nil {

View File

@@ -3,10 +3,12 @@ package dbfs
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"strings"
)
const MaxFileNameLength = 256
@@ -30,18 +32,35 @@ func validateFileName(name string) error {
// validateExtension validates the file extension.
func validateExtension(name string, policy *ent.StoragePolicy) error {
// 不需要验证
if len(policy.Settings.FileType) == 0 {
return nil
}
if !util.IsInExtensionList(policy.Settings.FileType, name) {
inList := util.IsInExtensionList(policy.Settings.FileType, name)
if (policy.Settings.IsFileTypeDenyList && inList) || (!policy.Settings.IsFileTypeDenyList && !inList) {
return fmt.Errorf("file extension is not allowed")
}
return nil
}
func validateFileNameRegexp(name string, policy *ent.StoragePolicy) error {
if policy.Settings.NameRegexp == "" {
return nil
}
match, err := regexp.MatchString(policy.Settings.NameRegexp, name)
if err != nil {
return fmt.Errorf("invalid file name regexp: %s", err)
}
if (policy.Settings.IsNameRegexpDenyList && match) || (!policy.Settings.IsNameRegexpDenyList && !match) {
return fmt.Errorf("file name is not allowed by regexp")
}
return nil
}
// validateFileSize validates the file size.
func validateFileSize(size int64, policy *ent.StoragePolicy) error {
if policy.MaxSize == 0 {
@@ -56,11 +75,15 @@ func validateFileSize(size int64, policy *ent.StoragePolicy) error {
// validateNewFile validates the upload request.
func validateNewFile(fileName string, size int64, policy *ent.StoragePolicy) error {
if err := validateFileName(fileName); err != nil {
return err
return fs.ErrIllegalObjectName.WithError(err)
}
if err := validateExtension(fileName, policy); err != nil {
return err
return fs.ErrIllegalObjectName.WithError(err)
}
if err := validateFileNameRegexp(fileName, policy); err != nil {
return fs.ErrIllegalObjectName.WithError(err)
}
if err := validateFileSize(size, policy); err != nil {

View File

@@ -203,10 +203,11 @@ type (
}
MetadataPatch struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
Private bool `json:"private" binding:"ne=true"`
Remove bool `json:"remove"`
Key string `json:"key" binding:"required"`
Value string `json:"value"`
Private bool `json:"private" binding:"ne=true"`
Remove bool `json:"remove"`
UpdateModifiedAt bool `json:"-"`
}
// ListFileResult result of listing files.

View File

@@ -25,6 +25,7 @@ const (
QuerySearchNameOpOr = "name_op_or"
QuerySearchUseOr = "use_or"
QuerySearchMetadataPrefix = "meta_"
QuerySearchMetadataExact = "exact_meta_"
QuerySearchCaseFolding = "case_folding"
QuerySearchType = "type"
QuerySearchTypeCategory = "category"
@@ -218,7 +219,7 @@ func (u *URI) FileSystem() constants.FileSystemType {
func (u *URI) SearchParameters() *inventory.SearchFileParameters {
q := u.U.Query()
res := &inventory.SearchFileParameters{
Metadata: make(map[string]string),
Metadata: make([]inventory.MetadataFilter, 0),
}
withSearch := false
@@ -252,7 +253,18 @@ func (u *URI) SearchParameters() *inventory.SearchFileParameters {
for k, v := range q {
if strings.HasPrefix(k, QuerySearchMetadataPrefix) {
res.Metadata[strings.TrimPrefix(k, QuerySearchMetadataPrefix)] = v[0]
res.Metadata = append(res.Metadata, inventory.MetadataFilter{
Key: strings.TrimPrefix(k, QuerySearchMetadataPrefix),
Value: v[0],
Exact: false,
})
withSearch = true
} else if strings.HasPrefix(k, QuerySearchMetadataExact) {
res.Metadata = append(res.Metadata, inventory.MetadataFilter{
Key: strings.TrimPrefix(k, QuerySearchMetadataExact),
Value: v[0],
Exact: true,
})
withSearch = true
}
}

View File

@@ -168,7 +168,7 @@ func (m *manager) GetUrlForRedirectedDirectLink(ctx context.Context, dl *ent.Dir
)
// Try to read from cache.
cacheKey := entityUrlCacheKey(primaryEntity.ID(), int64(dl.Speed), dl.Name, false,
cacheKey := entityUrlCacheKey(primaryEntity.ID(), int64(dl.Speed), dl.Name, o.IsDownload,
m.settings.SiteURL(ctx).String())
if cached, ok := m.kv.Get(cacheKey); ok {
cachedItem := cached.(EntityUrlCache)
@@ -185,7 +185,7 @@ func (m *manager) GetUrlForRedirectedDirectLink(ctx context.Context, dl *ent.Dir
m.l, m.config, m.dep.MimeDetector(ctx))
downloadUrl, err := source.Url(ctx,
entitysource.WithExpire(o.Expire),
entitysource.WithDownload(false),
entitysource.WithDownload(o.IsDownload),
entitysource.WithSpeedLimit(int64(dl.Speed)),
entitysource.WithDisplayName(dl.Name),
)

View File

@@ -8,6 +8,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/cluster"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/cos"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/ks3"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/local"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/obs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/onedrive"
@@ -73,6 +74,8 @@ func (m *manager) GetStorageDriver(ctx context.Context, policy *ent.StoragePolic
return cos.New(ctx, policy, m.settings, m.config, m.l, m.dep.MimeDetector(ctx))
case types.PolicyTypeS3:
return s3.New(ctx, policy, m.settings, m.config, m.l, m.dep.MimeDetector(ctx))
case types.PolicyTypeKs3:
return ks3.New(ctx, policy, m.settings, m.config, m.l, m.dep.MimeDetector(ctx))
case types.PolicyTypeObs:
return obs.New(ctx, policy, m.settings, m.config, m.l, m.dep.MimeDetector(ctx))
case types.PolicyTypeQiniu:

View File

@@ -5,14 +5,18 @@ import (
"crypto/sha1"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/go-playground/validator/v10"
"strings"
"github.com/samber/lo"
)
type (
@@ -20,13 +24,14 @@ type (
)
const (
wildcardMetadataKey = "*"
customizeMetadataSuffix = "customize"
tagMetadataSuffix = "tag"
iconColorMetadataKey = customizeMetadataSuffix + ":icon_color"
emojiIconMetadataKey = customizeMetadataSuffix + ":emoji"
shareOwnerMetadataKey = dbfs.MetadataSysPrefix + "shared_owner"
shareRedirectMetadataKey = dbfs.MetadataSysPrefix + "shared_redirect"
wildcardMetadataKey = "*"
customizeMetadataSuffix = "customize"
tagMetadataSuffix = "tag"
customPropsMetadataSuffix = "props"
iconColorMetadataKey = customizeMetadataSuffix + ":icon_color"
emojiIconMetadataKey = customizeMetadataSuffix + ":emoji"
shareOwnerMetadataKey = dbfs.MetadataSysPrefix + "shared_owner"
shareRedirectMetadataKey = dbfs.MetadataSysPrefix + "shared_redirect"
)
var (
@@ -38,6 +43,8 @@ var (
// validateColor validates a color value
validateColor = func(optional bool) metadataValidator {
return func(ctx context.Context, m *manager, patch *fs.MetadataPatch) error {
patch.UpdateModifiedAt = true
if patch.Remove {
return nil
}
@@ -62,6 +69,8 @@ var (
return fmt.Errorf("cannot remove system metadata")
}
patch.UpdateModifiedAt = true
dep := dependency.FromContext(ctx)
// Validate share owner is valid hashid
if patch.Key == shareOwnerMetadataKey {
@@ -91,6 +100,8 @@ var (
customizeMetadataSuffix: {
iconColorMetadataKey: validateColor(false),
emojiIconMetadataKey: func(ctx context.Context, m *manager, patch *fs.MetadataPatch) error {
patch.UpdateModifiedAt = true
if patch.Remove {
return nil
}
@@ -120,6 +131,8 @@ var (
},
tagMetadataSuffix: {
wildcardMetadataKey: func(ctx context.Context, m *manager, patch *fs.MetadataPatch) error {
patch.UpdateModifiedAt = true
if err := validateColor(true)(ctx, m, patch); err != nil {
return err
}
@@ -131,44 +144,170 @@ var (
return nil
},
},
customPropsMetadataSuffix: {
wildcardMetadataKey: func(ctx context.Context, m *manager, patch *fs.MetadataPatch) error {
patch.UpdateModifiedAt = true
if patch.Remove {
return nil
}
customProps := m.settings.CustomProps(ctx)
propId := strings.TrimPrefix(patch.Key, customPropsMetadataSuffix+":")
for _, prop := range customProps {
if prop.ID == propId {
switch prop.Type {
case types.CustomPropsTypeText:
if prop.Min > 0 && prop.Min > len(patch.Value) {
return fmt.Errorf("value is too short")
}
if prop.Max > 0 && prop.Max < len(patch.Value) {
return fmt.Errorf("value is too long")
}
return nil
case types.CustomPropsTypeRating:
if patch.Value == "" {
return nil
}
// validate the value is a number
rating, err := strconv.Atoi(patch.Value)
if err != nil {
return fmt.Errorf("value is not a number")
}
if prop.Max < rating {
return fmt.Errorf("value is too large")
}
return nil
case types.CustomPropsTypeNumber:
if patch.Value == "" {
return nil
}
value, err := strconv.Atoi(patch.Value)
if err != nil {
return fmt.Errorf("value is not a number")
}
if prop.Min > value {
return fmt.Errorf("value is too small")
}
if prop.Max > 0 && prop.Max < value {
return fmt.Errorf("value is too large")
}
return nil
case types.CustomPropsTypeBoolean:
if patch.Value == "" {
return nil
}
if patch.Value != "true" && patch.Value != "false" {
return fmt.Errorf("value is not a boolean")
}
return nil
case types.CustomPropsTypeSelect:
if patch.Value == "" {
return nil
}
for _, option := range prop.Options {
if option == patch.Value {
return nil
}
}
return fmt.Errorf("invalid option")
case types.CustomPropsTypeMultiSelect:
if patch.Value == "" {
return nil
}
var values []string
if err := json.Unmarshal([]byte(patch.Value), &values); err != nil {
return fmt.Errorf("invalid multi select value: %w", err)
}
// make sure all values are in the options
for _, value := range values {
if !lo.Contains(prop.Options, value) {
return fmt.Errorf("invalid option")
}
}
return nil
case types.CustomPropsTypeLink:
if patch.Value == "" {
return nil
}
if prop.Min > 0 && len(patch.Value) < prop.Min {
return fmt.Errorf("value is too small")
}
if prop.Max > 0 && len(patch.Value) > prop.Max {
return fmt.Errorf("value is too large")
}
return nil
default:
return nil
}
}
}
return fmt.Errorf("unkown custom props")
},
},
}
)
func (m *manager) PatchMedata(ctx context.Context, path []*fs.URI, data ...fs.MetadataPatch) error {
if err := m.validateMetadata(ctx, data...); err != nil {
data, err := m.validateMetadata(ctx, data...)
if err != nil {
return err
}
return m.fs.PatchMetadata(ctx, path, data...)
}
func (m *manager) validateMetadata(ctx context.Context, data ...fs.MetadataPatch) error {
func (m *manager) validateMetadata(ctx context.Context, data ...fs.MetadataPatch) ([]fs.MetadataPatch, error) {
validated := make([]fs.MetadataPatch, 0, len(data))
for _, patch := range data {
category := strings.Split(patch.Key, ":")
if len(category) < 2 {
return serializer.NewError(serializer.CodeParamErr, "Invalid metadata key", nil)
return validated, serializer.NewError(serializer.CodeParamErr, "Invalid metadata key", nil)
}
categoryValidators, ok := validators[category[0]]
if !ok {
return serializer.NewError(serializer.CodeParamErr, "Invalid metadata key",
return validated, serializer.NewError(serializer.CodeParamErr, "Invalid metadata key",
fmt.Errorf("unknown category: %s", category[0]))
}
// Explicit validators
if v, ok := categoryValidators[patch.Key]; ok {
if err := v(ctx, m, &patch); err != nil {
return serializer.NewError(serializer.CodeParamErr, "Invalid metadata patch", err)
return validated, serializer.NewError(serializer.CodeParamErr, "Invalid metadata patch", err)
}
}
// Wildcard validators
if v, ok := categoryValidators[wildcardMetadataKey]; ok {
if err := v(ctx, m, &patch); err != nil {
return serializer.NewError(serializer.CodeParamErr, "Invalid metadata patch", err)
return validated, serializer.NewError(serializer.CodeParamErr, "Invalid metadata patch", err)
}
}
validated = append(validated, patch)
}
return nil
return validated, nil
}

View File

@@ -115,7 +115,7 @@ func (m *manager) Create(ctx context.Context, path *fs.URI, fileType types.FileT
isSymbolic := false
if o.Metadata != nil {
if err := m.validateMetadata(ctx, lo.MapToSlice(o.Metadata, func(key string, value string) fs.MetadataPatch {
_, err := m.validateMetadata(ctx, lo.MapToSlice(o.Metadata, func(key string, value string) fs.MetadataPatch {
if key == shareRedirectMetadataKey {
isSymbolic = true
}
@@ -124,7 +124,8 @@ func (m *manager) Create(ctx context.Context, path *fs.URI, fileType types.FileT
Key: key,
Value: value,
}
})...); err != nil {
})...)
if err != nil {
return nil, err
}
}

View File

@@ -311,6 +311,7 @@ func CronCollectTrashBin(ctx context.Context) {
res, err := fm.fs.AllFilesInTrashBin(ctx, fs.WithPageSize(pageSize))
if err != nil {
l.Error("Failed to get files in trash bin: %s", err)
return
}
expired := lo.Filter(res.Files, func(file fs.File, index int) bool {

View File

@@ -55,7 +55,7 @@ func (m *manager) CreateUploadSession(ctx context.Context, req *fs.UploadRequest
// Validate metadata
if req.Props.Metadata != nil {
if err := m.validateMetadata(ctx, lo.MapToSlice(req.Props.Metadata, func(key string, value string) fs.MetadataPatch {
if _, err := m.validateMetadata(ctx, lo.MapToSlice(req.Props.Metadata, func(key string, value string) fs.MetadataPatch {
return fs.MetadataPatch{
Key: key,
Value: value,

View File

@@ -8,6 +8,7 @@ import (
"os"
"path"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
@@ -318,7 +319,7 @@ func (m *RemoteDownloadTask) slaveTransfer(ctx context.Context, dep dependency.D
continue
}
dst := dstUri.JoinRaw(f.Name)
dst := dstUri.JoinRaw(sanitizeFileName(f.Name))
src := path.Join(m.state.Status.SavePath, f.Name)
payload.Files = append(payload.Files, SlaveUploadEntity{
Src: src,
@@ -431,25 +432,29 @@ func (m *RemoteDownloadTask) masterTransfer(ctx context.Context, dep dependency.
ae := serializer.NewAggregateError()
transferFunc := func(workerId int, file downloader.TaskFile) {
defer func() {
atomic.AddInt64(&m.progress[ProgressTypeUploadCount].Current, 1)
worker <- workerId
wg.Done()
}()
dst := dstUri.JoinRaw(file.Name)
sanitizedName := sanitizeFileName(file.Name)
dst := dstUri.JoinRaw(sanitizedName)
src := filepath.FromSlash(path.Join(m.state.Status.SavePath, file.Name))
m.l.Info("Uploading file %s to %s...", src, file.Name, dst)
m.l.Info("Uploading file %s to %s...", src, sanitizedName, dst)
progressKey := fmt.Sprintf("%s%d", ProgressTypeUploadSinglePrefix, workerId)
m.Lock()
m.progress[progressKey] = &queue.Progress{Identifier: dst.String(), Total: file.Size}
fileProgress := m.progress[progressKey]
uploadProgress := m.progress[ProgressTypeUpload]
uploadCountProgress := m.progress[ProgressTypeUploadCount]
m.Unlock()
defer func() {
atomic.AddInt64(&uploadCountProgress.Current, 1)
worker <- workerId
wg.Done()
}()
fileStream, err := os.Open(src)
if err != nil {
m.l.Warning("Failed to open file %s: %s", src, err.Error())
atomic.AddInt64(&m.progress[ProgressTypeUpload].Current, file.Size)
atomic.AddInt64(&uploadProgress.Current, file.Size)
atomic.AddInt64(&failed, 1)
ae.Add(file.Name, fmt.Errorf("failed to open file: %w", err))
return
@@ -463,8 +468,8 @@ func (m *RemoteDownloadTask) masterTransfer(ctx context.Context, dep dependency.
Size: file.Size,
},
ProgressFunc: func(current, diff int64, total int64) {
atomic.AddInt64(&m.progress[progressKey].Current, diff)
atomic.AddInt64(&m.progress[ProgressTypeUpload].Current, diff)
atomic.AddInt64(&fileProgress.Current, diff)
atomic.AddInt64(&uploadProgress.Current, diff)
},
File: fileStream,
}
@@ -473,7 +478,7 @@ func (m *RemoteDownloadTask) masterTransfer(ctx context.Context, dep dependency.
if err != nil {
m.l.Warning("Failed to upload file %s: %s", src, err.Error())
atomic.AddInt64(&failed, 1)
atomic.AddInt64(&m.progress[ProgressTypeUpload].Current, file.Size)
atomic.AddInt64(&uploadProgress.Current, file.Size)
ae.Add(file.Name, fmt.Errorf("failed to upload file: %w", err))
return
}
@@ -488,8 +493,10 @@ func (m *RemoteDownloadTask) masterTransfer(ctx context.Context, dep dependency.
// Check if file is already transferred
if _, ok := m.state.Transferred[file.Index]; ok {
m.l.Info("File %s already transferred, skipping...", file.Name)
m.Lock()
atomic.AddInt64(&m.progress[ProgressTypeUpload].Current, file.Size)
atomic.AddInt64(&m.progress[ProgressTypeUploadCount].Current, 1)
m.Unlock()
continue
}
@@ -538,7 +545,7 @@ func (m *RemoteDownloadTask) validateFiles(ctx context.Context, dep dependency.D
validateArgs := lo.Map(selectedFiles, func(f downloader.TaskFile, _ int) fs.PreValidateFile {
return fs.PreValidateFile{
Name: f.Name,
Name: sanitizeFileName(f.Name),
Size: f.Size,
OmitName: f.Name == "",
}
@@ -623,17 +630,21 @@ func (m *RemoteDownloadTask) Progress(ctx context.Context) queue.Progresses {
m.Lock()
defer m.Unlock()
if m.state.NodeState.progress != nil {
merged := make(queue.Progresses)
for k, v := range m.progress {
merged[k] = v
}
merged := make(queue.Progresses)
for k, v := range m.progress {
merged[k] = v
}
if m.state.NodeState.progress != nil {
for k, v := range m.state.NodeState.progress {
merged[k] = v
}
return merged
}
return m.progress
return merged
}
func sanitizeFileName(name string) string {
r := strings.NewReplacer("\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
return r.Replace(name)
}

View File

@@ -115,23 +115,26 @@ func (t *SlaveUploadTask) Do(ctx context.Context) (task.Status, error) {
atomic.StoreInt64(&t.progress[ProgressTypeUpload].Total, totalSize)
ae := serializer.NewAggregateError()
transferFunc := func(workerId, fileId int, file SlaveUploadEntity) {
defer func() {
atomic.AddInt64(&t.progress[ProgressTypeUploadCount].Current, 1)
worker <- workerId
wg.Done()
}()
t.l.Info("Uploading file %s to %s...", file.Src, file.Uri.String())
progressKey := fmt.Sprintf("%s%d", ProgressTypeUploadSinglePrefix, workerId)
t.Lock()
t.progress[progressKey] = &queue.Progress{Identifier: file.Uri.String(), Total: file.Size}
fileProgress := t.progress[progressKey]
uploadProgress := t.progress[ProgressTypeUpload]
uploadCountProgress := t.progress[ProgressTypeUploadCount]
t.Unlock()
defer func() {
atomic.AddInt64(&uploadCountProgress.Current, 1)
worker <- workerId
wg.Done()
}()
handle, err := os.Open(filepath.FromSlash(file.Src))
if err != nil {
t.l.Warning("Failed to open file %s: %s", file.Src, err.Error())
atomic.AddInt64(&t.progress[ProgressTypeUpload].Current, file.Size)
atomic.AddInt64(&fileProgress.Current, file.Size)
ae.Add(path.Base(file.Src), fmt.Errorf("failed to open file: %w", err))
return
}
@@ -140,7 +143,7 @@ func (t *SlaveUploadTask) Do(ctx context.Context) (task.Status, error) {
if err != nil {
t.l.Warning("Failed to get file stat for %s: %s", file.Src, err.Error())
handle.Close()
atomic.AddInt64(&t.progress[ProgressTypeUpload].Current, file.Size)
atomic.AddInt64(&fileProgress.Current, file.Size)
ae.Add(path.Base(file.Src), fmt.Errorf("failed to get file stat: %w", err))
return
}
@@ -151,9 +154,9 @@ func (t *SlaveUploadTask) Do(ctx context.Context) (task.Status, error) {
Size: stat.Size(),
},
ProgressFunc: func(current, diff int64, total int64) {
atomic.AddInt64(&t.progress[progressKey].Current, diff)
atomic.AddInt64(&t.progress[ProgressTypeUpload].Current, diff)
atomic.StoreInt64(&t.progress[progressKey].Total, total)
atomic.AddInt64(&fileProgress.Current, diff)
atomic.AddInt64(&uploadCountProgress.Current, 1)
atomic.StoreInt64(&fileProgress.Total, total)
},
File: handle,
Seeker: handle,
@@ -163,7 +166,7 @@ func (t *SlaveUploadTask) Do(ctx context.Context) (task.Status, error) {
if err != nil {
handle.Close()
t.l.Warning("Failed to upload file %s: %s", file.Src, err.Error())
atomic.AddInt64(&t.progress[ProgressTypeUpload].Current, file.Size)
atomic.AddInt64(&uploadProgress.Current, file.Size)
ae.Add(path.Base(file.Src), fmt.Errorf("failed to upload file: %w", err))
return
}
@@ -179,8 +182,10 @@ func (t *SlaveUploadTask) Do(ctx context.Context) (task.Status, error) {
// Check if file is already transferred
if _, ok := t.state.Transferred[fileId]; ok {
t.l.Info("File %s already transferred, skipping...", file.Src)
t.Lock()
atomic.AddInt64(&t.progress[ProgressTypeUpload].Current, file.Size)
atomic.AddInt64(&t.progress[ProgressTypeUploadCount].Current, 1)
t.Unlock()
continue
}
@@ -221,5 +226,9 @@ func (m *SlaveUploadTask) Progress(ctx context.Context) queue.Progresses {
m.Lock()
defer m.Unlock()
return m.progress
res := make(queue.Progresses)
for k, v := range m.progress {
res[k] = v
}
return res
}

View File

@@ -196,6 +196,14 @@ type (
LibRawThumbExts(ctx context.Context) []string
// LibRawThumbPath returns the path of libraw executable.
LibRawThumbPath(ctx context.Context) string
// CustomProps returns the custom props settings.
CustomProps(ctx context.Context) []types.CustomProps
// CustomNavItems returns the custom nav items settings.
CustomNavItems(ctx context.Context) []CustomNavItem
// CustomHTML returns the custom HTML settings.
CustomHTML(ctx context.Context) *CustomHTML
// FFMpegExtraArgs returns the extra arguments of ffmpeg thumb generator.
FFMpegExtraArgs(ctx context.Context) string
}
UseFirstSiteUrlCtxKey = struct{}
)
@@ -223,6 +231,30 @@ type (
}
)
func (s *settingProvider) CustomHTML(ctx context.Context) *CustomHTML {
return &CustomHTML{
HeadlessFooter: s.getString(ctx, "headless_footer_html", ""),
HeadlessBody: s.getString(ctx, "headless_bottom_html", ""),
SidebarBottom: s.getString(ctx, "sidebar_bottom_html", ""),
}
}
func (s *settingProvider) CustomNavItems(ctx context.Context) []CustomNavItem {
raw := s.getString(ctx, "custom_nav_items", "[]")
var items []CustomNavItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return []CustomNavItem{}
}
return items
}
func (s *settingProvider) CustomProps(ctx context.Context) []types.CustomProps {
raw := s.getString(ctx, "custom_props", "[]")
var props []types.CustomProps
if err := json.Unmarshal([]byte(raw), &props); err != nil {
return []types.CustomProps{}
}
return props
}
func (s *settingProvider) License(ctx context.Context) string {
return s.getString(ctx, "license", "")
}
@@ -376,6 +408,10 @@ func (s *settingProvider) FFMpegThumbSeek(ctx context.Context) string {
return s.getString(ctx, "thumb_ffmpeg_seek", "00:00:01.00")
}
func (s *settingProvider) FFMpegExtraArgs(ctx context.Context) string {
return s.getString(ctx, "thumb_ffmpeg_extra_args", "")
}
func (s *settingProvider) FFMpegThumbMaxSize(ctx context.Context) int64 {
return s.getInt64(ctx, "thumb_ffmpeg_max_size", 10737418240)
}

View File

@@ -209,3 +209,15 @@ type AvatarProcess struct {
MaxFileSize int64 `json:"max_file_size"`
MaxWidth int `json:"max_width"`
}
type CustomNavItem struct {
Icon string `json:"icon"`
Name string `json:"name"`
URL string `json:"url"`
}
type CustomHTML struct {
HeadlessFooter string `json:"headless_footer,omitempty"`
HeadlessBody string `json:"headless_bottom,omitempty"`
SidebarBottom string `json:"sidebar_bottom,omitempty"`
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver"
@@ -41,7 +42,7 @@ func (f *FfmpegGenerator) Generate(ctx context.Context, es entitysource.EntitySo
tempOutputPath := filepath.Join(
util.DataPath(f.settings.TempPath(ctx)),
thumbTempFolder,
fmt.Sprintf("thumb_%s.%s", uuid.Must(uuid.NewV4()).String(), f.settings.ThumbEncode(ctx).Format),
fmt.Sprintf("thumb_%s.png", uuid.Must(uuid.NewV4()).String()),
)
if err := util.CreatNestedFolder(filepath.Dir(tempOutputPath)); err != nil {
@@ -64,9 +65,22 @@ func (f *FfmpegGenerator) Generate(ctx context.Context, es entitysource.EntitySo
// Invoke ffmpeg
w, h := f.settings.ThumbSize(ctx)
scaleOpt := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", w, h)
cmd := exec.CommandContext(ctx,
f.settings.FFMpegPath(ctx), "-ss", f.settings.FFMpegThumbSeek(ctx), "-i", input,
"-vf", scaleOpt, "-vframes", "1", tempOutputPath)
args := []string{
"-ss", f.settings.FFMpegThumbSeek(ctx),
}
extraArgs := f.settings.FFMpegExtraArgs(ctx)
if extraArgs != "" {
args = append(args, strings.Split(extraArgs, " ")...)
}
args = append(args, []string{
"-i", input,
"-vf", scaleOpt,
"-vframes", "1",
tempOutputPath,
}...)
cmd := exec.CommandContext(ctx, f.settings.FFMpegPath(ctx), args...)
// Redirect IO
var stdErr bytes.Buffer

View File

@@ -69,10 +69,9 @@ func (l *LibreOfficeGenerator) Generate(ctx context.Context, es entitysource.Ent
}
// Convert the document to an image
encode := l.settings.ThumbEncode(ctx)
cmd := exec.CommandContext(ctx, l.settings.LibreOfficePath(ctx), "--headless",
"--nologo", "--nofirststartwizard", "--invisible", "--norestore", "--convert-to",
encode.Format, "--outdir", tempOutputPath, tempInputPath)
"png", "--outdir", tempOutputPath, tempInputPath)
// Redirect IO
var stdErr bytes.Buffer
@@ -86,7 +85,7 @@ func (l *LibreOfficeGenerator) Generate(ctx context.Context, es entitysource.Ent
return &Result{
Path: filepath.Join(
tempOutputPath,
strings.TrimSuffix(filepath.Base(tempInputPath), filepath.Ext(tempInputPath))+"."+encode.Format,
strings.TrimSuffix(filepath.Base(tempInputPath), filepath.Ext(tempInputPath))+".png",
),
Continue: true,
Cleanup: []func(){func() { _ = os.RemoveAll(tempOutputPath) }},

View File

@@ -38,8 +38,8 @@ func (v *VipsGenerator) Generate(ctx context.Context, es entitysource.EntitySour
outputOpt := ".png"
encode := v.settings.ThumbEncode(ctx)
if encode.Format == "jpg" {
outputOpt = fmt.Sprintf(".jpg[Q=%d]", encode.Quality)
if encode.Format == "jpg" || encode.Format == "webp" {
outputOpt = fmt.Sprintf(".%s[Q=%d]", encode.Format, encode.Quality)
}
input := "[descriptor=0]"

View File

@@ -86,12 +86,14 @@ func ExtractArchive(c *gin.Context) {
}
// AnonymousPermLink 文件中转后的永久直链接
func AnonymousPermLink(c *gin.Context) {
name := c.Param("name")
if err := explorer.RedirectDirectLink(c, name); err != nil {
c.JSON(404, serializer.Err(c, err))
c.Abort()
return
func AnonymousPermLink(download bool) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
if err := explorer.RedirectDirectLink(c, name, download); err != nil {
c.JSON(404, serializer.Err(c, err))
c.Abort()
return
}
}
}

View File

@@ -245,7 +245,10 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
{
source.GET(":id/:name",
middleware.HashID(hashid.SourceLinkID),
controllers.AnonymousPermLink)
controllers.AnonymousPermLink(false))
source.GET("d/:id/:name",
middleware.HashID(hashid.SourceLinkID),
controllers.AnonymousPermLink(true))
}
shareShort := r.Group("s")
@@ -489,6 +492,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
middleware.UseUploadSession(types.PolicyTypeS3),
controllers.ProcessCallback(http.StatusBadRequest, false),
)
// 金山 ks3策略上传回调
callback.GET(
"ks3/:sessionID/:key",
middleware.UseUploadSession(types.PolicyTypeKs3),
controllers.ProcessCallback(http.StatusBadRequest, false),
)
// Huawei OBS upload callback
callback.POST(
"obs/:sessionID/:key",

View File

@@ -17,6 +17,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/cloudreve/Cloudreve/v4/pkg/credmanager"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/cos"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/ks3"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/obs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/onedrive"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/oss"
@@ -362,6 +363,17 @@ func (service *CreateStoragePolicyCorsService) Create(c *gin.Context) error {
return nil
case types.PolicyTypeKs3:
handler, err := ks3.New(c, service.Policy, dep.SettingProvider(), dep.ConfigProvider(), dep.Logger(), dep.MimeDetector(c))
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to create ks3 driver", err)
}
if err := handler.CORS(); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to create cors: "+err.Error(), err)
}
return nil
case types.PolicyTypeObs:
handler, err := obs.New(c, service.Policy, dep.SettingProvider(), dep.ConfigProvider(), dep.Logger(), dep.MimeDetector(c))
if err != nil {

View File

@@ -5,6 +5,8 @@ import (
"net/http"
"strconv"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
@@ -12,9 +14,8 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/wopi"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/gin-gonic/gin"
"github.com/go-mail/mail"
"github.com/wneessen/go-mail"
)
type (
@@ -138,26 +139,30 @@ func (s *TestSMTPService) Test(c *gin.Context) error {
return serializer.NewError(serializer.CodeParamErr, "Invalid SMTP port", err)
}
d := mail.NewDialer(s.Settings["smtpHost"], port, s.Settings["smtpUser"], s.Settings["smtpPass"])
d.SSL = false
tlsPolicy := mail.TLSOpportunistic
if setting.IsTrueValue(s.Settings["smtpEncryption"]) {
d.SSL = true
tlsPolicy = mail.TLSMandatory
}
d.StartTLSPolicy = mail.OpportunisticStartTLS
sender, err := d.Dial()
if err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to connect to SMTP server: "+err.Error(), err)
d, diaErr := mail.NewClient(s.Settings["smtpHost"],
mail.WithPort(port),
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(tlsPolicy),
mail.WithUsername(s.Settings["smtpUser"]), mail.WithPassword(s.Settings["smtpPass"]),
)
if diaErr != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to create SMTP client: "+diaErr.Error(), diaErr)
}
m := mail.NewMessage()
m.SetHeader("From", s.Settings["fromAdress"])
m.SetAddressHeader("Reply-To", s.Settings["replyTo"], s.Settings["fromName"])
m.SetHeader("To", s.To)
m.SetHeader("Subject", "Cloudreve SMTP Test")
m.SetBody("text/plain", "This is a test email from Cloudreve.")
m := mail.NewMsg()
if err := m.FromFormat(s.Settings["fromName"], s.Settings["fromAdress"]); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to set FROM address: "+err.Error(), err)
}
m.ReplyToFormat(s.Settings["fromName"], s.Settings["replyTo"])
m.To(s.To)
m.Subject("Cloudreve SMTP Test")
m.SetMessageID()
m.SetBodyString(mail.TypeTextHTML, "This is a test email from Cloudreve.")
err = mail.Send(sender, m)
err = d.DialAndSendWithContext(c, m)
if err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to send test email: "+err.Error(), err)
}

View File

@@ -13,13 +13,15 @@ import (
// SiteConfig 站点全局设置序列
type SiteConfig struct {
// Basic Section
InstanceID string `json:"instance_id,omitempty"`
SiteName string `json:"title,omitempty"`
Themes string `json:"themes,omitempty"`
DefaultTheme string `json:"default_theme,omitempty"`
User *user.User `json:"user,omitempty"`
Logo string `json:"logo,omitempty"`
LogoLight string `json:"logo_light,omitempty"`
InstanceID string `json:"instance_id,omitempty"`
SiteName string `json:"title,omitempty"`
Themes string `json:"themes,omitempty"`
DefaultTheme string `json:"default_theme,omitempty"`
User *user.User `json:"user,omitempty"`
Logo string `json:"logo,omitempty"`
LogoLight string `json:"logo_light,omitempty"`
CustomNavItems []setting.CustomNavItem `json:"custom_nav_items,omitempty"`
CustomHTML *setting.CustomHTML `json:"custom_html,omitempty"`
// Login Section
LoginCaptcha bool `json:"login_captcha,omitempty"`
@@ -45,6 +47,7 @@ type SiteConfig struct {
MaxBatchSize int `json:"max_batch_size,omitempty"`
ThumbnailWidth int `json:"thumbnail_width,omitempty"`
ThumbnailHeight int `json:"thumbnail_height,omitempty"`
CustomProps []types.CustomProps `json:"custom_props,omitempty"`
// App settings
AppPromotion bool `json:"app_promotion,omitempty"`
@@ -87,6 +90,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
explorerSettings := settings.ExplorerFrontendSettings(c)
mapSettings := settings.MapSetting(c)
fileViewers := settings.FileViewers(c)
customProps := settings.CustomProps(c)
maxBatchSize := settings.MaxBatchedFile(c)
w, h := settings.ThumbSize(c)
for i := range fileViewers {
@@ -102,6 +106,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
GoogleMapTileType: mapSettings.GoogleTileType,
ThumbnailWidth: w,
ThumbnailHeight: h,
CustomProps: customProps,
}, nil
case "emojis":
emojis := settings.EmojiPresets(c)
@@ -125,7 +130,8 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
reCaptcha := settings.ReCaptcha(c)
capCaptcha := settings.CapCaptcha(c)
appSetting := settings.AppSetting(c)
customNavItems := settings.CustomNavItems(c)
customHTML := settings.CustomHTML(c)
return &SiteConfig{
InstanceID: siteBasic.ID,
SiteName: siteBasic.Name,
@@ -141,6 +147,8 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
CapSiteKey: capCaptcha.SiteKey,
CapAssetServer: capCaptcha.AssetServer,
AppPromotion: appSetting.Promotion,
CustomNavItems: customNavItems,
CustomHTML: customHTML,
}, nil
}

View File

@@ -659,7 +659,7 @@ func (s *GetFileInfoService) Get(c *gin.Context) (*FileResponse, error) {
return BuildFileResponse(c, user, file, dep.HashIDEncoder(), nil), nil
}
func RedirectDirectLink(c *gin.Context, name string) error {
func RedirectDirectLink(c *gin.Context, name string, download bool) error {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
@@ -680,6 +680,7 @@ func RedirectDirectLink(c *gin.Context, name string) error {
expire := time.Now().Add(settings.EntityUrlValidDuration(c))
res, earliestExpire, err := m.GetUrlForRedirectedDirectLink(c, dl,
fs.WithUrlExpire(&expire),
fs.WithIsDownload(download),
)
if err != nil {
return err

View File

@@ -250,12 +250,15 @@ type DirectLink struct {
}
type StoragePolicy struct {
ID string `json:"id"`
Name string `json:"name"`
AllowedSuffix []string `json:"allowed_suffix,omitempty"`
Type types.PolicyType `json:"type"`
MaxSize int64 `json:"max_size"`
Relay bool `json:"relay,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
AllowedSuffix []string `json:"allowed_suffix,omitempty"`
DeniedSuffix []string `json:"denied_suffix,omitempty"`
AllowedNameRegexp string `json:"allowed_name_regexp,omitempty"`
DeniedNameRegexp string `json:"denied_name_regexp,omitempty"`
Type types.PolicyType `json:"type"`
MaxSize int64 `json:"max_size"`
Relay bool `json:"relay,omitempty"`
}
type Entity struct {
@@ -268,19 +271,20 @@ type Entity struct {
}
type Share struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
RemainDownloads *int `json:"remain_downloads,omitempty"`
Visited int `json:"visited"`
Downloaded int `json:"downloaded,omitempty"`
Expires *time.Time `json:"expires,omitempty"`
Unlocked bool `json:"unlocked"`
SourceType *types.FileType `json:"source_type,omitempty"`
Owner user.User `json:"owner"`
CreatedAt time.Time `json:"created_at,omitempty"`
Expired bool `json:"expired"`
Url string `json:"url"`
ShowReadMe bool `json:"show_readme,omitempty"`
ID string `json:"id"`
Name string `json:"name,omitempty"`
RemainDownloads *int `json:"remain_downloads,omitempty"`
Visited int `json:"visited"`
Downloaded int `json:"downloaded,omitempty"`
Expires *time.Time `json:"expires,omitempty"`
Unlocked bool `json:"unlocked"`
PasswordProtected bool `json:"password_protected,omitempty"`
SourceType *types.FileType `json:"source_type,omitempty"`
Owner user.User `json:"owner"`
CreatedAt time.Time `json:"created_at,omitempty"`
Expired bool `json:"expired"`
Url string `json:"url"`
ShowReadMe bool `json:"show_readme,omitempty"`
// Only viewable by owner
IsPrivate bool `json:"is_private,omitempty"`
@@ -298,15 +302,16 @@ func BuildShare(s *ent.Share, base *url.URL, hasher hashid.Encoder, requester *e
redactLevel = user.RedactLevelUser
}
res := Share{
Name: name,
ID: hashid.EncodeShareID(hasher, s.ID),
Unlocked: unlocked,
Owner: user.BuildUserRedacted(owner, redactLevel, hasher),
Expired: inventory.IsShareExpired(s) != nil || expired,
Url: BuildShareLink(s, hasher, base),
CreatedAt: s.CreatedAt,
Visited: s.Views,
SourceType: util.ToPtr(t),
Name: name,
ID: hashid.EncodeShareID(hasher, s.ID),
Unlocked: unlocked,
Owner: user.BuildUserRedacted(owner, redactLevel, hasher),
Expired: inventory.IsShareExpired(s) != nil || expired,
Url: BuildShareLink(s, hasher, base, unlocked),
CreatedAt: s.CreatedAt,
Visited: s.Views,
SourceType: util.ToPtr(t),
PasswordProtected: s.Password != "",
}
if unlocked {
@@ -433,23 +438,42 @@ func BuildEntity(extendedInfo *fs.FileExtendedInfo, e fs.Entity, hasher hashid.E
}
}
func BuildShareLink(s *ent.Share, hasher hashid.Encoder, base *url.URL) string {
func BuildShareLink(s *ent.Share, hasher hashid.Encoder, base *url.URL, unlocked bool) string {
shareId := hashid.EncodeShareID(hasher, s.ID)
return routes.MasterShareUrl(base, shareId, s.Password).String()
if unlocked {
return routes.MasterShareUrl(base, shareId, s.Password).String()
}
return routes.MasterShareUrl(base, shareId, "").String()
}
func BuildStoragePolicy(sp *ent.StoragePolicy, hasher hashid.Encoder) *StoragePolicy {
if sp == nil {
return nil
}
return &StoragePolicy{
ID: hashid.EncodePolicyID(hasher, sp.ID),
Name: sp.Name,
Type: types.PolicyType(sp.Type),
MaxSize: sp.MaxSize,
AllowedSuffix: sp.Settings.FileType,
Relay: sp.Settings.Relay,
res := &StoragePolicy{
ID: hashid.EncodePolicyID(hasher, sp.ID),
Name: sp.Name,
Type: types.PolicyType(sp.Type),
MaxSize: sp.MaxSize,
Relay: sp.Settings.Relay,
}
if sp.Settings.IsFileTypeDenyList {
res.DeniedSuffix = sp.Settings.FileType
} else {
res.AllowedSuffix = sp.Settings.FileType
}
if sp.Settings.NameRegexp != "" {
if sp.Settings.IsNameRegexpDenyList {
res.DeniedNameRegexp = sp.Settings.NameRegexp
} else {
res.AllowedNameRegexp = sp.Settings.NameRegexp
}
}
return res
}
func WriteEventSourceHeader(c *gin.Context) {

View File

@@ -66,7 +66,7 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string,
}
base := dep.SettingProvider().SiteURL(c)
return explorer.BuildShareLink(share, dep.HashIDEncoder(), base), nil
return explorer.BuildShareLink(share, dep.HashIDEncoder(), base, true), nil
}
func DeleteShare(c *gin.Context, shareId int) error {

View File

@@ -137,6 +137,16 @@ func (s *ListShareService) ListInUserProfile(c *gin.Context, uid int) (*ListShar
hasher := dep.HashIDEncoder()
shareClient := dep.ShareClient()
targetUser, err := dep.UserClient().GetActiveByID(c, uid)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err)
}
if targetUser.Settings != nil && targetUser.Settings.ShareLinksInProfile == types.ProfileHideShare {
return nil, serializer.NewError(serializer.CodeParamErr, "User has disabled share links in profile", nil)
}
publicOnly := targetUser.Settings == nil || targetUser.Settings.ShareLinksInProfile == types.ProfilePublicShareOnly
args := &inventory.ListShareArgs{
PaginationArgs: &inventory.PaginationArgs{
UseCursorPagination: true,
@@ -146,7 +156,7 @@ func (s *ListShareService) ListInUserProfile(c *gin.Context, uid int) (*ListShar
OrderBy: s.OrderBy,
},
UserID: uid,
PublicOnly: true,
PublicOnly: publicOnly,
}
ctx := context.WithValue(c, inventory.LoadShareUser{}, true)

View File

@@ -29,6 +29,7 @@ type UserSettings struct {
TwoFAEnabled bool `json:"two_fa_enabled"`
Passkeys []Passkey `json:"passkeys,omitempty"`
DisableViewSync bool `json:"disable_view_sync"`
ShareLinksInProfile string `json:"share_links_in_profile"`
}
func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Parser) *UserSettings {
@@ -41,7 +42,8 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa
Passkeys: lo.Map(passkeys, func(item *ent.Passkey, index int) Passkey {
return BuildPasskey(item)
}),
DisableViewSync: u.Settings.DisableViewSync,
DisableViewSync: u.Settings.DisableViewSync,
ShareLinksInProfile: string(u.Settings.ShareLinksInProfile),
}
}
@@ -97,18 +99,19 @@ type BuiltinLoginResponse struct {
// User 用户序列化器
type User struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Nickname string `json:"nickname"`
Status user.Status `json:"status,omitempty"`
Avatar string `json:"avatar,omitempty"`
CreatedAt time.Time `json:"created_at"`
PreferredTheme string `json:"preferred_theme,omitempty"`
Anonymous bool `json:"anonymous,omitempty"`
Group *Group `json:"group,omitempty"`
Pined []types.PinedFile `json:"pined,omitempty"`
Language string `json:"language,omitempty"`
DisableViewSync bool `json:"disable_view_sync,omitempty"`
ID string `json:"id"`
Email string `json:"email,omitempty"`
Nickname string `json:"nickname"`
Status user.Status `json:"status,omitempty"`
Avatar string `json:"avatar,omitempty"`
CreatedAt time.Time `json:"created_at"`
PreferredTheme string `json:"preferred_theme,omitempty"`
Anonymous bool `json:"anonymous,omitempty"`
Group *Group `json:"group,omitempty"`
Pined []types.PinedFile `json:"pined,omitempty"`
Language string `json:"language,omitempty"`
DisableViewSync bool `json:"disable_view_sync,omitempty"`
ShareLinksInProfile types.ShareLinksInProfileLevel `json:"share_links_in_profile,omitempty"`
}
type Group struct {
@@ -153,18 +156,19 @@ func BuildWebAuthnList(credentials []webauthn.Credential) []WebAuthnCredentials
// BuildUser 序列化用户
func BuildUser(user *ent.User, idEncoder hashid.Encoder) User {
return User{
ID: hashid.EncodeUserID(idEncoder, user.ID),
Email: user.Email,
Nickname: user.Nick,
Status: user.Status,
Avatar: user.Avatar,
CreatedAt: user.CreatedAt,
PreferredTheme: user.Settings.PreferredTheme,
Anonymous: user.ID == 0,
Group: BuildGroup(user.Edges.Group, idEncoder),
Pined: user.Settings.Pined,
Language: user.Settings.Language,
DisableViewSync: user.Settings.DisableViewSync,
ID: hashid.EncodeUserID(idEncoder, user.ID),
Email: user.Email,
Nickname: user.Nick,
Status: user.Status,
Avatar: user.Avatar,
CreatedAt: user.CreatedAt,
PreferredTheme: user.Settings.PreferredTheme,
Anonymous: user.ID == 0,
Group: BuildGroup(user.Edges.Group, idEncoder),
Pined: user.Settings.Pined,
Language: user.Settings.Language,
DisableViewSync: user.Settings.DisableViewSync,
ShareLinksInProfile: user.Settings.ShareLinksInProfile,
}
}
@@ -193,10 +197,11 @@ func BuildUserRedacted(u *ent.User, level int, idEncoder hashid.Encoder) User {
userRaw := BuildUser(u, idEncoder)
user := User{
ID: userRaw.ID,
Nickname: userRaw.Nickname,
Avatar: userRaw.Avatar,
CreatedAt: userRaw.CreatedAt,
ID: userRaw.ID,
Nickname: userRaw.Nickname,
Avatar: userRaw.Avatar,
CreatedAt: userRaw.CreatedAt,
ShareLinksInProfile: userRaw.ShareLinksInProfile,
}
if userRaw.Group != nil {

View File

@@ -14,6 +14,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
@@ -221,6 +222,7 @@ type (
TwoFAEnabled *bool `json:"two_fa_enabled" binding:"omitempty"`
TwoFACode *string `json:"two_fa_code" binding:"omitempty"`
DisableViewSync *bool `json:"disable_view_sync" binding:"omitempty"`
ShareLinksInProfile *string `json:"share_links_in_profile" binding:"omitempty"`
}
PatchUserSettingParamsCtx struct{}
)
@@ -267,6 +269,11 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error {
saveSetting = true
}
if s.ShareLinksInProfile != nil {
u.Settings.ShareLinksInProfile = types.ShareLinksInProfileLevel(*s.ShareLinksInProfile)
saveSetting = true
}
if s.CurrentPassword != nil && s.NewPassword != nil {
if err := inventory.CheckPassword(u, *s.CurrentPassword); err != nil {
return serializer.NewError(serializer.CodeIncorrectPassword, "Incorrect password", err)