Merge pull request #182 from Dvorinka/feat/storage-policy-adv

feat(storage): group-allowed policies, per-dir policy, load-balance policy, user-facing relocate
pull/3587/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit 8c08df9530
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -176,8 +176,8 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [ ] Paid shares — `share.price_points` + gate page + purchase/resume-ticket flow; needs B.4 credits first
2. **Storage policy advanced** — multiple policies per group, per-directory binding, load-balancer policy, file migration (fixes #3518, #2961, #2262). See §1.3a.
- [x] PR #175 — resumable admin relocation task (entities or whole-policy scope), encryption-aware re-wrap, admin UI + per-policy migrate action (#9, #125, #136)
- [ ] Group→policies M:N join + group-editor multi-select; per-directory policy picker in FM toolbar; `StoragePolicyTypeLB` weighted children
- [ ] User-facing relocate — extend #175 path to FM context menu, restricted to group-allowed policies
- [x] Group→policies M:N (`allowed_policies` edge, empty = legacy single) + group-editor multi-select; per-directory `sys:preferred_policy` metadata marker with nearest-ancestor precedence (invalid marker cuts inheritance); user `preferred_policy` setting applied in own tree only; `load_balance` policy type with weighted children resolved before drivers (#182, fixes #2961)
- [x] User-facing relocate — `POST /file/relocate` from FM More-actions dialog, entity expansion + same-policy skip + dedup, restricted to group-allowed policies (#182, fixes #2262)
3. **SSO** — generic OIDC, Logto, multi-account switching, sign-up email filtering (fixes #3464, #3056, #3505). See §1.3a.
- [x] PR #141 — inbound OIDC consumer (auth-code + nonce, JWKS-verified RS256 id_tokens, userinfo fallback, auto-provisioning, one-time ticket handoff, SSRF-validated endpoints, redacted secret); covers Keycloak/Authentik/Logto/generic IdPs; sign-up email domain filtering (whitelist/blacklist + sub-address block) enforced at registration and SSO provisioning; multi-account lands via existing session `upsert` (fixes #3464, #3056)
- [ ] Multi-account switcher UI — N-token session store, avatar-menu switch + signed-out badges (frontend-only, backend already supports)

@ -1552,6 +1552,22 @@ func (c *GroupClient) QueryStoragePolicies(gr *Group) *StoragePolicyQuery {
return query
}
// QueryAllowedPolicies queries the allowed_policies edge of a Group.
func (c *GroupClient) QueryAllowedPolicies(gr *Group) *StoragePolicyQuery {
query := (&StoragePolicyClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := gr.ID
step := sqlgraph.NewStep(
sqlgraph.From(group.Table, group.FieldID, id),
sqlgraph.To(storagepolicy.Table, storagepolicy.FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, group.AllowedPoliciesTable, group.AllowedPoliciesPrimaryKey...),
)
fromV = sqlgraph.Neighbors(gr.driver.Dialect(), step)
return fromV, nil
}
return query
}
// Hooks returns the client hooks.
func (c *GroupClient) Hooks() []Hook {
hooks := c.hooks.Group
@ -2943,6 +2959,22 @@ func (c *StoragePolicyClient) QueryEntities(sp *StoragePolicy) *EntityQuery {
return query
}
// QueryAllowedGroups queries the allowed_groups edge of a StoragePolicy.
func (c *StoragePolicyClient) QueryAllowedGroups(sp *StoragePolicy) *GroupQuery {
query := (&GroupClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := sp.ID
step := sqlgraph.NewStep(
sqlgraph.From(storagepolicy.Table, storagepolicy.FieldID, id),
sqlgraph.To(group.Table, group.FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, storagepolicy.AllowedGroupsTable, storagepolicy.AllowedGroupsPrimaryKey...),
)
fromV = sqlgraph.Neighbors(sp.driver.Dialect(), step)
return fromV, nil
}
return query
}
// QueryNode queries the node edge of a StoragePolicy.
func (c *StoragePolicyClient) QueryNode(sp *StoragePolicy) *NodeQuery {
query := (&NodeClient{config: c.config}).Query()

@ -51,9 +51,11 @@ type GroupEdges struct {
Users []*User `json:"users,omitempty"`
// StoragePolicies holds the value of the storage_policies edge.
StoragePolicies *StoragePolicy `json:"storage_policies,omitempty"`
// AllowedPolicies holds the value of the allowed_policies edge.
AllowedPolicies []*StoragePolicy `json:"allowed_policies,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
loadedTypes [2]bool
loadedTypes [3]bool
}
// UsersOrErr returns the Users value or an error if the edge
@ -78,6 +80,15 @@ func (e GroupEdges) StoragePoliciesOrErr() (*StoragePolicy, error) {
return nil, &NotLoadedError{edge: "storage_policies"}
}
// AllowedPoliciesOrErr returns the AllowedPolicies value or an error if the edge
// was not loaded in eager-loading.
func (e GroupEdges) AllowedPoliciesOrErr() ([]*StoragePolicy, error) {
if e.loadedTypes[2] {
return e.AllowedPolicies, nil
}
return nil, &NotLoadedError{edge: "allowed_policies"}
}
// scanValues returns the types for scanning values from sql.Rows.
func (*Group) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
@ -194,6 +205,11 @@ func (gr *Group) QueryStoragePolicies() *StoragePolicyQuery {
return NewGroupClient(gr.config).QueryStoragePolicies(gr)
}
// QueryAllowedPolicies queries the "allowed_policies" edge of the Group entity.
func (gr *Group) QueryAllowedPolicies() *StoragePolicyQuery {
return NewGroupClient(gr.config).QueryAllowedPolicies(gr)
}
// Update returns a builder for updating this Group.
// Note that you need to call Group.Unwrap() before calling this method if this Group
// was returned from a transaction, and the transaction was committed or rolled back.
@ -261,5 +277,11 @@ func (e *Group) SetStoragePolicies(v *StoragePolicy) {
e.Edges.loadedTypes[1] = true
}
// SetAllowedPolicies manually set the edge as loaded state.
func (e *Group) SetAllowedPolicies(v []*StoragePolicy) {
e.Edges.AllowedPolicies = v
e.Edges.loadedTypes[2] = true
}
// Groups is a parsable slice of Group.
type Groups []*Group

@ -38,6 +38,8 @@ const (
EdgeUsers = "users"
// EdgeStoragePolicies holds the string denoting the storage_policies edge name in mutations.
EdgeStoragePolicies = "storage_policies"
// EdgeAllowedPolicies holds the string denoting the allowed_policies edge name in mutations.
EdgeAllowedPolicies = "allowed_policies"
// Table holds the table name of the group in the database.
Table = "groups"
// UsersTable is the table that holds the users relation/edge.
@ -54,6 +56,11 @@ const (
StoragePoliciesInverseTable = "storage_policies"
// StoragePoliciesColumn is the table column denoting the storage_policies relation/edge.
StoragePoliciesColumn = "storage_policy_id"
// AllowedPoliciesTable is the table that holds the allowed_policies relation/edge. The primary key declared below.
AllowedPoliciesTable = "group_allowed_policies"
// AllowedPoliciesInverseTable is the table name for the StoragePolicy entity.
// It exists in this package in order to avoid circular dependency with the "storagepolicy" package.
AllowedPoliciesInverseTable = "storage_policies"
)
// Columns holds all SQL columns for group fields.
@ -70,6 +77,12 @@ var Columns = []string{
FieldStoragePolicyID,
}
var (
// AllowedPoliciesPrimaryKey and AllowedPoliciesColumn2 are the table columns denoting the
// primary key for the allowed_policies relation (M2M).
AllowedPoliciesPrimaryKey = []string{"group_id", "storage_policy_id"}
)
// ValidColumn reports if the column name is valid (part of the table columns).
func ValidColumn(column string) bool {
for i := range Columns {
@ -161,6 +174,20 @@ func ByStoragePoliciesField(field string, opts ...sql.OrderTermOption) OrderOpti
sqlgraph.OrderByNeighborTerms(s, newStoragePoliciesStep(), sql.OrderByField(field, opts...))
}
}
// ByAllowedPoliciesCount orders the results by allowed_policies count.
func ByAllowedPoliciesCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborsCount(s, newAllowedPoliciesStep(), opts...)
}
}
// ByAllowedPolicies orders the results by allowed_policies terms.
func ByAllowedPolicies(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newAllowedPoliciesStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
func newUsersStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
@ -175,3 +202,10 @@ func newStoragePoliciesStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.M2O, true, StoragePoliciesTable, StoragePoliciesColumn),
)
}
func newAllowedPoliciesStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(AllowedPoliciesInverseTable, FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, AllowedPoliciesTable, AllowedPoliciesPrimaryKey...),
)
}

@ -517,6 +517,29 @@ func HasStoragePoliciesWith(preds ...predicate.StoragePolicy) predicate.Group {
})
}
// HasAllowedPolicies applies the HasEdge predicate on the "allowed_policies" edge.
func HasAllowedPolicies() predicate.Group {
return predicate.Group(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, AllowedPoliciesTable, AllowedPoliciesPrimaryKey...),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasAllowedPoliciesWith applies the HasEdge predicate on the "allowed_policies" edge with a given conditions (other predicates).
func HasAllowedPoliciesWith(preds ...predicate.StoragePolicy) predicate.Group {
return predicate.Group(func(s *sql.Selector) {
step := newAllowedPoliciesStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// And groups predicates with the AND operator between them.
func And(predicates ...predicate.Group) predicate.Group {
return predicate.Group(sql.AndPredicates(predicates...))

@ -162,6 +162,21 @@ func (gc *GroupCreate) SetStoragePolicies(s *StoragePolicy) *GroupCreate {
return gc.SetStoragePoliciesID(s.ID)
}
// AddAllowedPolicyIDs adds the "allowed_policies" edge to the StoragePolicy entity by IDs.
func (gc *GroupCreate) AddAllowedPolicyIDs(ids ...int) *GroupCreate {
gc.mutation.AddAllowedPolicyIDs(ids...)
return gc
}
// AddAllowedPolicies adds the "allowed_policies" edges to the StoragePolicy entity.
func (gc *GroupCreate) AddAllowedPolicies(s ...*StoragePolicy) *GroupCreate {
ids := make([]int, len(s))
for i := range s {
ids[i] = s[i].ID
}
return gc.AddAllowedPolicyIDs(ids...)
}
// Mutation returns the GroupMutation object of the builder.
func (gc *GroupCreate) Mutation() *GroupMutation {
return gc.mutation
@ -333,6 +348,22 @@ func (gc *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_node.StoragePolicyID = nodes[0]
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := gc.mutation.AllowedPoliciesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: group.AllowedPoliciesTable,
Columns: group.AllowedPoliciesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges = append(_spec.Edges, edge)
}
return _node, _spec
}

@ -26,6 +26,7 @@ type GroupQuery struct {
predicates []predicate.Group
withUsers *UserQuery
withStoragePolicies *StoragePolicyQuery
withAllowedPolicies *StoragePolicyQuery
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@ -106,6 +107,28 @@ func (gq *GroupQuery) QueryStoragePolicies() *StoragePolicyQuery {
return query
}
// QueryAllowedPolicies chains the current query on the "allowed_policies" edge.
func (gq *GroupQuery) QueryAllowedPolicies() *StoragePolicyQuery {
query := (&StoragePolicyClient{config: gq.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := gq.prepareQuery(ctx); err != nil {
return nil, err
}
selector := gq.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(group.Table, group.FieldID, selector),
sqlgraph.To(storagepolicy.Table, storagepolicy.FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, group.AllowedPoliciesTable, group.AllowedPoliciesPrimaryKey...),
)
fromU = sqlgraph.SetNeighbors(gq.driver.Dialect(), step)
return fromU, nil
}
return query
}
// First returns the first Group entity from the query.
// Returns a *NotFoundError when no Group was found.
func (gq *GroupQuery) First(ctx context.Context) (*Group, error) {
@ -300,6 +323,7 @@ func (gq *GroupQuery) Clone() *GroupQuery {
predicates: append([]predicate.Group{}, gq.predicates...),
withUsers: gq.withUsers.Clone(),
withStoragePolicies: gq.withStoragePolicies.Clone(),
withAllowedPolicies: gq.withAllowedPolicies.Clone(),
// clone intermediate query.
sql: gq.sql.Clone(),
path: gq.path,
@ -328,6 +352,17 @@ func (gq *GroupQuery) WithStoragePolicies(opts ...func(*StoragePolicyQuery)) *Gr
return gq
}
// WithAllowedPolicies tells the query-builder to eager-load the nodes that are connected to
// the "allowed_policies" edge. The optional arguments are used to configure the query builder of the edge.
func (gq *GroupQuery) WithAllowedPolicies(opts ...func(*StoragePolicyQuery)) *GroupQuery {
query := (&StoragePolicyClient{config: gq.config}).Query()
for _, opt := range opts {
opt(query)
}
gq.withAllowedPolicies = query
return gq
}
// GroupBy is used to group vertices by one or more fields/columns.
// It is often used with aggregate functions, like: count, max, mean, min, sum.
//
@ -406,9 +441,10 @@ func (gq *GroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Group,
var (
nodes = []*Group{}
_spec = gq.querySpec()
loadedTypes = [2]bool{
loadedTypes = [3]bool{
gq.withUsers != nil,
gq.withStoragePolicies != nil,
gq.withAllowedPolicies != nil,
}
)
_spec.ScanValues = func(columns []string) ([]any, error) {
@ -442,6 +478,13 @@ func (gq *GroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Group,
return nil, err
}
}
if query := gq.withAllowedPolicies; query != nil {
if err := gq.loadAllowedPolicies(ctx, query, nodes,
func(n *Group) { n.Edges.AllowedPolicies = []*StoragePolicy{} },
func(n *Group, e *StoragePolicy) { n.Edges.AllowedPolicies = append(n.Edges.AllowedPolicies, e) }); err != nil {
return nil, err
}
}
return nodes, nil
}
@ -504,6 +547,67 @@ func (gq *GroupQuery) loadStoragePolicies(ctx context.Context, query *StoragePol
}
return nil
}
func (gq *GroupQuery) loadAllowedPolicies(ctx context.Context, query *StoragePolicyQuery, nodes []*Group, init func(*Group), assign func(*Group, *StoragePolicy)) error {
edgeIDs := make([]driver.Value, len(nodes))
byID := make(map[int]*Group)
nids := make(map[int]map[*Group]struct{})
for i, node := range nodes {
edgeIDs[i] = node.ID
byID[node.ID] = node
if init != nil {
init(node)
}
}
query.Where(func(s *sql.Selector) {
joinT := sql.Table(group.AllowedPoliciesTable)
s.Join(joinT).On(s.C(storagepolicy.FieldID), joinT.C(group.AllowedPoliciesPrimaryKey[1]))
s.Where(sql.InValues(joinT.C(group.AllowedPoliciesPrimaryKey[0]), edgeIDs...))
columns := s.SelectedColumns()
s.Select(joinT.C(group.AllowedPoliciesPrimaryKey[0]))
s.AppendSelect(columns...)
s.SetDistinct(false)
})
if err := query.prepareQuery(ctx); err != nil {
return err
}
qr := QuerierFunc(func(ctx context.Context, q Query) (Value, error) {
return query.sqlAll(ctx, func(_ context.Context, spec *sqlgraph.QuerySpec) {
assign := spec.Assign
values := spec.ScanValues
spec.ScanValues = func(columns []string) ([]any, error) {
values, err := values(columns[1:])
if err != nil {
return nil, err
}
return append([]any{new(sql.NullInt64)}, values...), nil
}
spec.Assign = func(columns []string, values []any) error {
outValue := int(values[0].(*sql.NullInt64).Int64)
inValue := int(values[1].(*sql.NullInt64).Int64)
if nids[inValue] == nil {
nids[inValue] = map[*Group]struct{}{byID[outValue]: {}}
return assign(columns[1:], values[1:])
}
nids[inValue][byID[outValue]] = struct{}{}
return nil
}
})
})
neighbors, err := withInterceptors[[]*StoragePolicy](ctx, query, qr, query.inters)
if err != nil {
return err
}
for _, n := range neighbors {
nodes, ok := nids[n.ID]
if !ok {
return fmt.Errorf(`unexpected "allowed_policies" node returned %v`, n.ID)
}
for kn := range nodes {
assign(kn, n)
}
}
return nil
}
func (gq *GroupQuery) sqlCount(ctx context.Context) (int, error) {
_spec := gq.querySpec()

@ -198,6 +198,21 @@ func (gu *GroupUpdate) SetStoragePolicies(s *StoragePolicy) *GroupUpdate {
return gu.SetStoragePoliciesID(s.ID)
}
// AddAllowedPolicyIDs adds the "allowed_policies" edge to the StoragePolicy entity by IDs.
func (gu *GroupUpdate) AddAllowedPolicyIDs(ids ...int) *GroupUpdate {
gu.mutation.AddAllowedPolicyIDs(ids...)
return gu
}
// AddAllowedPolicies adds the "allowed_policies" edges to the StoragePolicy entity.
func (gu *GroupUpdate) AddAllowedPolicies(s ...*StoragePolicy) *GroupUpdate {
ids := make([]int, len(s))
for i := range s {
ids[i] = s[i].ID
}
return gu.AddAllowedPolicyIDs(ids...)
}
// Mutation returns the GroupMutation object of the builder.
func (gu *GroupUpdate) Mutation() *GroupMutation {
return gu.mutation
@ -230,6 +245,27 @@ func (gu *GroupUpdate) ClearStoragePolicies() *GroupUpdate {
return gu
}
// ClearAllowedPolicies clears all "allowed_policies" edges to the StoragePolicy entity.
func (gu *GroupUpdate) ClearAllowedPolicies() *GroupUpdate {
gu.mutation.ClearAllowedPolicies()
return gu
}
// RemoveAllowedPolicyIDs removes the "allowed_policies" edge to StoragePolicy entities by IDs.
func (gu *GroupUpdate) RemoveAllowedPolicyIDs(ids ...int) *GroupUpdate {
gu.mutation.RemoveAllowedPolicyIDs(ids...)
return gu
}
// RemoveAllowedPolicies removes "allowed_policies" edges to StoragePolicy entities.
func (gu *GroupUpdate) RemoveAllowedPolicies(s ...*StoragePolicy) *GroupUpdate {
ids := make([]int, len(s))
for i := range s {
ids[i] = s[i].ID
}
return gu.RemoveAllowedPolicyIDs(ids...)
}
// Save executes the query and returns the number of nodes affected by the update operation.
func (gu *GroupUpdate) Save(ctx context.Context) (int, error) {
if err := gu.defaults(); err != nil {
@ -394,6 +430,51 @@ func (gu *GroupUpdate) sqlSave(ctx context.Context) (n int, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if gu.mutation.AllowedPoliciesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: group.AllowedPoliciesTable,
Columns: group.AllowedPoliciesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := gu.mutation.RemovedAllowedPoliciesIDs(); len(nodes) > 0 && !gu.mutation.AllowedPoliciesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: group.AllowedPoliciesTable,
Columns: group.AllowedPoliciesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := gu.mutation.AllowedPoliciesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: group.AllowedPoliciesTable,
Columns: group.AllowedPoliciesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if n, err = sqlgraph.UpdateNodes(ctx, gu.driver, _spec); err != nil {
if _, ok := err.(*sqlgraph.NotFoundError); ok {
err = &NotFoundError{group.Label}
@ -580,6 +661,21 @@ func (guo *GroupUpdateOne) SetStoragePolicies(s *StoragePolicy) *GroupUpdateOne
return guo.SetStoragePoliciesID(s.ID)
}
// AddAllowedPolicyIDs adds the "allowed_policies" edge to the StoragePolicy entity by IDs.
func (guo *GroupUpdateOne) AddAllowedPolicyIDs(ids ...int) *GroupUpdateOne {
guo.mutation.AddAllowedPolicyIDs(ids...)
return guo
}
// AddAllowedPolicies adds the "allowed_policies" edges to the StoragePolicy entity.
func (guo *GroupUpdateOne) AddAllowedPolicies(s ...*StoragePolicy) *GroupUpdateOne {
ids := make([]int, len(s))
for i := range s {
ids[i] = s[i].ID
}
return guo.AddAllowedPolicyIDs(ids...)
}
// Mutation returns the GroupMutation object of the builder.
func (guo *GroupUpdateOne) Mutation() *GroupMutation {
return guo.mutation
@ -612,6 +708,27 @@ func (guo *GroupUpdateOne) ClearStoragePolicies() *GroupUpdateOne {
return guo
}
// ClearAllowedPolicies clears all "allowed_policies" edges to the StoragePolicy entity.
func (guo *GroupUpdateOne) ClearAllowedPolicies() *GroupUpdateOne {
guo.mutation.ClearAllowedPolicies()
return guo
}
// RemoveAllowedPolicyIDs removes the "allowed_policies" edge to StoragePolicy entities by IDs.
func (guo *GroupUpdateOne) RemoveAllowedPolicyIDs(ids ...int) *GroupUpdateOne {
guo.mutation.RemoveAllowedPolicyIDs(ids...)
return guo
}
// RemoveAllowedPolicies removes "allowed_policies" edges to StoragePolicy entities.
func (guo *GroupUpdateOne) RemoveAllowedPolicies(s ...*StoragePolicy) *GroupUpdateOne {
ids := make([]int, len(s))
for i := range s {
ids[i] = s[i].ID
}
return guo.RemoveAllowedPolicyIDs(ids...)
}
// Where appends a list predicates to the GroupUpdate builder.
func (guo *GroupUpdateOne) Where(ps ...predicate.Group) *GroupUpdateOne {
guo.mutation.Where(ps...)
@ -806,6 +923,51 @@ func (guo *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if guo.mutation.AllowedPoliciesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: group.AllowedPoliciesTable,
Columns: group.AllowedPoliciesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := guo.mutation.RemovedAllowedPoliciesIDs(); len(nodes) > 0 && !guo.mutation.AllowedPoliciesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: group.AllowedPoliciesTable,
Columns: group.AllowedPoliciesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := guo.mutation.AllowedPoliciesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: group.AllowedPoliciesTable,
Columns: group.AllowedPoliciesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
_node = &Group{config: guo.config}
_spec.Assign = _node.assignValues
_spec.ScanValues = _node.scanValues

File diff suppressed because one or more lines are too long

@ -582,6 +582,31 @@ var (
},
},
}
// GroupAllowedPoliciesColumns holds the columns for the "group_allowed_policies" table.
GroupAllowedPoliciesColumns = []*schema.Column{
{Name: "group_id", Type: field.TypeInt},
{Name: "storage_policy_id", Type: field.TypeInt},
}
// GroupAllowedPoliciesTable holds the schema information for the "group_allowed_policies" table.
GroupAllowedPoliciesTable = &schema.Table{
Name: "group_allowed_policies",
Columns: GroupAllowedPoliciesColumns,
PrimaryKey: []*schema.Column{GroupAllowedPoliciesColumns[0], GroupAllowedPoliciesColumns[1]},
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "group_allowed_policies_group_id",
Columns: []*schema.Column{GroupAllowedPoliciesColumns[0]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.Cascade,
},
{
Symbol: "group_allowed_policies_storage_policy_id",
Columns: []*schema.Column{GroupAllowedPoliciesColumns[1]},
RefColumns: []*schema.Column{StoragePoliciesColumns[0]},
OnDelete: schema.Cascade,
},
},
}
// Tables holds all the tables in the schema.
Tables = []*schema.Table{
ACLEntriesTable,
@ -603,6 +628,7 @@ var (
TasksTable,
UsersTable,
FileEntitiesTable,
GroupAllowedPoliciesTable,
}
)
@ -628,4 +654,6 @@ func init() {
UsersTable.ForeignKeys[0].RefTable = GroupsTable
FileEntitiesTable.ForeignKeys[0].RefTable = FilesTable
FileEntitiesTable.ForeignKeys[1].RefTable = EntitiesTable
GroupAllowedPoliciesTable.ForeignKeys[0].RefTable = GroupsTable
GroupAllowedPoliciesTable.ForeignKeys[1].RefTable = StoragePoliciesTable
}

@ -6205,6 +6205,9 @@ type GroupMutation struct {
clearedusers bool
storage_policies *int
clearedstorage_policies bool
allowed_policies map[int]struct{}
removedallowed_policies map[int]struct{}
clearedallowed_policies bool
done bool
oldValue func(context.Context) (*Group, error)
predicates []predicate.Group
@ -6833,6 +6836,60 @@ func (m *GroupMutation) ResetStoragePolicies() {
m.clearedstorage_policies = false
}
// AddAllowedPolicyIDs adds the "allowed_policies" edge to the StoragePolicy entity by ids.
func (m *GroupMutation) AddAllowedPolicyIDs(ids ...int) {
if m.allowed_policies == nil {
m.allowed_policies = make(map[int]struct{})
}
for i := range ids {
m.allowed_policies[ids[i]] = struct{}{}
}
}
// ClearAllowedPolicies clears the "allowed_policies" edge to the StoragePolicy entity.
func (m *GroupMutation) ClearAllowedPolicies() {
m.clearedallowed_policies = true
}
// AllowedPoliciesCleared reports if the "allowed_policies" edge to the StoragePolicy entity was cleared.
func (m *GroupMutation) AllowedPoliciesCleared() bool {
return m.clearedallowed_policies
}
// RemoveAllowedPolicyIDs removes the "allowed_policies" edge to the StoragePolicy entity by IDs.
func (m *GroupMutation) RemoveAllowedPolicyIDs(ids ...int) {
if m.removedallowed_policies == nil {
m.removedallowed_policies = make(map[int]struct{})
}
for i := range ids {
delete(m.allowed_policies, ids[i])
m.removedallowed_policies[ids[i]] = struct{}{}
}
}
// RemovedAllowedPolicies returns the removed IDs of the "allowed_policies" edge to the StoragePolicy entity.
func (m *GroupMutation) RemovedAllowedPoliciesIDs() (ids []int) {
for id := range m.removedallowed_policies {
ids = append(ids, id)
}
return
}
// AllowedPoliciesIDs returns the "allowed_policies" edge IDs in the mutation.
func (m *GroupMutation) AllowedPoliciesIDs() (ids []int) {
for id := range m.allowed_policies {
ids = append(ids, id)
}
return
}
// ResetAllowedPolicies resets all changes to the "allowed_policies" edge.
func (m *GroupMutation) ResetAllowedPolicies() {
m.allowed_policies = nil
m.clearedallowed_policies = false
m.removedallowed_policies = nil
}
// Where appends a list predicates to the GroupMutation builder.
func (m *GroupMutation) Where(ps ...predicate.Group) {
m.predicates = append(m.predicates, ps...)
@ -7162,13 +7219,16 @@ func (m *GroupMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *GroupMutation) AddedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.users != nil {
edges = append(edges, group.EdgeUsers)
}
if m.storage_policies != nil {
edges = append(edges, group.EdgeStoragePolicies)
}
if m.allowed_policies != nil {
edges = append(edges, group.EdgeAllowedPolicies)
}
return edges
}
@ -7186,16 +7246,25 @@ func (m *GroupMutation) AddedIDs(name string) []ent.Value {
if id := m.storage_policies; id != nil {
return []ent.Value{*id}
}
case group.EdgeAllowedPolicies:
ids := make([]ent.Value, 0, len(m.allowed_policies))
for id := range m.allowed_policies {
ids = append(ids, id)
}
return ids
}
return nil
}
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *GroupMutation) RemovedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.removedusers != nil {
edges = append(edges, group.EdgeUsers)
}
if m.removedallowed_policies != nil {
edges = append(edges, group.EdgeAllowedPolicies)
}
return edges
}
@ -7209,19 +7278,28 @@ func (m *GroupMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case group.EdgeAllowedPolicies:
ids := make([]ent.Value, 0, len(m.removedallowed_policies))
for id := range m.removedallowed_policies {
ids = append(ids, id)
}
return ids
}
return nil
}
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *GroupMutation) ClearedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.clearedusers {
edges = append(edges, group.EdgeUsers)
}
if m.clearedstorage_policies {
edges = append(edges, group.EdgeStoragePolicies)
}
if m.clearedallowed_policies {
edges = append(edges, group.EdgeAllowedPolicies)
}
return edges
}
@ -7233,6 +7311,8 @@ func (m *GroupMutation) EdgeCleared(name string) bool {
return m.clearedusers
case group.EdgeStoragePolicies:
return m.clearedstorage_policies
case group.EdgeAllowedPolicies:
return m.clearedallowed_policies
}
return false
}
@ -7258,6 +7338,9 @@ func (m *GroupMutation) ResetEdge(name string) error {
case group.EdgeStoragePolicies:
m.ResetStoragePolicies()
return nil
case group.EdgeAllowedPolicies:
m.ResetAllowedPolicies()
return nil
}
return fmt.Errorf("unknown Group edge %s", name)
}
@ -14227,40 +14310,43 @@ func (m *ShareMutation) ResetEdge(name string) error {
// StoragePolicyMutation represents an operation that mutates the StoragePolicy nodes in the graph.
type StoragePolicyMutation struct {
config
op Op
typ string
id *int
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
name *string
_type *string
status *storagepolicy.Status
server *string
bucket_name *string
is_private *bool
access_key *string
secret_key *string
max_size *int64
addmax_size *int64
dir_name_rule *string
file_name_rule *string
settings **types.PolicySetting
clearedFields map[string]struct{}
groups map[int]struct{}
removedgroups map[int]struct{}
clearedgroups bool
files map[int]struct{}
removedfiles map[int]struct{}
clearedfiles bool
entities map[int]struct{}
removedentities map[int]struct{}
clearedentities bool
node *int
clearednode bool
done bool
oldValue func(context.Context) (*StoragePolicy, error)
predicates []predicate.StoragePolicy
op Op
typ string
id *int
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
name *string
_type *string
status *storagepolicy.Status
server *string
bucket_name *string
is_private *bool
access_key *string
secret_key *string
max_size *int64
addmax_size *int64
dir_name_rule *string
file_name_rule *string
settings **types.PolicySetting
clearedFields map[string]struct{}
groups map[int]struct{}
removedgroups map[int]struct{}
clearedgroups bool
files map[int]struct{}
removedfiles map[int]struct{}
clearedfiles bool
entities map[int]struct{}
removedentities map[int]struct{}
clearedentities bool
allowed_groups map[int]struct{}
removedallowed_groups map[int]struct{}
clearedallowed_groups bool
node *int
clearednode bool
done bool
oldValue func(context.Context) (*StoragePolicy, error)
predicates []predicate.StoragePolicy
}
var _ ent.Mutation = (*StoragePolicyMutation)(nil)
@ -15263,6 +15349,60 @@ func (m *StoragePolicyMutation) ResetEntities() {
m.removedentities = nil
}
// AddAllowedGroupIDs adds the "allowed_groups" edge to the Group entity by ids.
func (m *StoragePolicyMutation) AddAllowedGroupIDs(ids ...int) {
if m.allowed_groups == nil {
m.allowed_groups = make(map[int]struct{})
}
for i := range ids {
m.allowed_groups[ids[i]] = struct{}{}
}
}
// ClearAllowedGroups clears the "allowed_groups" edge to the Group entity.
func (m *StoragePolicyMutation) ClearAllowedGroups() {
m.clearedallowed_groups = true
}
// AllowedGroupsCleared reports if the "allowed_groups" edge to the Group entity was cleared.
func (m *StoragePolicyMutation) AllowedGroupsCleared() bool {
return m.clearedallowed_groups
}
// RemoveAllowedGroupIDs removes the "allowed_groups" edge to the Group entity by IDs.
func (m *StoragePolicyMutation) RemoveAllowedGroupIDs(ids ...int) {
if m.removedallowed_groups == nil {
m.removedallowed_groups = make(map[int]struct{})
}
for i := range ids {
delete(m.allowed_groups, ids[i])
m.removedallowed_groups[ids[i]] = struct{}{}
}
}
// RemovedAllowedGroups returns the removed IDs of the "allowed_groups" edge to the Group entity.
func (m *StoragePolicyMutation) RemovedAllowedGroupsIDs() (ids []int) {
for id := range m.removedallowed_groups {
ids = append(ids, id)
}
return
}
// AllowedGroupsIDs returns the "allowed_groups" edge IDs in the mutation.
func (m *StoragePolicyMutation) AllowedGroupsIDs() (ids []int) {
for id := range m.allowed_groups {
ids = append(ids, id)
}
return
}
// ResetAllowedGroups resets all changes to the "allowed_groups" edge.
func (m *StoragePolicyMutation) ResetAllowedGroups() {
m.allowed_groups = nil
m.clearedallowed_groups = false
m.removedallowed_groups = nil
}
// ClearNode clears the "node" edge to the Node entity.
func (m *StoragePolicyMutation) ClearNode() {
m.clearednode = true
@ -15762,7 +15902,7 @@ func (m *StoragePolicyMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *StoragePolicyMutation) AddedEdges() []string {
edges := make([]string, 0, 4)
edges := make([]string, 0, 5)
if m.groups != nil {
edges = append(edges, storagepolicy.EdgeGroups)
}
@ -15772,6 +15912,9 @@ func (m *StoragePolicyMutation) AddedEdges() []string {
if m.entities != nil {
edges = append(edges, storagepolicy.EdgeEntities)
}
if m.allowed_groups != nil {
edges = append(edges, storagepolicy.EdgeAllowedGroups)
}
if m.node != nil {
edges = append(edges, storagepolicy.EdgeNode)
}
@ -15800,6 +15943,12 @@ func (m *StoragePolicyMutation) AddedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case storagepolicy.EdgeAllowedGroups:
ids := make([]ent.Value, 0, len(m.allowed_groups))
for id := range m.allowed_groups {
ids = append(ids, id)
}
return ids
case storagepolicy.EdgeNode:
if id := m.node; id != nil {
return []ent.Value{*id}
@ -15810,7 +15959,7 @@ func (m *StoragePolicyMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *StoragePolicyMutation) RemovedEdges() []string {
edges := make([]string, 0, 4)
edges := make([]string, 0, 5)
if m.removedgroups != nil {
edges = append(edges, storagepolicy.EdgeGroups)
}
@ -15820,6 +15969,9 @@ func (m *StoragePolicyMutation) RemovedEdges() []string {
if m.removedentities != nil {
edges = append(edges, storagepolicy.EdgeEntities)
}
if m.removedallowed_groups != nil {
edges = append(edges, storagepolicy.EdgeAllowedGroups)
}
return edges
}
@ -15845,13 +15997,19 @@ func (m *StoragePolicyMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case storagepolicy.EdgeAllowedGroups:
ids := make([]ent.Value, 0, len(m.removedallowed_groups))
for id := range m.removedallowed_groups {
ids = append(ids, id)
}
return ids
}
return nil
}
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *StoragePolicyMutation) ClearedEdges() []string {
edges := make([]string, 0, 4)
edges := make([]string, 0, 5)
if m.clearedgroups {
edges = append(edges, storagepolicy.EdgeGroups)
}
@ -15861,6 +16019,9 @@ func (m *StoragePolicyMutation) ClearedEdges() []string {
if m.clearedentities {
edges = append(edges, storagepolicy.EdgeEntities)
}
if m.clearedallowed_groups {
edges = append(edges, storagepolicy.EdgeAllowedGroups)
}
if m.clearednode {
edges = append(edges, storagepolicy.EdgeNode)
}
@ -15877,6 +16038,8 @@ func (m *StoragePolicyMutation) EdgeCleared(name string) bool {
return m.clearedfiles
case storagepolicy.EdgeEntities:
return m.clearedentities
case storagepolicy.EdgeAllowedGroups:
return m.clearedallowed_groups
case storagepolicy.EdgeNode:
return m.clearednode
}
@ -15907,6 +16070,9 @@ func (m *StoragePolicyMutation) ResetEdge(name string) error {
case storagepolicy.EdgeEntities:
m.ResetEntities()
return nil
case storagepolicy.EdgeAllowedGroups:
m.ResetAllowedGroups()
return nil
case storagepolicy.EdgeNode:
m.ResetNode()
return nil

@ -41,5 +41,8 @@ func (Group) Edges() []ent.Edge {
Ref("groups").
Field("storage_policy_id").
Unique(),
// allowed_policies is the pool of storage policies members may pick
// from. Empty means the single storage_policies default applies.
edge.To("allowed_policies", StoragePolicy.Type),
}
}

@ -54,6 +54,8 @@ func (StoragePolicy) Edges() []ent.Edge {
edge.To("groups", Group.Type),
edge.To("files", File.Type),
edge.To("entities", Entity.Type),
edge.From("allowed_groups", Group.Type).
Ref("allowed_policies"),
edge.From("node", Node.Type).
Ref("storage_policy").
Field("node_id").

@ -66,11 +66,13 @@ type StoragePolicyEdges struct {
Files []*File `json:"files,omitempty"`
// Entities holds the value of the entities edge.
Entities []*Entity `json:"entities,omitempty"`
// AllowedGroups holds the value of the allowed_groups edge.
AllowedGroups []*Group `json:"allowed_groups,omitempty"`
// Node holds the value of the node edge.
Node *Node `json:"node,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
loadedTypes [4]bool
loadedTypes [5]bool
}
// GroupsOrErr returns the Groups value or an error if the edge
@ -100,10 +102,19 @@ func (e StoragePolicyEdges) EntitiesOrErr() ([]*Entity, error) {
return nil, &NotLoadedError{edge: "entities"}
}
// AllowedGroupsOrErr returns the AllowedGroups value or an error if the edge
// was not loaded in eager-loading.
func (e StoragePolicyEdges) AllowedGroupsOrErr() ([]*Group, error) {
if e.loadedTypes[3] {
return e.AllowedGroups, nil
}
return nil, &NotLoadedError{edge: "allowed_groups"}
}
// NodeOrErr returns the Node value or an error if the edge
// was not loaded in eager-loading, or loaded but was not found.
func (e StoragePolicyEdges) NodeOrErr() (*Node, error) {
if e.loadedTypes[3] {
if e.loadedTypes[4] {
if e.Node == nil {
// Edge was loaded but was not found.
return nil, &NotFoundError{label: node.Label}
@ -276,6 +287,11 @@ func (sp *StoragePolicy) QueryEntities() *EntityQuery {
return NewStoragePolicyClient(sp.config).QueryEntities(sp)
}
// QueryAllowedGroups queries the "allowed_groups" edge of the StoragePolicy entity.
func (sp *StoragePolicy) QueryAllowedGroups() *GroupQuery {
return NewStoragePolicyClient(sp.config).QueryAllowedGroups(sp)
}
// QueryNode queries the "node" edge of the StoragePolicy entity.
func (sp *StoragePolicy) QueryNode() *NodeQuery {
return NewStoragePolicyClient(sp.config).QueryNode(sp)
@ -375,10 +391,16 @@ func (e *StoragePolicy) SetEntities(v []*Entity) {
e.Edges.loadedTypes[2] = true
}
// SetAllowedGroups manually set the edge as loaded state.
func (e *StoragePolicy) SetAllowedGroups(v []*Group) {
e.Edges.AllowedGroups = v
e.Edges.loadedTypes[3] = true
}
// SetNode manually set the edge as loaded state.
func (e *StoragePolicy) SetNode(v *Node) {
e.Edges.Node = v
e.Edges.loadedTypes[3] = true
e.Edges.loadedTypes[4] = true
}
// StoragePolicies is a parsable slice of StoragePolicy.

@ -55,6 +55,8 @@ const (
EdgeFiles = "files"
// EdgeEntities holds the string denoting the entities edge name in mutations.
EdgeEntities = "entities"
// EdgeAllowedGroups holds the string denoting the allowed_groups edge name in mutations.
EdgeAllowedGroups = "allowed_groups"
// EdgeNode holds the string denoting the node edge name in mutations.
EdgeNode = "node"
// Table holds the table name of the storagepolicy in the database.
@ -80,6 +82,11 @@ const (
EntitiesInverseTable = "entities"
// EntitiesColumn is the table column denoting the entities relation/edge.
EntitiesColumn = "storage_policy_entities"
// AllowedGroupsTable is the table that holds the allowed_groups relation/edge. The primary key declared below.
AllowedGroupsTable = "group_allowed_policies"
// AllowedGroupsInverseTable is the table name for the Group entity.
// It exists in this package in order to avoid circular dependency with the "group" package.
AllowedGroupsInverseTable = "groups"
// NodeTable is the table that holds the node relation/edge.
NodeTable = "storage_policies"
// NodeInverseTable is the table name for the Node entity.
@ -110,6 +117,12 @@ var Columns = []string{
FieldNodeID,
}
var (
// AllowedGroupsPrimaryKey and AllowedGroupsColumn2 are the table columns denoting the
// primary key for the allowed_groups relation (M2M).
AllowedGroupsPrimaryKey = []string{"group_id", "storage_policy_id"}
)
// ValidColumn reports if the column name is valid (part of the table columns).
func ValidColumn(column string) bool {
for i := range Columns {
@ -289,6 +302,20 @@ func ByEntities(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
}
}
// ByAllowedGroupsCount orders the results by allowed_groups count.
func ByAllowedGroupsCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborsCount(s, newAllowedGroupsStep(), opts...)
}
}
// ByAllowedGroups orders the results by allowed_groups terms.
func ByAllowedGroups(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newAllowedGroupsStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
// ByNodeField orders the results by node field.
func ByNodeField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@ -316,6 +343,13 @@ func newEntitiesStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.O2M, false, EntitiesTable, EntitiesColumn),
)
}
func newAllowedGroupsStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(AllowedGroupsInverseTable, FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, AllowedGroupsTable, AllowedGroupsPrimaryKey...),
)
}
func newNodeStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),

@ -1034,6 +1034,29 @@ func HasEntitiesWith(preds ...predicate.Entity) predicate.StoragePolicy {
})
}
// HasAllowedGroups applies the HasEdge predicate on the "allowed_groups" edge.
func HasAllowedGroups() predicate.StoragePolicy {
return predicate.StoragePolicy(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, AllowedGroupsTable, AllowedGroupsPrimaryKey...),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasAllowedGroupsWith applies the HasEdge predicate on the "allowed_groups" edge with a given conditions (other predicates).
func HasAllowedGroupsWith(preds ...predicate.Group) predicate.StoragePolicy {
return predicate.StoragePolicy(func(s *sql.Selector) {
step := newAllowedGroupsStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// HasNode applies the HasEdge predicate on the "node" edge.
func HasNode() predicate.StoragePolicy {
return predicate.StoragePolicy(func(s *sql.Selector) {

@ -272,6 +272,21 @@ func (spc *StoragePolicyCreate) AddEntities(e ...*Entity) *StoragePolicyCreate {
return spc.AddEntityIDs(ids...)
}
// AddAllowedGroupIDs adds the "allowed_groups" edge to the Group entity by IDs.
func (spc *StoragePolicyCreate) AddAllowedGroupIDs(ids ...int) *StoragePolicyCreate {
spc.mutation.AddAllowedGroupIDs(ids...)
return spc
}
// AddAllowedGroups adds the "allowed_groups" edges to the Group entity.
func (spc *StoragePolicyCreate) AddAllowedGroups(g ...*Group) *StoragePolicyCreate {
ids := make([]int, len(g))
for i := range g {
ids[i] = g[i].ID
}
return spc.AddAllowedGroupIDs(ids...)
}
// SetNode sets the "node" edge to the Node entity.
func (spc *StoragePolicyCreate) SetNode(n *Node) *StoragePolicyCreate {
return spc.SetNodeID(n.ID)
@ -503,6 +518,22 @@ func (spc *StoragePolicyCreate) createSpec() (*StoragePolicy, *sqlgraph.CreateSp
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := spc.mutation.AllowedGroupsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: storagepolicy.AllowedGroupsTable,
Columns: storagepolicy.AllowedGroupsPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := spc.mutation.NodeIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,

@ -22,14 +22,15 @@ import (
// StoragePolicyQuery is the builder for querying StoragePolicy entities.
type StoragePolicyQuery struct {
config
ctx *QueryContext
order []storagepolicy.OrderOption
inters []Interceptor
predicates []predicate.StoragePolicy
withGroups *GroupQuery
withFiles *FileQuery
withEntities *EntityQuery
withNode *NodeQuery
ctx *QueryContext
order []storagepolicy.OrderOption
inters []Interceptor
predicates []predicate.StoragePolicy
withGroups *GroupQuery
withFiles *FileQuery
withEntities *EntityQuery
withAllowedGroups *GroupQuery
withNode *NodeQuery
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@ -132,6 +133,28 @@ func (spq *StoragePolicyQuery) QueryEntities() *EntityQuery {
return query
}
// QueryAllowedGroups chains the current query on the "allowed_groups" edge.
func (spq *StoragePolicyQuery) QueryAllowedGroups() *GroupQuery {
query := (&GroupClient{config: spq.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := spq.prepareQuery(ctx); err != nil {
return nil, err
}
selector := spq.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(storagepolicy.Table, storagepolicy.FieldID, selector),
sqlgraph.To(group.Table, group.FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, storagepolicy.AllowedGroupsTable, storagepolicy.AllowedGroupsPrimaryKey...),
)
fromU = sqlgraph.SetNeighbors(spq.driver.Dialect(), step)
return fromU, nil
}
return query
}
// QueryNode chains the current query on the "node" edge.
func (spq *StoragePolicyQuery) QueryNode() *NodeQuery {
query := (&NodeClient{config: spq.config}).Query()
@ -341,15 +364,16 @@ func (spq *StoragePolicyQuery) Clone() *StoragePolicyQuery {
return nil
}
return &StoragePolicyQuery{
config: spq.config,
ctx: spq.ctx.Clone(),
order: append([]storagepolicy.OrderOption{}, spq.order...),
inters: append([]Interceptor{}, spq.inters...),
predicates: append([]predicate.StoragePolicy{}, spq.predicates...),
withGroups: spq.withGroups.Clone(),
withFiles: spq.withFiles.Clone(),
withEntities: spq.withEntities.Clone(),
withNode: spq.withNode.Clone(),
config: spq.config,
ctx: spq.ctx.Clone(),
order: append([]storagepolicy.OrderOption{}, spq.order...),
inters: append([]Interceptor{}, spq.inters...),
predicates: append([]predicate.StoragePolicy{}, spq.predicates...),
withGroups: spq.withGroups.Clone(),
withFiles: spq.withFiles.Clone(),
withEntities: spq.withEntities.Clone(),
withAllowedGroups: spq.withAllowedGroups.Clone(),
withNode: spq.withNode.Clone(),
// clone intermediate query.
sql: spq.sql.Clone(),
path: spq.path,
@ -389,6 +413,17 @@ func (spq *StoragePolicyQuery) WithEntities(opts ...func(*EntityQuery)) *Storage
return spq
}
// WithAllowedGroups tells the query-builder to eager-load the nodes that are connected to
// the "allowed_groups" edge. The optional arguments are used to configure the query builder of the edge.
func (spq *StoragePolicyQuery) WithAllowedGroups(opts ...func(*GroupQuery)) *StoragePolicyQuery {
query := (&GroupClient{config: spq.config}).Query()
for _, opt := range opts {
opt(query)
}
spq.withAllowedGroups = query
return spq
}
// WithNode tells the query-builder to eager-load the nodes that are connected to
// the "node" edge. The optional arguments are used to configure the query builder of the edge.
func (spq *StoragePolicyQuery) WithNode(opts ...func(*NodeQuery)) *StoragePolicyQuery {
@ -478,10 +513,11 @@ func (spq *StoragePolicyQuery) sqlAll(ctx context.Context, hooks ...queryHook) (
var (
nodes = []*StoragePolicy{}
_spec = spq.querySpec()
loadedTypes = [4]bool{
loadedTypes = [5]bool{
spq.withGroups != nil,
spq.withFiles != nil,
spq.withEntities != nil,
spq.withAllowedGroups != nil,
spq.withNode != nil,
}
)
@ -524,6 +560,13 @@ func (spq *StoragePolicyQuery) sqlAll(ctx context.Context, hooks ...queryHook) (
return nil, err
}
}
if query := spq.withAllowedGroups; query != nil {
if err := spq.loadAllowedGroups(ctx, query, nodes,
func(n *StoragePolicy) { n.Edges.AllowedGroups = []*Group{} },
func(n *StoragePolicy, e *Group) { n.Edges.AllowedGroups = append(n.Edges.AllowedGroups, e) }); err != nil {
return nil, err
}
}
if query := spq.withNode; query != nil {
if err := spq.loadNode(ctx, query, nodes, nil,
func(n *StoragePolicy, e *Node) { n.Edges.Node = e }); err != nil {
@ -623,6 +666,67 @@ func (spq *StoragePolicyQuery) loadEntities(ctx context.Context, query *EntityQu
}
return nil
}
func (spq *StoragePolicyQuery) loadAllowedGroups(ctx context.Context, query *GroupQuery, nodes []*StoragePolicy, init func(*StoragePolicy), assign func(*StoragePolicy, *Group)) error {
edgeIDs := make([]driver.Value, len(nodes))
byID := make(map[int]*StoragePolicy)
nids := make(map[int]map[*StoragePolicy]struct{})
for i, node := range nodes {
edgeIDs[i] = node.ID
byID[node.ID] = node
if init != nil {
init(node)
}
}
query.Where(func(s *sql.Selector) {
joinT := sql.Table(storagepolicy.AllowedGroupsTable)
s.Join(joinT).On(s.C(group.FieldID), joinT.C(storagepolicy.AllowedGroupsPrimaryKey[0]))
s.Where(sql.InValues(joinT.C(storagepolicy.AllowedGroupsPrimaryKey[1]), edgeIDs...))
columns := s.SelectedColumns()
s.Select(joinT.C(storagepolicy.AllowedGroupsPrimaryKey[1]))
s.AppendSelect(columns...)
s.SetDistinct(false)
})
if err := query.prepareQuery(ctx); err != nil {
return err
}
qr := QuerierFunc(func(ctx context.Context, q Query) (Value, error) {
return query.sqlAll(ctx, func(_ context.Context, spec *sqlgraph.QuerySpec) {
assign := spec.Assign
values := spec.ScanValues
spec.ScanValues = func(columns []string) ([]any, error) {
values, err := values(columns[1:])
if err != nil {
return nil, err
}
return append([]any{new(sql.NullInt64)}, values...), nil
}
spec.Assign = func(columns []string, values []any) error {
outValue := int(values[0].(*sql.NullInt64).Int64)
inValue := int(values[1].(*sql.NullInt64).Int64)
if nids[inValue] == nil {
nids[inValue] = map[*StoragePolicy]struct{}{byID[outValue]: {}}
return assign(columns[1:], values[1:])
}
nids[inValue][byID[outValue]] = struct{}{}
return nil
}
})
})
neighbors, err := withInterceptors[[]*Group](ctx, query, qr, query.inters)
if err != nil {
return err
}
for _, n := range neighbors {
nodes, ok := nids[n.ID]
if !ok {
return fmt.Errorf(`unexpected "allowed_groups" node returned %v`, n.ID)
}
for kn := range nodes {
assign(kn, n)
}
}
return nil
}
func (spq *StoragePolicyQuery) loadNode(ctx context.Context, query *NodeQuery, nodes []*StoragePolicy, init func(*StoragePolicy), assign func(*StoragePolicy, *Node)) error {
ids := make([]int, 0, len(nodes))
nodeids := make(map[int][]*StoragePolicy)

@ -345,6 +345,21 @@ func (spu *StoragePolicyUpdate) AddEntities(e ...*Entity) *StoragePolicyUpdate {
return spu.AddEntityIDs(ids...)
}
// AddAllowedGroupIDs adds the "allowed_groups" edge to the Group entity by IDs.
func (spu *StoragePolicyUpdate) AddAllowedGroupIDs(ids ...int) *StoragePolicyUpdate {
spu.mutation.AddAllowedGroupIDs(ids...)
return spu
}
// AddAllowedGroups adds the "allowed_groups" edges to the Group entity.
func (spu *StoragePolicyUpdate) AddAllowedGroups(g ...*Group) *StoragePolicyUpdate {
ids := make([]int, len(g))
for i := range g {
ids[i] = g[i].ID
}
return spu.AddAllowedGroupIDs(ids...)
}
// SetNode sets the "node" edge to the Node entity.
func (spu *StoragePolicyUpdate) SetNode(n *Node) *StoragePolicyUpdate {
return spu.SetNodeID(n.ID)
@ -418,6 +433,27 @@ func (spu *StoragePolicyUpdate) RemoveEntities(e ...*Entity) *StoragePolicyUpdat
return spu.RemoveEntityIDs(ids...)
}
// ClearAllowedGroups clears all "allowed_groups" edges to the Group entity.
func (spu *StoragePolicyUpdate) ClearAllowedGroups() *StoragePolicyUpdate {
spu.mutation.ClearAllowedGroups()
return spu
}
// RemoveAllowedGroupIDs removes the "allowed_groups" edge to Group entities by IDs.
func (spu *StoragePolicyUpdate) RemoveAllowedGroupIDs(ids ...int) *StoragePolicyUpdate {
spu.mutation.RemoveAllowedGroupIDs(ids...)
return spu
}
// RemoveAllowedGroups removes "allowed_groups" edges to Group entities.
func (spu *StoragePolicyUpdate) RemoveAllowedGroups(g ...*Group) *StoragePolicyUpdate {
ids := make([]int, len(g))
for i := range g {
ids[i] = g[i].ID
}
return spu.RemoveAllowedGroupIDs(ids...)
}
// ClearNode clears the "node" edge to the Node entity.
func (spu *StoragePolicyUpdate) ClearNode() *StoragePolicyUpdate {
spu.mutation.ClearNode()
@ -698,6 +734,51 @@ func (spu *StoragePolicyUpdate) sqlSave(ctx context.Context) (n int, err error)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if spu.mutation.AllowedGroupsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: storagepolicy.AllowedGroupsTable,
Columns: storagepolicy.AllowedGroupsPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := spu.mutation.RemovedAllowedGroupsIDs(); len(nodes) > 0 && !spu.mutation.AllowedGroupsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: storagepolicy.AllowedGroupsTable,
Columns: storagepolicy.AllowedGroupsPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := spu.mutation.AllowedGroupsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: storagepolicy.AllowedGroupsTable,
Columns: storagepolicy.AllowedGroupsPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if spu.mutation.NodeCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@ -1059,6 +1140,21 @@ func (spuo *StoragePolicyUpdateOne) AddEntities(e ...*Entity) *StoragePolicyUpda
return spuo.AddEntityIDs(ids...)
}
// AddAllowedGroupIDs adds the "allowed_groups" edge to the Group entity by IDs.
func (spuo *StoragePolicyUpdateOne) AddAllowedGroupIDs(ids ...int) *StoragePolicyUpdateOne {
spuo.mutation.AddAllowedGroupIDs(ids...)
return spuo
}
// AddAllowedGroups adds the "allowed_groups" edges to the Group entity.
func (spuo *StoragePolicyUpdateOne) AddAllowedGroups(g ...*Group) *StoragePolicyUpdateOne {
ids := make([]int, len(g))
for i := range g {
ids[i] = g[i].ID
}
return spuo.AddAllowedGroupIDs(ids...)
}
// SetNode sets the "node" edge to the Node entity.
func (spuo *StoragePolicyUpdateOne) SetNode(n *Node) *StoragePolicyUpdateOne {
return spuo.SetNodeID(n.ID)
@ -1132,6 +1228,27 @@ func (spuo *StoragePolicyUpdateOne) RemoveEntities(e ...*Entity) *StoragePolicyU
return spuo.RemoveEntityIDs(ids...)
}
// ClearAllowedGroups clears all "allowed_groups" edges to the Group entity.
func (spuo *StoragePolicyUpdateOne) ClearAllowedGroups() *StoragePolicyUpdateOne {
spuo.mutation.ClearAllowedGroups()
return spuo
}
// RemoveAllowedGroupIDs removes the "allowed_groups" edge to Group entities by IDs.
func (spuo *StoragePolicyUpdateOne) RemoveAllowedGroupIDs(ids ...int) *StoragePolicyUpdateOne {
spuo.mutation.RemoveAllowedGroupIDs(ids...)
return spuo
}
// RemoveAllowedGroups removes "allowed_groups" edges to Group entities.
func (spuo *StoragePolicyUpdateOne) RemoveAllowedGroups(g ...*Group) *StoragePolicyUpdateOne {
ids := make([]int, len(g))
for i := range g {
ids[i] = g[i].ID
}
return spuo.RemoveAllowedGroupIDs(ids...)
}
// ClearNode clears the "node" edge to the Node entity.
func (spuo *StoragePolicyUpdateOne) ClearNode() *StoragePolicyUpdateOne {
spuo.mutation.ClearNode()
@ -1442,6 +1559,51 @@ func (spuo *StoragePolicyUpdateOne) sqlSave(ctx context.Context) (_node *Storage
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if spuo.mutation.AllowedGroupsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: storagepolicy.AllowedGroupsTable,
Columns: storagepolicy.AllowedGroupsPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := spuo.mutation.RemovedAllowedGroupsIDs(); len(nodes) > 0 && !spuo.mutation.AllowedGroupsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: storagepolicy.AllowedGroupsTable,
Columns: storagepolicy.AllowedGroupsPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := spuo.mutation.AllowedGroupsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: storagepolicy.AllowedGroupsTable,
Columns: storagepolicy.AllowedGroupsPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if spuo.mutation.NodeCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,

@ -488,7 +488,16 @@
"ftsIndexed": "Indexed",
"ftsNotIndexed": "Not indexed",
"lineEnding": "Line ending",
"downloadAll": "Download all"
"downloadAll": "Download all",
"dirPolicyTitle": "Storage Policy for Uploads",
"dirPolicyDes": "New files uploaded into \"{{name}}\" will use the selected storage policy.",
"relocateTitle": "Relocate Storage",
"relocateDes": "Move the physical content of \"{{name}}\" to the selected storage policy.",
"relocateSubmitted": "Relocation task submitted",
"relocateStart": "Relocate",
"policySaved": "Storage policy updated",
"policyInherit": "Inherit (use parent or group default)",
"policyGroupDefault": "Group default"
},
"modals": {
"includePasswordInShareLink": "Include password in share link",
@ -1056,7 +1065,12 @@
"shareDefaultPrivate": "Default share privacy",
"shareDefaultPrivateDes": "New shares default to private (password protected).",
"sharePrivateOn": "Private",
"sharePrivateOff": "Public"
"sharePrivateOff": "Public",
"preferredPolicy": "Preferred storage policy",
"preferredPolicyDes": "New uploads in your own files use this storage policy unless a folder overrides it.",
"policySaved": "Storage policy updated",
"policyInherit": "Inherit (group default)",
"policyGroupDefault": "Group default"
},
"vas": {
"points": "Points",

@ -1190,7 +1190,14 @@
"enableFileEncryptionDes": "When enabled, file blobs will be stored encrypted. Changing this setting only affects newly added files.",
"encryptedFile": "Encrypted files",
"status": "Status",
"statusDes": "Suspended policies keep serving existing files but reject new uploads."
"statusDes": "Suspended policies keep serving existing files but reject new uploads.",
"loadBalance": "Load Balance",
"lbChildren": "Child storage policies",
"lbChildPolicy": "Storage policy",
"lbWeight": "Weight",
"lbAddChild": "Add child policy",
"lbChildrenDes": "New uploads are distributed across child policies proportionally to their weight.",
"lbWizardDes": "A load-balance policy distributes uploads across weighted child policies. Children are configured after creation."
},
"node": {
"slave": "slave",
@ -1373,7 +1380,9 @@
"notCopy": "Not copy",
"confirmDelete": "Are you sure you want to delete group {{group}}?",
"new": "New group",
"editGroup": "Edit {{group}}"
"editGroup": "Edit {{group}}",
"switchablePolicies": "Switchable storage policies",
"switchablePoliciesDes": "Policies members of this group can freely switch to. The default policy above is always available."
},
"user": {
"createdAt": "Created at",
@ -1446,8 +1455,7 @@
"invitationCodeCopy": "Copy",
"invitationCodeDefaultGroup": "Default",
"noInvitationCodes": "No invitation codes yet.",
"loadMore": "Load more",
"createdAt": "Created at"
"loadMore": "Load more"
},
"file": {
"deleteXFiles": "Delete {{num}} files",

@ -488,7 +488,16 @@
"ftsIndexed": "已索引",
"ftsNotIndexed": "未索引",
"lineEnding": "换行符",
"downloadAll": "全部下载"
"downloadAll": "全部下载",
"dirPolicyTitle": "上传存储策略",
"dirPolicyDes": "上传到「{{name}}」的新文件将使用所选的存储策略。",
"relocateTitle": "迁移存储",
"relocateDes": "将「{{name}}」的物理内容迁移到所选存储策略。",
"relocateSubmitted": "迁移任务已提交",
"relocateStart": "迁移",
"policySaved": "存储策略已更新",
"policyInherit": "继承(使用父目录或用户组默认)",
"policyGroupDefault": "用户组默认"
},
"modals": {
"includePasswordInShareLink": "在链接中包含密码",
@ -1056,7 +1065,12 @@
"shareDefaultPrivate": "默认分享隐私",
"shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。",
"sharePrivateOn": "私密",
"sharePrivateOff": "公开"
"sharePrivateOff": "公开",
"preferredPolicy": "首选存储策略",
"preferredPolicyDes": "在您自己的文件中上传新文件时使用此存储策略,除非某个目录单独指定。",
"policySaved": "存储策略已更新",
"policyInherit": "继承(用户组默认)",
"policyGroupDefault": "用户组默认"
},
"vas": {
"points": "积分",

@ -1190,7 +1190,14 @@
"enableFileEncryptionDes": "开启后,文件 Blob 会被加密存储。更改此设置只对新增的文件有效。",
"encryptedFile": "加密的文件",
"status": "状态",
"statusDes": "已暂停的存储策略仍可读取已有文件,但会拒绝新的上传。"
"statusDes": "已暂停的存储策略仍可读取已有文件,但会拒绝新的上传。",
"loadBalance": "负载均衡",
"lbChildren": "子存储策略",
"lbChildPolicy": "存储策略",
"lbWeight": "权重",
"lbAddChild": "添加子策略",
"lbChildrenDes": "新上传将按权重比例分配到各子存储策略。",
"lbWizardDes": "负载均衡策略按权重将上传分配到子策略,创建后再配置子策略。"
},
"node": {
"slave": "从机",
@ -1373,10 +1380,12 @@
"notCopy": "不复制",
"confirmDelete": "确认要删除用户组 {{group}}?",
"new": "新建用户组",
"editGroup": "编辑 {{group}}"
"editGroup": "编辑 {{group}}",
"switchablePolicies": "可切换存储策略",
"switchablePoliciesDes": "该组成员可以自由切换使用的存储策略。上方默认策略始终可用。"
},
"user": {
"createdAt": "创建日期",
"createdAt": "创建时间",
"originUserGroup": "原始用户组",
"originUserGroupDes": "用户在购买用户组前所属的用户组,当前用户组到期后会回退到此用户组。",
"noOriginUserGroup": "无",
@ -1446,8 +1455,7 @@
"invitationCodeCopy": "复制",
"invitationCodeDefaultGroup": "默认组",
"noInvitationCodes": "暂无邀请码。",
"loadMore": "加载更多",
"createdAt": "创建时间"
"loadMore": "加载更多"
},
"file": {
"deleteXFiles": "删除 {{num}} 个文件",

@ -54,6 +54,8 @@ import {
DeleteFileService,
DeleteUploadSessionService,
DirectLink,
FileRelocateResponse,
FileRelocateService,
FileResponse,
FileThumbResponse,
FileUpdateService,
@ -67,9 +69,11 @@ import {
PatchMetadataService,
PatchViewSyncService,
PinFileService,
PreferredPolicyService,
RenameFileService,
Share,
ShareCreateService,
StoragePolicyBrief,
UnlockFileService,
UploadCredential,
UploadSessionRequest,
@ -526,6 +530,56 @@ export function sendMetadataPatch(req: PatchMetadataService): ThunkResponse<void
};
}
export function getAllowedPolicies(): ThunkResponse<StoragePolicyBrief[]> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/file/policy",
{
method: "GET",
},
{
...defaultOpts,
},
),
);
};
}
export function setPreferredPolicy(req: PreferredPolicyService): ThunkResponse<StoragePolicyBrief | undefined> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/file/policy",
{
data: req,
method: "PUT",
},
{
...defaultOpts,
},
),
);
};
}
export function relocateToPolicy(req: FileRelocateService): ThunkResponse<FileRelocateResponse> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/file/relocate",
{
data: req,
method: "POST",
},
{
...defaultOpts,
},
),
);
};
}
export function getSearchUser(keyword: string): ThunkResponse<User[]> {
return async (dispatch, _getState) => {
return await dispatch(

@ -47,6 +47,7 @@ export interface GroupEnt extends CommonMixin {
permissions?: string;
edges: {
storage_policies?: StoragePolicy;
allowed_policies?: StoragePolicy[];
};
total_users?: number;
settings?: GroupSetting;
@ -263,6 +264,12 @@ export interface PolicySetting {
qiniu_upload_cdn?: boolean;
chunk_concurrency?: number;
encryption?: boolean;
lb_policies?: LBPolicyRef[];
}
export interface LBPolicyRef {
policy: number;
weight?: number;
}
export interface User extends CommonMixin {

@ -176,6 +176,7 @@ export const Metadata = {
restore_uri: "sys:restore_uri",
expected_collect_time: "sys:expected_collect_time",
full_text_index: "sys:fulltext_index",
preferred_policy: "sys:preferred_policy",
// Exif
gps_lng: "exif:longitude",
@ -641,3 +642,26 @@ export interface FullTextSearchResult {
file: FileResponse;
content: string;
}
// StoragePolicyBrief is the user-facing view of a storage policy the current
// group is allowed to use.
export interface StoragePolicyBrief {
id: string;
name: string;
type: string;
is_default?: boolean;
}
export interface PreferredPolicyService {
uri: string;
policy: string;
}
export interface FileRelocateService {
uri: string;
policy: string;
}
export interface FileRelocateResponse {
id: string;
}

@ -146,6 +146,7 @@ export interface UserSettings {
share_default_private?: boolean;
preferred_viewers?: Record<string, string>;
trash_retention?: number;
preferred_policy?: string;
oauth_grants?: OAuthGrant[];
}
@ -175,6 +176,8 @@ export interface PatchUserSetting {
preferred_viewers?: Record<string, string>;
// Trash retention override in seconds; 0 inherits the group setting.
trash_retention?: number;
// Preferred storage policy hash ID; "" inherits the group default.
preferred_policy?: string;
}
export interface PasskeyCredentialOption {

@ -11,6 +11,7 @@ import SettingForm from "../../../Pages/Setting/SettingForm";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../Settings/Settings";
import { AnonymousGroupID } from "../GroupRow";
import { GroupSettingContext } from "./GroupSettingWrapper";
import PolicyMultiSelectionInput from "./PolicyMultiSelectionInput";
import PolicySelectionInput from "./PolicySelectionInput";
const BasicInfoSection = () => {
const { t } = useTranslation("dashboard");
@ -37,6 +38,16 @@ const BasicInfoSection = () => {
[setGroup],
);
const onAllowedPoliciesChange = useCallback(
(value: number[]) => {
setGroup((p: GroupEnt) => ({
...p,
edges: { ...p.edges, allowed_policies: value.map((id) => ({ id }) as StoragePolicy) },
}));
},
[setGroup],
);
const onMaxStorageChange = useCallback(
(size: number) => {
setGroup((p: GroupEnt) => ({
@ -108,6 +119,13 @@ const BasicInfoSection = () => {
<NoMarginHelperText> {t("group.availablePolicyDesPro")}
</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("group.switchablePolicies")} lgWidth={5}>
<PolicyMultiSelectionInput
value={(values.edges.allowed_policies ?? []).map((p) => p.id)}
onChange={onAllowedPoliciesChange}
/>
<NoMarginHelperText>{t("group.switchablePoliciesDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("group.initialStorageQuota")} lgWidth={5}>
<FormControl fullWidth>
<SizeInput

@ -1,7 +1,6 @@
import { Box } from "@mui/material";
import * as React from "react";
import { createContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { getGroupDetail, upsertGroup } from "../../../../api/api.ts";
import { GroupEnt, StoragePolicy } from "../../../../api/dashboard.ts";
@ -39,13 +38,13 @@ const groupValueFilter = (group: GroupEnt): GroupEnt => {
storage_policies: {
id: group.edges.storage_policies?.id ?? 0,
} as StoragePolicy,
allowed_policies: (group.edges.allowed_policies ?? []).map((p) => ({ id: p.id }) as StoragePolicy),
},
};
};
const GroupSettingWrapper = ({ groupID, children, onGroupChange }: GroupSettingWrapperProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation("dashboard");
const [values, setValues] = useState<GroupEnt>({
...defaultGroup,
});

@ -0,0 +1,82 @@
import { Box, Checkbox, FormControl, ListItemText, SelectChangeEvent } from "@mui/material";
import { useEffect, useState } from "react";
import { getStoragePolicyList } from "../../../../api/api";
import { StoragePolicy } from "../../../../api/dashboard";
import { useAppDispatch } from "../../../../redux/hooks";
import { DenseSelect, SquareChip } from "../../../Common/StyledComponents";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu";
export interface PolicyMultiSelectionInputProps {
value: number[];
onChange: (value: number[]) => void;
}
// PolicyMultiSelectionInput picks the pool of storage policies members of a
// group may freely switch between. The group's default policy is always
// available implicitly and need not be selected here.
const PolicyMultiSelectionInput = ({ value, onChange }: PolicyMultiSelectionInputProps) => {
const dispatch = useAppDispatch();
const [policies, setPolicies] = useState<StoragePolicy[]>([]);
const [loading, setLoading] = useState(false);
const [policyMap, setPolicyMap] = useState<Record<number, StoragePolicy>>({});
const handleChange = (event: SelectChangeEvent<unknown>) => {
const {
target: { value: v },
} = event;
onChange(typeof v === "string" ? v.split(",").map((x) => parseInt(x)) : (v as number[]));
};
useEffect(() => {
setLoading(true);
dispatch(getStoragePolicyList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" }))
.then((res) => {
setPolicies(res.policies);
setPolicyMap(
res.policies.reduce(
(acc, policy) => {
acc[policy.id] = policy;
return acc;
},
{} as Record<number, StoragePolicy>,
),
);
})
.finally(() => {
setLoading(false);
});
}, []);
return (
<FormControl fullWidth>
<DenseSelect
multiple
value={value}
onChange={handleChange}
sx={{
minHeight: 39,
}}
disabled={loading}
MenuProps={{
PaperProps: { sx: { maxWidth: 300 } },
}}
renderValue={(selected) => (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, py: 0.5 }}>
{(selected as number[]).map((id) => (
<SquareChip key={id} label={policyMap[id]?.name ?? id} size="small" />
))}
</Box>
)}
>
{policies.map((p) => (
<SquareMenuItem key={p.id} value={p.id}>
<Checkbox size="small" checked={value.indexOf(p.id) > -1} />
<ListItemText primary={p.name} secondary={`#${p.id} · ${p.type}`} />
</SquareMenuItem>
))}
</DenseSelect>
</FormControl>
);
};
export default PolicyMultiSelectionInput;

@ -0,0 +1,160 @@
import {
Box,
FormControl,
IconButton,
SelectChangeEvent,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { useCallback, useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getStoragePolicyList } from "../../../../../api/api";
import { LBPolicyRef, StoragePolicy } from "../../../../../api/dashboard";
import { PolicyType } from "../../../../../api/explorer";
import { useAppDispatch } from "../../../../../redux/hooks";
import { DenseFilledTextField, DenseSelect, SecondaryButton } from "../../../../Common/StyledComponents";
import { SquareMenuItem } from "../../../../FileManager/ContextMenu/ContextMenu";
import Dismiss from "../../../../Icons/Dismiss";
import SettingForm from "../../../../Pages/Setting/SettingForm";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../../Settings/Settings";
import { StoragePolicySettingContext } from "../StoragePolicySettingWrapper";
// LoadBalanceSection edits the weighted child policies of a load_balance
// policy. Only concrete (non-load-balance) policies can be children.
const LoadBalanceSection = () => {
const { t } = useTranslation("dashboard");
const { values, setPolicy } = useContext(StoragePolicySettingContext);
const dispatch = useAppDispatch();
const [policies, setPolicies] = useState<StoragePolicy[]>([]);
const isLB = values.type === PolicyType.load_balance;
const refs = values.settings?.lb_policies ?? [];
useEffect(() => {
if (!isLB) {
return;
}
dispatch(getStoragePolicyList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" })).then(
(res) => {
setPolicies(res.policies.filter((p) => p.type !== PolicyType.load_balance && p.id !== values.id));
},
);
}, [isLB]);
const setRefs = useCallback(
(next: LBPolicyRef[]) => {
setPolicy((p: StoragePolicy) => ({
...p,
settings: { ...p.settings, lb_policies: next },
}));
},
[setPolicy],
);
const addChild = useCallback(() => {
const used = new Set(refs.map((r) => r.policy));
const candidate = policies.find((p) => !used.has(p.id));
if (!candidate) {
return;
}
setRefs([...refs, { policy: candidate.id, weight: 1 }]);
}, [refs, policies, setRefs]);
const onChildChange = useCallback(
(index: number, e: SelectChangeEvent<unknown>) => {
const next = refs.slice();
next[index] = { ...next[index], policy: e.target.value as number };
setRefs(next);
},
[refs, setRefs],
);
const onWeightChange = useCallback(
(index: number, v: string) => {
const w = parseInt(v);
const next = refs.slice();
next[index] = { ...next[index], weight: isNaN(w) ? undefined : Math.max(1, w) };
setRefs(next);
},
[refs, setRefs],
);
const removeChild = useCallback(
(index: number) => {
setRefs(refs.filter((_, i) => i !== index));
},
[refs, setRefs],
);
if (!isLB) {
return null;
}
return (
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("policy.loadBalance")}
</Typography>
<SettingSectionContent>
<SettingForm title={t("policy.lbChildren")} lgWidth={12}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("policy.lbChildPolicy")}</TableCell>
<TableCell width={140}>{t("policy.lbWeight")}</TableCell>
<TableCell width={48} />
</TableRow>
</TableHead>
<TableBody>
{refs.map((r, i) => (
<TableRow key={i}>
<TableCell>
<FormControl fullWidth size="small">
<DenseSelect value={r.policy} onChange={(e) => onChildChange(i, e)}>
{policies.map((p) => (
<SquareMenuItem
key={p.id}
value={p.id}
disabled={refs.some((x, xi) => xi !== i && x.policy === p.id)}
>
{p.name}
</SquareMenuItem>
))}
</DenseSelect>
</FormControl>
</TableCell>
<TableCell>
<DenseFilledTextField
type="number"
size="small"
inputProps={{ min: 1 }}
value={r.weight ?? 1}
onChange={(e) => onWeightChange(i, e.target.value)}
/>
</TableCell>
<TableCell>
<IconButton size="small" onClick={() => removeChild(i)}>
<Dismiss fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Box sx={{ mt: 1 }}>
<SecondaryButton onClick={addChild} disabled={refs.length >= policies.length}>
{t("policy.lbAddChild")}
</SecondaryButton>
</Box>
<NoMarginHelperText>{t("policy.lbChildrenDes")}</NoMarginHelperText>
</SettingForm>
</SettingSectionContent>
</SettingSection>
);
};
export default LoadBalanceSection;

@ -1,4 +1,5 @@
export { default as BasicInfoSection } from "./BasicInfoSection";
export { default as LoadBalanceSection } from "./LoadBalanceSection";
export { default as DownloadSection } from "./DownloadSection";
export { default as EncryptionSection } from "./EncryptionSection";
export * from "./magicVars";

@ -6,6 +6,7 @@ import {
BasicInfoSection,
DownloadSection,
EncryptionSection,
LoadBalanceSection,
MediaMetadataSection,
StorageAndUploadSection,
ThumbnailsSection,
@ -29,6 +30,7 @@ const StoragePolicyForm = () => {
)}
<Stack spacing={5}>
<BasicInfoSection />
<LoadBalanceSection />
<StorageAndUploadSection />
<DownloadSection />
<ThumbnailsSection />

@ -21,6 +21,7 @@ import AddWizardDialog, { AddWizardProps } from "./AddWizardDialog";
import SelectProvider from "./SelectProvider";
import StoragePolicyCard from "./StoragePolicyCard";
import CosWizard from "./Wizards/COS/CosWizard";
import LBWizard from "./Wizards/LB/LBWizard";
import LocalWizard from "./Wizards/Local/LocalWizard";
import ObsWizard from "./Wizards/OBS/ObsWizard";
import OneDriveWizard from "./Wizards/OneDrive/OneDriveWizard";
@ -77,6 +78,7 @@ export const PolicyPropsMap: Record<PolicyType, PolicyProps> = {
name: "policy.load_balance",
img: "/static/img/lb.svg",
wizardSize: "sm",
wizard: LBWizard,
},
[PolicyType.remote]: {
name: "policy.remote",

@ -0,0 +1,52 @@
import { Button } from "@mui/material";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { StoragePolicy } from "../../../../../api/dashboard";
import { PolicyType } from "../../../../../api/explorer";
import { DenseFilledTextField } from "../../../../Common/StyledComponents";
import SettingForm from "../../../../Pages/Setting/SettingForm";
import { NoMarginHelperText } from "../../../Settings/Settings";
import { AddWizardProps } from "../../AddWizardDialog";
// LBWizard creates a bare load_balance policy; weighted children are bound on
// the policy edit page.
const LBWizard = ({ onSubmit }: AddWizardProps) => {
const { t } = useTranslation("dashboard");
const formRef = useRef<HTMLFormElement>(null);
const [policy, setPolicy] = useState<StoragePolicy>({
id: 0,
name: "",
type: PolicyType.load_balance,
settings: {
lb_policies: [],
},
edges: {},
});
const handleSubmit = () => {
if (!formRef.current?.checkValidity()) {
formRef.current?.reportValidity();
return;
}
onSubmit(policy);
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<SettingForm title={t("policy.name")} lgWidth={12}>
<DenseFilledTextField
fullWidth
required
value={policy.name}
onChange={(e) => setPolicy({ ...policy, name: e.target.value })}
/>
<NoMarginHelperText>{t("policy.lbWizardDes")}</NoMarginHelperText>
</SettingForm>
<Button variant="contained" color="primary" sx={{ mt: 2 }} onClick={handleSubmit}>
{t("policy.create")}
</Button>
</form>
);
};
export default LBWizard;

@ -7,12 +7,15 @@ import {
setCreateArchiveDialog,
setDirectLinkManagementDialog,
setManageShareDialog,
setStoragePolicyDialog,
setVersionControlDialog,
} from "../../../redux/globalStateSlice.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { resetThumbnails } from "../../../redux/thunks/file.ts";
import Archive from "../../Icons/Archive.tsx";
import BoxMultiple from "../../Icons/BoxMultiple.tsx";
import BranchForkLink from "../../Icons/BranchForkLink.tsx";
import CloudArrowIUp from "../../Icons/CloudArrowIUp.tsx";
import HistoryOutlined from "../../Icons/HistoryOutlined.tsx";
import ImageArrowCounterclockwise from "../../Icons/ImageAarowCounterclockwise.tsx";
import LinkSetting from "../../Icons/LinkSetting.tsx";
@ -25,7 +28,7 @@ const MoreMenuItems = ({ displayOpt, targets }: SubMenuItemsProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(
(f: () => any) => () => {
(f: () => void) => () => {
f();
if (rootPopupState) {
rootPopupState.close();
@ -126,6 +129,42 @@ const MoreMenuItems = ({ displayOpt, targets }: SubMenuItemsProps) => {
<ListItemText>{t("application:fileManager.createArchive")}</ListItemText>
</CascadingMenuItem>
)}
{displayOpt.showDirPolicy && (
<CascadingMenuItem
onClick={onClick(() =>
dispatch(
setStoragePolicyDialog({
open: true,
mode: "dir",
file: targets[0],
}),
),
)}
>
<ListItemIcon>
<CloudArrowIUp fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.dirPolicyTitle")}</ListItemText>
</CascadingMenuItem>
)}
{displayOpt.showRelocate && (
<CascadingMenuItem
onClick={onClick(() =>
dispatch(
setStoragePolicyDialog({
open: true,
mode: "relocate",
file: targets[0],
}),
),
)}
>
<ListItemIcon>
<BoxMultiple fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.relocateTitle")}</ListItemText>
</CascadingMenuItem>
)}
{displayOpt.showResetThumb && (
<CascadingMenuItem onClick={onClick(() => dispatch(resetThumbnails(targets)))}>
<ListItemIcon>

@ -103,6 +103,8 @@ export interface DisplayOption {
showAcl?: boolean;
showCreateArchive?: boolean;
showResetThumb?: boolean;
showDirPolicy?: boolean;
showRelocate?: boolean;
andCapability?: Boolset;
orCapability?: Boolset;
@ -320,6 +322,15 @@ export const getActionOpt = (
display.orCapability &&
display.hasReadable &&
canManageVersion(targets[0], display.orCapability);
// Storage-policy actions apply only to files the user owns; share visitors
// cannot re-home entities they do not own.
display.showDirPolicy =
targets.length == 1 &&
!!currentUser &&
targets[0].owned &&
display.hasFolder &&
!display.hasTrashFile;
display.showRelocate = targets.length == 1 && !!currentUser && targets[0].owned && !display.hasTrashFile;
display.showManageShares =
targets.length == 1 &&
targets[0].shared &&

@ -9,6 +9,7 @@ import ChangeIcon from "./ChangeIcon.tsx";
import ShareDialog from "./Share/ShareDialog.tsx";
import VersionControl from "./VersionControl.tsx";
import ManageShares from "./Share/ManageShares.tsx";
import StoragePolicy from "./StoragePolicy.tsx";
import AclDialog from "./Acl/AclDialog.tsx";
import StaleVersionConfirm from "./StaleVersionConfirm.tsx";
import SaveAs from "./SaveAs.tsx";
@ -31,7 +32,6 @@ import CreateArchive from "./CreateArchive.tsx";
import ExtractArchive from "./ExtractArchive.tsx";
import CreateRemoteDownload from "./CreateRemoteDownload.tsx";
import AdvanceSearch from "../Search/AdvanceSearch/AdvanceSearch.tsx";
import React from "react";
import ColumnSetting from "../Explorer/ListView/ColumnSetting.tsx";
import DirectLinks from "./DirectLinks/DirectLinks.tsx";
import DirectLinksControl from "./DirectLinksControl.tsx";
@ -83,6 +83,7 @@ const Dialogs = () => {
{directLink != undefined && <DirectLinks />}
{excalidrawViewer != undefined && <ExcalidrawViewer />}
{directLinkManagement != undefined && <DirectLinksControl />}
<StoragePolicy />
{archivePreview != undefined && <ArchivePreview />}
{desktopMountSetup != undefined && <DesktopMountSetup />}
</>

@ -0,0 +1,117 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControl,
ListItemText,
SelectChangeEvent,
} from "@mui/material";
import { useSnackbar } from "notistack";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAllowedPolicies, relocateToPolicy, setPreferredPolicy } from "../../../api/api";
import { Metadata, StoragePolicyBrief } from "../../../api/explorer";
import { closeStoragePolicyDialog } from "../../../redux/globalStateSlice";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { refreshFileList } from "../../../redux/thunks/filemanager";
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar";
import { DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../ContextMenu/ContextMenu";
import { FileManagerIndex } from "../FileManager";
// StoragePolicyDialog switches the storage policy for either a folder's
// preferred upload policy ("dir") or a file/folder's physical entities
// ("relocate"), choosing from the group's allowed set.
const StoragePolicyDialog = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const open = useAppSelector((state) => state.globalState.storagePolicyDialogOpen);
const mode = useAppSelector((state) => state.globalState.storagePolicyDialogMode);
const file = useAppSelector((state) => state.globalState.storagePolicyDialogFile);
const [policies, setPolicies] = useState<StoragePolicyBrief[]>([]);
const [value, setValue] = useState<string>("");
const [loading, setLoading] = useState(false);
const isDir = mode === "dir";
const current = file?.metadata?.[Metadata.preferred_policy] ?? "";
useEffect(() => {
if (!open) {
return;
}
setValue(isDir ? current : "");
dispatch(getAllowedPolicies()).then((res) => setPolicies(res ?? []));
}, [open]);
const onClose = () => dispatch(closeStoragePolicyDialog());
const onChange = (e: SelectChangeEvent<unknown>) => {
setValue(e.target.value as string);
};
const onSubmit = () => {
if (!file) {
return;
}
setLoading(true);
const uri = file.path;
const req = isDir
? dispatch(setPreferredPolicy({ uri, policy: value })).then(() => {
enqueueSnackbar(t("fileManager.policySaved"), { variant: "success", action: DefaultCloseAction });
})
: dispatch(relocateToPolicy({ uri, policy: value })).then(() => {
enqueueSnackbar(t("fileManager.relocateSubmitted"), { variant: "success", action: DefaultCloseAction });
});
req.then(() => {
onClose();
dispatch(refreshFileList(FileManagerIndex.main));
}).finally(() => setLoading(false));
};
return (
<Dialog open={!!open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>{isDir ? t("fileManager.dirPolicyTitle") : t("fileManager.relocateTitle")}</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
{isDir
? t("fileManager.dirPolicyDes", { name: file?.name ?? "" })
: t("fileManager.relocateDes", { name: file?.name ?? "" })}
</DialogContentText>
<FormControl fullWidth>
<DenseSelect value={value} onChange={onChange} disabled={loading}>
{isDir && (
<SquareMenuItem value="">
<ListItemText primary={t("fileManager.policyInherit")} />
</SquareMenuItem>
)}
{policies.map((p) => (
<SquareMenuItem key={p.id} value={p.id}>
<ListItemText
primary={p.name}
secondary={p.is_default ? t("fileManager.policyGroupDefault") : undefined}
/>
</SquareMenuItem>
))}
</DenseSelect>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("common:cancel")}</Button>
<Button
variant="contained"
onClick={onSubmit}
disabled={loading || (!isDir && !value) || (isDir && value === current)}
>
{isDir ? t("common:save") : t("fileManager.relocateStart")}
</Button>
</DialogActions>
</Dialog>
);
};
export default StoragePolicyDialog;

@ -1,12 +1,19 @@
import { Box, Stack, styled, Typography, useMediaQuery, useTheme } from "@mui/material";
import { Box, FormControl, ListItemText, SelectChangeEvent, Stack, styled, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useSnackbar } from "notistack";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { getAllowedPolicies, sendUpdateUserSetting } from "../../../api/api.ts";
import { StoragePolicyBrief } from "../../../api/explorer.ts";
import { Capacity, UserSettings } from "../../../api/user.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { updateUserCapacity } from "../../../redux/thunks/filemanager.ts";
import { loadSiteConfig } from "../../../redux/thunks/site.ts";
import { sizeToString } from "../../../util";
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx";
import { DenseSelect } from "../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
import { NoMarginHelperText } from "../../Admin/Settings/Settings.tsx";
import SettingForm from "./SettingForm.tsx";
export const StorageBar = styled(Box)(({ theme }) => ({
@ -108,12 +115,28 @@ const StorageSetting = ({ setting }: StorageSettingProps) => {
used: 0,
base: 0,
});
const [policies, setPolicies] = useState<StoragePolicyBrief[]>([]);
const [preferred, setPreferred] = useState(setting.preferred_policy ?? "");
const [saving, setSaving] = useState(false);
const { enqueueSnackbar } = useSnackbar();
useEffect(() => {
dispatch(updateUserCapacity(0));
dispatch(loadSiteConfig("vas"));
dispatch(getAllowedPolicies()).then((res) => setPolicies(res ?? []));
}, []);
const onPolicyChange = (e: SelectChangeEvent<unknown>) => {
const v = e.target.value as string;
setPreferred(v);
setSaving(true);
dispatch(sendUpdateUserSetting({ preferred_policy: v }))
.then(() => {
enqueueSnackbar(t("setting.policySaved"), { variant: "success", action: DefaultCloseAction });
})
.finally(() => setSaving(false));
};
return (
<Stack spacing={3}>
<SettingForm title={t("vas.quota")}>
@ -121,6 +144,26 @@ const StorageSetting = ({ setting }: StorageSettingProps) => {
<CapacityBar capacity={capacity} />
</Box>
</SettingForm>
{policies.length > 1 && (
<SettingForm title={t("setting.preferredPolicy")}>
<FormControl fullWidth sx={{ mt: 1 }}>
<DenseSelect value={preferred} onChange={onPolicyChange} disabled={saving}>
<SquareMenuItem value="">
<ListItemText primary={t("setting.policyInherit")} />
</SquareMenuItem>
{policies.map((p) => (
<SquareMenuItem key={p.id} value={p.id}>
<ListItemText
primary={p.name}
secondary={p.is_default ? t("setting.policyGroupDefault") : undefined}
/>
</SquareMenuItem>
))}
</DenseSelect>
<NoMarginHelperText>{t("setting.preferredPolicyDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
</Stack>
);
};

@ -222,6 +222,12 @@ export interface GlobalStateSlice {
directLinkManagementDialogFile?: FileResponse;
directLinkHighlight?: string;
// Storage policy dialog ("dir" sets the folder's preferred upload policy,
// "relocate" moves the target's entities to another policy)
storagePolicyDialogOpen?: boolean;
storagePolicyDialogMode?: "dir" | "relocate";
storagePolicyDialogFile?: FileResponse;
// Desktop mount setup dialog
desktopMountSetupDialogOpen?: boolean;
desktopMountSetupState?: DesktopCallbackState;
@ -362,6 +368,19 @@ export const globalStateSlice = createSlice({
state.directLinkManagementDialogFile = undefined;
state.directLinkHighlight = undefined;
},
setStoragePolicyDialog: (
state,
action: PayloadAction<{ open: boolean; mode?: "dir" | "relocate"; file?: FileResponse }>,
) => {
state.storagePolicyDialogOpen = action.payload.open;
state.storagePolicyDialogMode = action.payload.mode;
state.storagePolicyDialogFile = action.payload.file;
},
closeStoragePolicyDialog: (state) => {
state.storagePolicyDialogOpen = false;
state.storagePolicyDialogMode = undefined;
state.storagePolicyDialogFile = undefined;
},
setMobileDrawerOpen: (state, action: PayloadAction<boolean>) => {
state.mobileDrawerOpen = action.payload;
},
@ -966,6 +985,8 @@ export const {
closeExcalidrawViewer,
setDirectLinkManagementDialog,
closeDirectLinkManagementDialog,
setStoragePolicyDialog,
closeStoragePolicyDialog,
setShareReadmeDetect,
closeShareReadme,
setShareReadmeOpen,

@ -8,11 +8,16 @@ import (
"github.com/cloudreve/Cloudreve/v4/ent/group"
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/samber/lo"
)
type (
// Ctx keys for eager loading options.
LoadGroupPolicy struct{}
// LoadGroupAllowedPolicies eagerly loads the group's allowed_policies M2M
// edge.
LoadGroupAllowedPolicies struct{}
)
const (
@ -77,6 +82,10 @@ func (c *groupClient) ListAll(ctx context.Context) ([]*ent.Group, error) {
}
func (c *groupClient) Upsert(ctx context.Context, group *ent.Group) (*ent.Group, error) {
allowedIDs := lo.Map(group.Edges.AllowedPolicies, func(p *ent.StoragePolicy, _ int) int {
return p.ID
})
if group.ID == 0 {
stm := c.client.Group.Create().
SetName(group.Name).
@ -88,6 +97,9 @@ func (c *groupClient) Upsert(ctx context.Context, group *ent.Group) (*ent.Group,
if group.Edges.StoragePolicies != nil && group.Edges.StoragePolicies.ID > 0 {
stm.SetStoragePolicyID(group.Edges.StoragePolicies.ID)
}
if len(allowedIDs) > 0 {
stm.AddAllowedPolicyIDs(allowedIDs...)
}
return stm.Save(ctx)
}
@ -98,11 +110,15 @@ func (c *groupClient) Upsert(ctx context.Context, group *ent.Group) (*ent.Group,
SetSpeedLimit(group.SpeedLimit).
SetPermissions(group.Permissions).
SetSettings(group.Settings).
ClearStoragePolicies()
ClearStoragePolicies().
ClearAllowedPolicies()
if group.Edges.StoragePolicies != nil && group.Edges.StoragePolicies.ID > 0 {
stm.SetStoragePolicyID(group.Edges.StoragePolicies.ID)
}
if len(allowedIDs) > 0 {
stm.AddAllowedPolicyIDs(allowedIDs...)
}
res, err := stm.Save(ctx)
if err != nil {
@ -173,5 +189,8 @@ func withGroupEagerLoading(ctx context.Context, q *ent.GroupQuery) *ent.GroupQue
withStoragePolicyEagerLoading(ctx, spq)
})
}
if _, ok := ctx.Value(LoadGroupAllowedPolicies{}).(bool); ok {
q.WithAllowedPolicies()
}
return q
}

@ -4,12 +4,15 @@ import (
"context"
"encoding/gob"
"fmt"
"math/rand"
"sort"
"strconv"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
"github.com/samber/lo"
)
const (
@ -30,6 +33,15 @@ type (
TxOperator
// GetByGroup returns the storage policies of the group.
GetByGroup(ctx context.Context, group *ent.Group) (*ent.StoragePolicy, error)
// ListByGroup returns every active storage policy usable by the
// group: the allowed_policies set plus the legacy single-policy
// default. Returns the set in stable id order.
ListByGroup(ctx context.Context, group *ent.Group) ([]*ent.StoragePolicy, error)
// ResolveLoadBalance picks a concrete child of a load_balance policy
// by its configured weights. Suspended, missing, and nested
// load_balance children are skipped; the pick is weighted over the
// remaining set.
ResolveLoadBalance(ctx context.Context, policy *ent.StoragePolicy) (*ent.StoragePolicy, error)
// GetPolicyByID returns the storage policy by id.
GetPolicyByID(ctx context.Context, id int) (*ent.StoragePolicy, error)
// UpdateAccessKey updates the access key of the storage policy. It also clear related cache in KV.
@ -161,6 +173,68 @@ func (c *storagePolicyClient) GetByGroup(ctx context.Context, group *ent.Group)
return res, nil
}
func (c *storagePolicyClient) ListByGroup(ctx context.Context, group *ent.Group) ([]*ent.StoragePolicy, error) {
allowed, err := withStoragePolicyEagerLoading(ctx, c.client.Group.QueryAllowedPolicies(group)).
Where(storagepolicy.StatusEQ(storagepolicy.StatusActive)).
All(ctx)
if err != nil {
return nil, fmt.Errorf("list allowed storage policies: %w", err)
}
// The legacy storage_policy_id default is always part of the allowed set.
if group.Edges.StoragePolicies != nil {
def := group.Edges.StoragePolicies
if !lo.ContainsBy(allowed, func(p *ent.StoragePolicy) bool { return p.ID == def.ID }) {
if def.Status == storagepolicy.StatusActive {
allowed = append(allowed, def)
}
}
} else if group.StoragePolicyID > 0 {
if def, err := c.GetPolicyByID(ctx, group.StoragePolicyID); err == nil && def.Status == storagepolicy.StatusActive {
if !lo.ContainsBy(allowed, func(p *ent.StoragePolicy) bool { return p.ID == def.ID }) {
allowed = append(allowed, def)
}
}
}
sort.Slice(allowed, func(i, j int) bool { return allowed[i].ID < allowed[j].ID })
return allowed, nil
}
func (c *storagePolicyClient) ResolveLoadBalance(ctx context.Context, policy *ent.StoragePolicy) (*ent.StoragePolicy, error) {
refs := policy.Settings.LBPolicies
if len(refs) == 0 {
return nil, fmt.Errorf("load-balance policy %d has no child policies", policy.ID)
}
children := make([]*ent.StoragePolicy, 0, len(refs))
weights := make([]int, 0, len(refs))
for _, ref := range refs {
child, err := c.GetPolicyByID(ctx, ref.PolicyID)
if err != nil || child.Status != storagepolicy.StatusActive || child.Type == types.PolicyTypeLoadBalance {
continue
}
children = append(children, child)
weights = append(weights, max(ref.Weight, 1))
}
if len(children) == 0 {
return nil, fmt.Errorf("load-balance policy %d has no usable child policies", policy.ID)
}
total := 0
for _, w := range weights {
total += w
}
n := rand.Intn(total)
for i, w := range weights {
if n < w {
return children[i], nil
}
n -= w
}
return children[len(children)-1], nil
}
// GetPolicyByID returns the storage policy by id.
func (c *storagePolicyClient) GetPolicyByID(ctx context.Context, id int) (*ent.StoragePolicy, error) {
val, skipCache := ctx.Value(SkipStoragePolicyCache{}).(bool)

@ -0,0 +1,130 @@
package inventory
import (
"context"
"testing"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/stretchr/testify/require"
)
func TestListByGroup(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
pc := NewStoragePolicyClient(client, nil)
mk := func(name string, status storagepolicy.Status) *ent.StoragePolicy {
return client.StoragePolicy.Create().
SetName(name).SetType("local").SetStatus(status).SaveX(ctx)
}
t.Run("legacy default included when no M2M rows", func(t *testing.T) {
def := mk("def", storagepolicy.StatusActive)
g := client.Group.Create().SetName("g1").SetPermissions(&boolset.BooleanSet{}).
SetStoragePolicies(def).SaveX(ctx)
got, err := pc.ListByGroup(ctx, g)
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, def.ID, got[0].ID)
})
t.Run("allowed set union with default, active only, ordered by id", func(t *testing.T) {
def := mk("def", storagepolicy.StatusActive)
a := mk("a", storagepolicy.StatusActive)
b := mk("b", storagepolicy.StatusActive)
susp := mk("susp", storagepolicy.StatusSuspended)
g := client.Group.Create().SetName("g2").SetPermissions(&boolset.BooleanSet{}).
SetStoragePolicies(def).AddAllowedPolicies(a, b, susp).SaveX(ctx)
got, err := pc.ListByGroup(ctx, g)
require.NoError(t, err)
require.Len(t, got, 3)
ids := []int{got[0].ID, got[1].ID, got[2].ID}
require.IsIncreasing(t, ids)
require.ElementsMatch(t, []int{a.ID, b.ID, def.ID}, ids)
})
t.Run("suspended default not included", func(t *testing.T) {
def := mk("def", storagepolicy.StatusSuspended)
a := mk("a", storagepolicy.StatusActive)
g := client.Group.Create().SetName("g3").SetPermissions(&boolset.BooleanSet{}).
SetStoragePolicies(def).AddAllowedPolicies(a).SaveX(ctx)
got, err := pc.ListByGroup(ctx, g)
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, a.ID, got[0].ID)
})
t.Run("no usable policy returns empty", func(t *testing.T) {
def := mk("def", storagepolicy.StatusSuspended)
g := client.Group.Create().SetName("g4").SetPermissions(&boolset.BooleanSet{}).
SetStoragePolicies(def).SaveX(ctx)
got, err := pc.ListByGroup(ctx, g)
require.NoError(t, err)
require.Empty(t, got)
})
}
func TestResolveLoadBalance(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
pc := NewStoragePolicyClient(client, nil)
t.Run("skips suspended, missing and nested children", func(t *testing.T) {
nested := client.StoragePolicy.Create().SetName("nested").SetType(types.PolicyTypeLoadBalance).
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
susp := client.StoragePolicy.Create().SetName("susp").SetType("local").
SetStatus(storagepolicy.StatusSuspended).SaveX(ctx)
ok := client.StoragePolicy.Create().SetName("ok").SetType("local").
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
lb := client.StoragePolicy.Create().SetName("lb").SetType(types.PolicyTypeLoadBalance).
SetStatus(storagepolicy.StatusActive).
SetSettings(&types.PolicySetting{LBPolicies: []types.LBPolicyRef{
{PolicyID: nested.ID, Weight: 10},
{PolicyID: susp.ID, Weight: 10},
{PolicyID: 99999, Weight: 10},
{PolicyID: ok.ID, Weight: 10},
}}).SaveX(ctx)
for i := 0; i < 20; i++ {
got, err := pc.ResolveLoadBalance(ctx, lb)
require.NoError(t, err)
require.Equal(t, ok.ID, got.ID)
}
})
t.Run("weight of zero treated as one", func(t *testing.T) {
zero := client.StoragePolicy.Create().SetName("zero").SetType("local").
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
lb := client.StoragePolicy.Create().SetName("lb").SetType(types.PolicyTypeLoadBalance).
SetStatus(storagepolicy.StatusActive).
SetSettings(&types.PolicySetting{LBPolicies: []types.LBPolicyRef{
{PolicyID: zero.ID, Weight: 0},
}}).SaveX(ctx)
got, err := pc.ResolveLoadBalance(ctx, lb)
require.NoError(t, err)
require.Equal(t, zero.ID, got.ID)
})
t.Run("no usable children errors", func(t *testing.T) {
lb := client.StoragePolicy.Create().SetName("lb").SetType(types.PolicyTypeLoadBalance).
SetStatus(storagepolicy.StatusActive).
SetSettings(&types.PolicySetting{LBPolicies: []types.LBPolicyRef{
{PolicyID: 424242, Weight: 5},
}}).SaveX(ctx)
_, err := pc.ResolveLoadBalance(ctx, lb)
require.Error(t, err)
})
}

@ -28,6 +28,16 @@ type (
// 0 means inherit the group setting. Applied when a file is moved to
// trash — already-trashed files keep their original expiry.
TrashRetention int `json:"trash_retention,omitempty"`
// PreferredPolicy is the user's default storage policy, chosen from
// the policies allowed for their group. 0 means the group default.
PreferredPolicy int `json:"preferred_policy,omitempty"`
}
// LBPolicyRef binds a child storage policy to a load_balance policy with
// a selection weight. Weight 0 counts as 1.
LBPolicyRef struct {
PolicyID int `json:"policy"`
Weight int `json:"weight,omitempty"`
}
ShareLinksInProfileLevel string
@ -48,9 +58,9 @@ type (
Aria2TaskLimit int `json:"aria2_task_limit,omitempty"`
// Aria2MaxFileSize caps the total byte size of a single download task.
Aria2MaxFileSize int64 `json:"aria2_max_file_size,omitempty"`
MaxWalkedFiles int `json:"max_walked_files,omitempty"`
TrashRetention int `json:"trash_retention,omitempty"`
RedirectedSource bool `json:"redirected_source,omitempty"`
MaxWalkedFiles int `json:"max_walked_files,omitempty"`
TrashRetention int `json:"trash_retention,omitempty"`
RedirectedSource bool `json:"redirected_source,omitempty"`
// LoginIPWhitelist restricts sign-in to the given IPs/CIDR ranges.
// Empty means no restriction.
LoginIPWhitelist []string `json:"login_ip_whitelist,omitempty"`
@ -122,6 +132,9 @@ type (
StreamSaver bool `json:"stream_saver,omitempty"`
// UseCname whether to use CNAME for endpoint (OSS).
UseCname bool `json:"use_cname,omitempty"`
// LBPolicies binds weighted child policies to a load_balance policy.
// Children must be concrete (non-load_balance) policies.
LBPolicies []LBPolicyRef `json:"lb_policies,omitempty"`
// CDN domain does not need to be signed.
SourceAuth bool `json:"source_auth,omitempty"`
// QiniuUploadCdn whether to use CDN for Qiniu upload.
@ -419,6 +432,10 @@ const (
PolicyTypeOd = "onedrive"
PolicyTypeRemote = "remote"
PolicyTypeObs = "obs"
// PolicyTypeLoadBalance distributes uploads across weighted child
// policies. It is resolved to a concrete child policy before use and
// never reaches a storage driver.
PolicyTypeLoadBalance = "load_balance"
)
const (

@ -735,18 +735,82 @@ func (f *DBFS) generateEncryptMetadata(ctx context.Context, uploadRequest *fs.Up
// getPreferredPolicy tries to get the preferred storage policy for the given file.
func (f *DBFS) getPreferredPolicy(ctx context.Context, file *File) (*ent.StoragePolicy, error) {
ownerGroup := file.Owner().Edges.Group
owner := file.Owner()
ownerGroup := owner.Edges.Group
if ownerGroup == nil {
return nil, fmt.Errorf("owner group not loaded")
}
sc, _ := inventory.InheritTx(ctx, f.storagePolicyClient)
groupPolicy, err := sc.GetByGroup(ctx, ownerGroup)
allowed, err := sc.ListByGroup(ctx, ownerGroup)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get available storage policies", err)
}
if len(allowed) == 0 {
return nil, serializer.NewError(serializer.CodeDBError, "No active storage policy available for the group", nil)
}
candidate := f.pickPolicy(ctx, file, owner, allowed)
// A load_balance policy resolves to one of its weighted children before
// any storage driver sees it.
if candidate.Type == types.PolicyTypeLoadBalance {
child, err := sc.ResolveLoadBalance(ctx, candidate)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to resolve load-balanced storage policy", err)
}
candidate = child
}
return candidate, nil
}
// pickPolicy chooses among the group's allowed policies: the nearest ancestor
// directory carrying a preferred-policy marker wins; next the owner's own
// preferred_policy setting (applied only in their own tree); finally the
// group default, or the first allowed policy when none is configured.
func (f *DBFS) pickPolicy(ctx context.Context, file *File, owner *ent.User, allowed []*ent.StoragePolicy) *ent.StoragePolicy {
inAllowed := func(id int) *ent.StoragePolicy {
for _, p := range allowed {
if p.ID == id {
return p
}
}
return nil
}
return groupPolicy, nil
// The nearest ancestor directory with a preference wins; if its choice is
// no longer usable, the preference is ignored rather than inherited from
// a further ancestor. Ancestor metadata may not be eager-loaded, so each
// level is loaded lazily until a marker is found.
for _, ancestor := range file.AncestorsChain() {
if _, err := ancestor.Model.Edges.MetadataOrErr(); err != nil {
if err := f.fileClient.QueryMetadata(ctx, ancestor.Model); err != nil {
continue
}
}
raw := ancestor.Metadata()[MetadataPreferredPolicy]
if raw == "" {
continue
}
if id, err := f.hasher.Decode(raw, hashid.PolicyID); err == nil {
if p := inAllowed(id); p != nil {
return p
}
}
break
}
if f.user.ID == owner.ID && owner.Settings != nil && owner.Settings.PreferredPolicy > 0 {
if p := inAllowed(owner.Settings.PreferredPolicy); p != nil {
return p
}
}
if p := inAllowed(owner.Edges.Group.StoragePolicyID); p != nil {
return p
}
return allowed[0]
}
func (f *DBFS) getFileByPath(ctx context.Context, navigator Navigator, path *fs.URI) (*File, error) {

@ -65,6 +65,9 @@ const (
MetadataRestoreUri = MetadataSysPrefix + "restore_uri"
MetadataExpectedCollectTime = MetadataSysPrefix + "expected_collect_time"
MetadataSharedOwner = MetadataSysPrefix + "shared_owner"
// MetadataPreferredPolicy stores the hashid-encoded storage policy a
// directory prefers for new uploads inside it.
MetadataPreferredPolicy = MetadataSysPrefix + "preferred_policy"
ThumbMetadataPrefix = "thumb:"
ThumbDisabledKey = ThumbMetadataPrefix + "disabled"

@ -0,0 +1,138 @@
package dbfs
import (
"context"
"testing"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
entfile "github.com/cloudreve/Cloudreve/v4/ent/file"
entmetadata "github.com/cloudreve/Cloudreve/v4/ent/metadata"
"github.com/cloudreve/Cloudreve/v4/ent/schema"
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/stretchr/testify/require"
)
// pickPolicyFixture builds an owner in a group whose allowed set is
// [a, b] with `def` as the legacy default, and a dir chain root/dir/target.
func pickPolicyFixture(t *testing.T, client *ent.Client) (*ent.Group, *ent.StoragePolicy, *ent.StoragePolicy, *ent.StoragePolicy, *ent.User, *ent.File, *ent.File) {
t.Helper()
ctx := context.Background()
mk := func(name string) *ent.StoragePolicy {
return client.StoragePolicy.Create().SetName(name).SetType("local").
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
}
def := mk("def")
a := mk("a")
b := mk("b")
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).
SetStoragePolicies(def).AddAllowedPolicies(a, b).SaveX(ctx)
owner := client.User.Create().SetEmail("o@example.com").SetNick("o").
SetGroup(group).SetSettings(&types.UserSetting{PreferredPolicy: b.ID}).SaveX(ctx)
root := client.File.Create().SetName(inventory.RootFolderName).
SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx)
dir := client.File.Create().SetName("dir").SetType(int(types.FileTypeFolder)).
SetOwner(owner).SetParent(root).SaveX(ctx)
return group, def, a, b, owner, root, dir
}
func pickPolicyDBFS(t *testing.T, client *ent.Client, user *ent.User) *DBFS {
t.Helper()
hasher, err := hashid.New("pick-test-salt")
require.NoError(t, err)
return &DBFS{
user: user,
fileClient: inventory.NewFileClient(client, conf.SQLiteDB, hasher),
storagePolicyClient: inventory.NewStoragePolicyClient(client, nil),
hasher: hasher,
l: logging.NewConsoleLogger(logging.LevelError),
}
}
func wrapChain(models ...*ent.File) *File {
var parent *File
for _, m := range models {
parent = &File{Model: m, Parent: parent}
}
return parent
}
func setPreferredMarker(t *testing.T, client *ent.Client, f *ent.File, hasher hashid.Encoder, policyID int) {
t.Helper()
ctx := schema.SkipSoftDelete(context.Background())
client.Metadata.Delete().Where(entmetadata.Name(MetadataPreferredPolicy),
entmetadata.HasFileWith(entfile.ID(f.ID))).ExecX(ctx)
client.Metadata.Create().SetFile(f).SetName(MetadataPreferredPolicy).
SetValue(hashid.EncodePolicyID(hasher, policyID)).SaveX(ctx)
}
// freshChain reloads the file rows so previously lazy-loaded metadata edges
// do not leak between subtests.
func freshChain(t *testing.T, client *ent.Client, ids ...int) *File {
t.Helper()
models := make([]*ent.File, len(ids))
for i, id := range ids {
models[i] = client.File.GetX(context.Background(), id)
}
return wrapChain(models...)
}
func TestPickPolicyPrecedence(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
group, def, a, b, owner, root, dir := pickPolicyFixture(t, client)
owner.SetGroup(group)
allowed := []*ent.StoragePolicy{a, b, def}
f := pickPolicyDBFS(t, client, owner)
t.Run("directory marker wins over user preference", func(t *testing.T) {
setPreferredMarker(t, client, dir, f.hasher, a.ID)
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
require.Equal(t, a.ID, got.ID)
})
t.Run("user preference wins over group default", func(t *testing.T) {
client.Metadata.Delete().ExecX(schema.SkipSoftDelete(ctx))
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
require.Equal(t, b.ID, got.ID)
})
t.Run("invalid marker stops ancestor inheritance", func(t *testing.T) {
// Root carries a valid marker, but the nearer dir marker points at a
// policy outside the allowed set — must not fall through to root's.
outside := client.StoragePolicy.Create().SetName("outside").SetType("local").
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
setPreferredMarker(t, client, root, f.hasher, a.ID)
setPreferredMarker(t, client, dir, f.hasher, outside.ID)
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
require.Equal(t, b.ID, got.ID)
})
t.Run("other user's tree ignores user preference", func(t *testing.T) {
other := client.User.Create().SetEmail("x@example.com").SetNick("x").SetGroup(group).SaveX(ctx)
otherFs := pickPolicyDBFS(t, client, other)
got := otherFs.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
require.Equal(t, def.ID, got.ID)
})
t.Run("group default applies without preferences", func(t *testing.T) {
ownerNoPref := client.User.Create().SetEmail("np@example.com").SetNick("np").
SetGroup(group).SetSettings(&types.UserSetting{}).SaveX(ctx)
ownerNoPref.SetGroup(group)
fsNoPref := pickPolicyDBFS(t, client, ownerNoPref)
got := fsNoPref.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), ownerNoPref, allowed)
require.Equal(t, def.ID, got.ID)
})
}

@ -480,3 +480,42 @@ func FulltextSearch(c *gin.Context) {
Data: resp,
})
}
// ListStoragePolicies lists storage policies available to the current user's group.
func ListStoragePolicies(c *gin.Context) {
service := ParametersFromContext[*explorer.AllowedPolicyService](c, explorer.AllowedPolicyParamCtx{})
resp, err := service.Get(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{
Data: resp,
})
}
// UpdatePreferredPolicy sets the preferred storage policy for a directory.
func UpdatePreferredPolicy(c *gin.Context) {
service := ParametersFromContext[*explorer.PreferredPolicyService](c, explorer.PreferredPolicyParamCtx{})
resp, err := service.Update(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{
Data: resp,
})
}
// RelocatePolicy queues a task to relocate files to a different storage policy.
func RelocatePolicy(c *gin.Context) {
service := ParametersFromContext[*explorer.FileRelocateService](c, explorer.FileRelocateParamCtx{})
resp, err := service.Create(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{
Data: resp,
})
}

@ -750,6 +750,23 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
middleware.ValidateBatchFileCount(dep, explorer.PatchMetadataParameterCtx{}),
controllers.PatchMetadata,
)
// List storage policies available to the current group
file.GET("policy",
controllers.FromQuery[explorer.AllowedPolicyService](explorer.AllowedPolicyParamCtx{}),
controllers.ListStoragePolicies,
)
// Set preferred storage policy for a directory
file.PUT("policy",
middleware.RequiredScopes(types.ScopeFilesWrite),
controllers.FromJSON[explorer.PreferredPolicyService](explorer.PreferredPolicyParamCtx{}),
controllers.UpdatePreferredPolicy,
)
// Relocate files to a different storage policy
file.POST("relocate",
middleware.RequiredScopes(types.ScopeFilesWrite),
controllers.FromJSON[explorer.FileRelocateService](explorer.FileRelocateParamCtx{}),
controllers.RelocatePolicy,
)
// Upload related
upload := file.Group("upload", middleware.RequiredScopes(types.ScopeFilesWrite))
{

@ -124,6 +124,7 @@ func (s *SingleGroupService) Get(c *gin.Context) (*GetGroupResponse, error) {
groupClient := dep.GroupClient()
ctx := context.WithValue(c, inventory.LoadGroupPolicy{}, true)
ctx = context.WithValue(ctx, inventory.LoadGroupAllowedPolicies{}, true)
group, err := groupClient.GetByID(ctx, s.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get group", err)

@ -0,0 +1,238 @@
package explorer
import (
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/workflows"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
// StoragePolicyBrief is the user-facing view of an allowed storage policy.
type StoragePolicyBrief struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
IsDefault bool `json:"is_default,omitempty"`
}
type (
// AllowedPolicyService lists the storage policies the caller's group may use.
AllowedPolicyService struct{}
AllowedPolicyParamCtx struct{}
// PreferredPolicyService sets or clears a directory's preferred storage
// policy. New uploads inside the directory use it.
PreferredPolicyService struct {
Uri string `json:"uri" binding:"required"`
Policy string `json:"policy"`
}
PreferredPolicyParamCtx struct{}
// FileRelocateService moves a file's or folder's entities to another
// storage policy via the resumable relocation task.
FileRelocateService struct {
Uri string `json:"uri" binding:"required"`
Policy string `json:"policy" binding:"required"`
}
FileRelocateParamCtx struct{}
// FileRelocateResponse carries the created relocation task id.
FileRelocateResponse struct {
ID string `json:"id"`
}
)
// allowedGroupPolicies resolves the group's usable policy set and maps each
// to a brief. The group's configured default is marked.
func allowedGroupPolicies(c *gin.Context, group *ent.Group, dep dependency.Dep) ([]*ent.StoragePolicy, []*StoragePolicyBrief, error) {
allowed, err := dep.StoragePolicyClient().ListByGroup(c, group)
if err != nil {
return nil, nil, serializer.NewError(serializer.CodeDBError, "Failed to list storage policies", err)
}
if len(allowed) == 0 {
return nil, nil, serializer.NewError(serializer.CodeNoPermissionErr, "No storage policy is available for your group", nil)
}
hasher := dep.HashIDEncoder()
briefs := lo.Map(allowed, func(p *ent.StoragePolicy, _ int) *StoragePolicyBrief {
return &StoragePolicyBrief{
ID: hashid.EncodePolicyID(hasher, p.ID),
Name: p.Name,
Type: p.Type,
IsDefault: p.ID == group.StoragePolicyID,
}
})
return allowed, briefs, nil
}
func (s *AllowedPolicyService) Get(c *gin.Context) ([]*StoragePolicyBrief, error) {
user := inventory.UserFromContext(c)
if user.Edges.Group == nil {
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "Group not loaded", nil)
}
_, briefs, err := allowedGroupPolicies(c, user.Edges.Group, dependency.FromContext(c))
return briefs, err
}
// decodeAllowedPolicy validates a hashid-encoded policy id against the
// group's allowed set and returns the matching policy.
func decodeAllowedPolicy(c *gin.Context, dep dependency.Dep, group *ent.Group, raw string) (*ent.StoragePolicy, error) {
id, err := dep.HashIDEncoder().Decode(raw, hashid.PolicyID)
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid storage policy", err)
}
allowed, err := dep.StoragePolicyClient().ListByGroup(c, group)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list storage policies", err)
}
for _, p := range allowed {
if p.ID == id {
return p, nil
}
}
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "Storage policy is not available for your group", nil)
}
// ownedPolicyFile resolves the target and enforces ownership, returning the
// fs file for metadata access.
func ownedPolicyFile(c *gin.Context, dep dependency.Dep, uriRaw string) (*dbfs.File, error) {
user := inventory.UserFromContext(c)
uri, err := fs.NewUriFromString(uriRaw)
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
}
m := manager.NewFileManager(dep, user)
defer m.Recycle()
f, err := m.Get(c, uri, dbfs.WithFileEntities())
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
file, ok := f.(*dbfs.File)
if !ok {
return nil, serializer.NewError(serializer.CodeParamErr, "Unsupported file system", nil)
}
if file.OwnerID() != user.ID {
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "Only the owner can manage storage policies", nil)
}
return file, nil
}
func (s *PreferredPolicyService) Update(c *gin.Context) (*StoragePolicyBrief, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
file, err := ownedPolicyFile(c, dep, s.Uri)
if err != nil {
return nil, err
}
if file.Type() != types.FileTypeFolder {
return nil, serializer.NewError(serializer.CodeParamErr, "Preferred storage policy applies to folders only", nil)
}
var policy *ent.StoragePolicy
if s.Policy != "" {
policy, err = decodeAllowedPolicy(c, dep, user.Edges.Group, s.Policy)
if err != nil {
return nil, err
}
}
fc := dep.FileClient()
if policy == nil {
err = fc.RemoveMetadata(c, file.Model, dbfs.MetadataPreferredPolicy)
} else {
err = fc.UpsertMetadata(c, file.Model, map[string]string{
dbfs.MetadataPreferredPolicy: hashid.EncodePolicyID(dep.HashIDEncoder(), policy.ID),
}, map[string]bool{dbfs.MetadataPreferredPolicy: true})
}
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update metadata", err)
}
if policy == nil {
return nil, nil
}
return &StoragePolicyBrief{
ID: hashid.EncodePolicyID(dep.HashIDEncoder(), policy.ID),
Name: policy.Name,
Type: policy.Type,
}, nil
}
func (s *FileRelocateService) Create(c *gin.Context) (*FileRelocateResponse, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
policy, err := decodeAllowedPolicy(c, dep, user.Edges.Group, s.Policy)
if err != nil {
return nil, err
}
// A load-balance target resolves to one concrete child at enqueue time.
if policy.Type == types.PolicyTypeLoadBalance {
policy, err = dep.StoragePolicyClient().ResolveLoadBalance(c, policy)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to resolve load-balanced storage policy", err)
}
}
file, err := ownedPolicyFile(c, dep, s.Uri)
if err != nil {
return nil, err
}
// Depth is a countdown in the walk implementation; a large value walks
// the whole subtree. Entities already on the destination are skipped by
// the task, so they are filtered out of the request entirely.
pending := lo.Filter(file.Model.Edges.Entities, func(e *ent.Entity, _ int) bool {
return e.StoragePolicyEntities != policy.ID
})
entityIDs := lo.Map(pending, func(e *ent.Entity, _ int) int { return e.ID })
if file.Type() == types.FileTypeFolder {
uri, _ := fs.NewUriFromString(s.Uri)
m := manager.NewFileManager(dep, user)
defer m.Recycle()
err = m.Walk(c, uri, 1<<30, func(f fs.File, _ int) error {
dbFile, ok := f.(*dbfs.File)
if !ok {
return nil
}
for _, e := range dbFile.Model.Edges.Entities {
if e.StoragePolicyEntities != policy.ID {
entityIDs = append(entityIDs, e.ID)
}
}
return nil
}, dbfs.WithFileEntities())
if err != nil {
return nil, fmt.Errorf("failed to walk folder: %w", err)
}
}
entityIDs = lo.Uniq(entityIDs)
if len(entityIDs) == 0 {
return nil, serializer.NewError(serializer.CodeParamErr, "Nothing to relocate", nil)
}
t, err := workflows.NewRelocateTask(c, entityIDs, policy.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create relocation task", err)
}
if err := dep.IoIntenseQueue(c).QueueTask(c, t); err != nil {
return nil, fmt.Errorf("failed to submit task: %w", err)
}
return &FileRelocateResponse{ID: hashid.EncodeTaskID(dep.HashIDEncoder(), t.ID())}, nil
}

@ -0,0 +1,78 @@
package explorer
import (
"context"
"net/http/httptest"
"testing"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// policyDepStub exposes only the dependencies decodeAllowedPolicy touches.
type policyDepStub struct {
dependency.Dep
policyClient inventory.StoragePolicyClient
hasher hashid.Encoder
}
func (d *policyDepStub) StoragePolicyClient() inventory.StoragePolicyClient { return d.policyClient }
func (d *policyDepStub) HashIDEncoder() hashid.Encoder { return d.hasher }
func TestDecodeAllowedPolicy(t *testing.T) {
gin.SetMode(gin.TestMode)
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
mk := func(name string) *ent.StoragePolicy {
return client.StoragePolicy.Create().SetName(name).SetType("local").
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
}
def := mk("def")
a := mk("a")
outside := mk("outside")
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).
SetStoragePolicies(def).AddAllowedPolicies(a).SaveX(ctx)
hasher, err := hashid.New("policy-test-salt")
require.NoError(t, err)
dep := &policyDepStub{policyClient: inventory.NewStoragePolicyClient(client, nil), hasher: hasher}
engine := gin.New()
engine.ContextWithFallback = true
c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
c.Request = httptest.NewRequest("GET", "/", nil)
util.WithValue(c, dependency.DepCtx{}, dep)
t.Run("allowed policy decodes", func(t *testing.T) {
got, err := decodeAllowedPolicy(c, dep, group, hashid.EncodePolicyID(hasher, a.ID))
require.NoError(t, err)
require.Equal(t, a.ID, got.ID)
})
t.Run("legacy default policy decodes", func(t *testing.T) {
got, err := decodeAllowedPolicy(c, dep, group, hashid.EncodePolicyID(hasher, def.ID))
require.NoError(t, err)
require.Equal(t, def.ID, got.ID)
})
t.Run("policy outside the group set is rejected", func(t *testing.T) {
_, err := decodeAllowedPolicy(c, dep, group, hashid.EncodePolicyID(hasher, outside.ID))
require.Error(t, err)
})
t.Run("invalid hashid is rejected", func(t *testing.T) {
_, err := decodeAllowedPolicy(c, dep, group, "!!!")
require.Error(t, err)
})
}

@ -35,6 +35,7 @@ type UserSettings struct {
ShareDefaultPrivate *bool `json:"share_default_private,omitempty"`
PreferredViewers map[string]string `json:"preferred_viewers,omitempty"`
TrashRetention int `json:"trash_retention,omitempty"`
PreferredPolicy string `json:"preferred_policy,omitempty"`
OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"`
}

@ -25,6 +25,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
"github.com/samber/lo"
)
const (
@ -138,7 +139,11 @@ func GetUserSettings(c *gin.Context) (*UserSettings, error) {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user OAuth grants", err)
}
return BuildUserSettings(u, passkeys, dep.UAParser(), grants), nil
res := BuildUserSettings(u, passkeys, dep.UAParser(), grants)
if u.Settings.PreferredPolicy > 0 {
res.PreferredPolicy = hashid.EncodePolicyID(dep.HashIDEncoder(), u.Settings.PreferredPolicy)
}
return res, nil
// 用户组有效期
@ -239,6 +244,9 @@ type (
// TrashRetention overrides group trash retention, in seconds.
// 0 clears the override. Capped at ~10 years.
TrashRetention *int `json:"trash_retention" binding:"omitempty,min=0,max=315360000"`
// PreferredPolicy selects the user's default storage policy from the
// group's allowed set, hashid-encoded. "" clears the preference.
PreferredPolicy *string `json:"preferred_policy" binding:"omitempty"`
}
PatchUserSettingParamsCtx struct{}
)
@ -342,6 +350,26 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error {
saveSetting = true
}
if s.PreferredPolicy != nil {
if *s.PreferredPolicy == "" {
u.Settings.PreferredPolicy = 0
} else {
pid, err := dep.HashIDEncoder().Decode(*s.PreferredPolicy, hashid.PolicyID)
if err != nil {
return serializer.NewError(serializer.CodeParamErr, "Invalid storage policy", err)
}
allowed, err := dep.StoragePolicyClient().ListByGroup(c, u.Edges.Group)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to list storage policies", err)
}
if !lo.ContainsBy(allowed, func(p *ent.StoragePolicy) bool { return p.ID == pid }) {
return serializer.NewError(serializer.CodeNoPermissionErr, "Storage policy is not available for your group", nil)
}
u.Settings.PreferredPolicy = pid
}
saveSetting = true
}
if s.CurrentPassword != nil && s.NewPassword != nil {
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
return err

Loading…
Cancel
Save