feat: multi-file shares — one link covering N files

Share.files M:N edge joins every linked file (anchor file edge kept for
IsValidShare, ACL anchoring and paid-share gates). The share navigator
exposes a synthetic folder root listing the union of linked files with
each file's real parent chain rebuilt for correct source URIs; deleted
linked files drop out without invalidating the share. Upload-only is
rejected for multi shares and write capabilities are stripped at the
union root. Create API accepts uris[] (max 50, deduped); the share
dialog opens from multi-selection; share info exposes file_count.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3587/head
Tomas Dvorak 2 weeks ago
parent cf688634c4
commit df547db766

@ -175,7 +175,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] Default shares — `setting.default_symbolics` + group `default_pinned` chip-input of share IDs; materialize as share-shortcut entries on fs init (#180) - [x] Default shares — `setting.default_symbolics` + group `default_pinned` chip-input of share IDs; materialize as share-shortcut entries on fs init (#180)
- [x] Paid shares — `share.price_points` + `share_purchase` (buyer debit → owner income at `share_score_rate`) + resume ticket; download/thumb gated in share navigator, listing stays visible; `share_sell` (bit 29) gates price-setting, `share_free` (bit 8) bypasses paywall; `PaidShareGate` UI + restore via `purchase_ticket` - [x] Paid shares — `share.price_points` + `share_purchase` (buyer debit → owner income at `share_score_rate`) + resume ticket; download/thumb gated in share navigator, listing stays visible; `share_sell` (bit 29) gates price-setting, `share_free` (bit 8) bypasses paywall; `PaidShareGate` UI + restore via `purchase_ticket`
- [x] Hash dedup / instant upload — `entity.hash` + `(hash,size)` index; `FindEntityByHash` (completed version entities, owner scope default / `global` opt-in); `PrepareUpload` links existing entity (refcount++) → `rapid_uploaded` session, no transfer; media-meta/FTS queued per file; frontend streams SHA-256 via `hash-wasm`, skips encrypted policies; `upload_dedup_scope` admin setting (#191, fixes #3044) - [x] Hash dedup / instant upload — `entity.hash` + `(hash,size)` index; `FindEntityByHash` (completed version entities, owner scope default / `global` opt-in); `PrepareUpload` links existing entity (refcount++) → `rapid_uploaded` session, no transfer; media-meta/FTS queued per file; frontend streams SHA-256 via `hash-wasm`, skips encrypted policies; `upload_dedup_scope` admin setting (#191, fixes #3044)
- [ ] Multi-file share — one share covering N selected files (upstream #3032): share↔files M:N, navigator union, share page listing, create dialog multi-pick - [x] Multi-file share — one share covering N selected files (upstream #3032): share.files M:N join (anchor file edge preserved for validity/ACL/paid gate); synthetic union root in share navigator with per-file real parent chains; dead linked files drop out of listing; upload-only + write caps stripped at union root; uris[] create API (max 50, dedup); multi-select Share dialog; file_count + anchor source_uri in share info
2. **Storage policy advanced** — multiple policies per group, per-directory binding, load-balancer policy, file migration (fixes #3518, #2961, #2262). See §1.3a. 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) - [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)
- [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] 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)

@ -1692,6 +1692,22 @@ func (c *FileClient) QueryShares(f *File) *ShareQuery {
return query return query
} }
// QueryMultiShares queries the multi_shares edge of a File.
func (c *FileClient) QueryMultiShares(f *File) *ShareQuery {
query := (&ShareClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := f.ID
step := sqlgraph.NewStep(
sqlgraph.From(file.Table, file.FieldID, id),
sqlgraph.To(share.Table, share.FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, file.MultiSharesTable, file.MultiSharesPrimaryKey...),
)
fromV = sqlgraph.Neighbors(f.driver.Dialect(), step)
return fromV, nil
}
return query
}
// QueryACLEntries queries the acl_entries edge of a File. // QueryACLEntries queries the acl_entries edge of a File.
func (c *FileClient) QueryACLEntries(f *File) *AclEntryQuery { func (c *FileClient) QueryACLEntries(f *File) *AclEntryQuery {
query := (&AclEntryClient{config: c.config}).Query() query := (&AclEntryClient{config: c.config}).Query()
@ -3416,6 +3432,22 @@ func (c *ShareClient) QueryFile(s *Share) *FileQuery {
return query return query
} }
// QueryFiles queries the files edge of a Share.
func (c *ShareClient) QueryFiles(s *Share) *FileQuery {
query := (&FileClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := s.ID
step := sqlgraph.NewStep(
sqlgraph.From(share.Table, share.FieldID, id),
sqlgraph.To(file.Table, file.FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, share.FilesTable, share.FilesPrimaryKey...),
)
fromV = sqlgraph.Neighbors(s.driver.Dialect(), step)
return fromV, nil
}
return query
}
// QueryPurchases queries the purchases edge of a Share. // QueryPurchases queries the purchases edge of a Share.
func (c *ShareClient) QueryPurchases(s *Share) *SharePurchaseQuery { func (c *ShareClient) QueryPurchases(s *Share) *SharePurchaseQuery {
query := (&SharePurchaseClient{config: c.config}).Query() query := (&SharePurchaseClient{config: c.config}).Query()

@ -65,13 +65,15 @@ type FileEdges struct {
Entities []*Entity `json:"entities,omitempty"` Entities []*Entity `json:"entities,omitempty"`
// Shares holds the value of the shares edge. // Shares holds the value of the shares edge.
Shares []*Share `json:"shares,omitempty"` Shares []*Share `json:"shares,omitempty"`
// MultiShares holds the value of the multi_shares edge.
MultiShares []*Share `json:"multi_shares,omitempty"`
// ACLEntries holds the value of the acl_entries edge. // ACLEntries holds the value of the acl_entries edge.
ACLEntries []*AclEntry `json:"acl_entries,omitempty"` ACLEntries []*AclEntry `json:"acl_entries,omitempty"`
// DirectLinks holds the value of the direct_links edge. // DirectLinks holds the value of the direct_links edge.
DirectLinks []*DirectLink `json:"direct_links,omitempty"` DirectLinks []*DirectLink `json:"direct_links,omitempty"`
// loadedTypes holds the information for reporting if a // loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not. // type was loaded (or requested) in eager-loading or not.
loadedTypes [9]bool loadedTypes [10]bool
} }
// OwnerOrErr returns the Owner value or an error if the edge // OwnerOrErr returns the Owner value or an error if the edge
@ -149,10 +151,19 @@ func (e FileEdges) SharesOrErr() ([]*Share, error) {
return nil, &NotLoadedError{edge: "shares"} return nil, &NotLoadedError{edge: "shares"}
} }
// MultiSharesOrErr returns the MultiShares value or an error if the edge
// was not loaded in eager-loading.
func (e FileEdges) MultiSharesOrErr() ([]*Share, error) {
if e.loadedTypes[7] {
return e.MultiShares, nil
}
return nil, &NotLoadedError{edge: "multi_shares"}
}
// ACLEntriesOrErr returns the ACLEntries value or an error if the edge // ACLEntriesOrErr returns the ACLEntries value or an error if the edge
// was not loaded in eager-loading. // was not loaded in eager-loading.
func (e FileEdges) ACLEntriesOrErr() ([]*AclEntry, error) { func (e FileEdges) ACLEntriesOrErr() ([]*AclEntry, error) {
if e.loadedTypes[7] { if e.loadedTypes[8] {
return e.ACLEntries, nil return e.ACLEntries, nil
} }
return nil, &NotLoadedError{edge: "acl_entries"} return nil, &NotLoadedError{edge: "acl_entries"}
@ -161,7 +172,7 @@ func (e FileEdges) ACLEntriesOrErr() ([]*AclEntry, error) {
// DirectLinksOrErr returns the DirectLinks value or an error if the edge // DirectLinksOrErr returns the DirectLinks value or an error if the edge
// was not loaded in eager-loading. // was not loaded in eager-loading.
func (e FileEdges) DirectLinksOrErr() ([]*DirectLink, error) { func (e FileEdges) DirectLinksOrErr() ([]*DirectLink, error) {
if e.loadedTypes[8] { if e.loadedTypes[9] {
return e.DirectLinks, nil return e.DirectLinks, nil
} }
return nil, &NotLoadedError{edge: "direct_links"} return nil, &NotLoadedError{edge: "direct_links"}
@ -319,6 +330,11 @@ func (f *File) QueryShares() *ShareQuery {
return NewFileClient(f.config).QueryShares(f) return NewFileClient(f.config).QueryShares(f)
} }
// QueryMultiShares queries the "multi_shares" edge of the File entity.
func (f *File) QueryMultiShares() *ShareQuery {
return NewFileClient(f.config).QueryMultiShares(f)
}
// QueryACLEntries queries the "acl_entries" edge of the File entity. // QueryACLEntries queries the "acl_entries" edge of the File entity.
func (f *File) QueryACLEntries() *AclEntryQuery { func (f *File) QueryACLEntries() *AclEntryQuery {
return NewFileClient(f.config).QueryACLEntries(f) return NewFileClient(f.config).QueryACLEntries(f)
@ -430,16 +446,22 @@ func (e *File) SetShares(v []*Share) {
e.Edges.loadedTypes[6] = true e.Edges.loadedTypes[6] = true
} }
// SetMultiShares manually set the edge as loaded state.
func (e *File) SetMultiShares(v []*Share) {
e.Edges.MultiShares = v
e.Edges.loadedTypes[7] = true
}
// SetACLEntries manually set the edge as loaded state. // SetACLEntries manually set the edge as loaded state.
func (e *File) SetACLEntries(v []*AclEntry) { func (e *File) SetACLEntries(v []*AclEntry) {
e.Edges.ACLEntries = v e.Edges.ACLEntries = v
e.Edges.loadedTypes[7] = true e.Edges.loadedTypes[8] = true
} }
// SetDirectLinks manually set the edge as loaded state. // SetDirectLinks manually set the edge as loaded state.
func (e *File) SetDirectLinks(v []*DirectLink) { func (e *File) SetDirectLinks(v []*DirectLink) {
e.Edges.DirectLinks = v e.Edges.DirectLinks = v
e.Edges.loadedTypes[8] = true e.Edges.loadedTypes[9] = true
} }
// Files is a parsable slice of File. // Files is a parsable slice of File.

@ -51,6 +51,8 @@ const (
EdgeEntities = "entities" EdgeEntities = "entities"
// EdgeShares holds the string denoting the shares edge name in mutations. // EdgeShares holds the string denoting the shares edge name in mutations.
EdgeShares = "shares" EdgeShares = "shares"
// EdgeMultiShares holds the string denoting the multi_shares edge name in mutations.
EdgeMultiShares = "multi_shares"
// EdgeACLEntries holds the string denoting the acl_entries edge name in mutations. // EdgeACLEntries holds the string denoting the acl_entries edge name in mutations.
EdgeACLEntries = "acl_entries" EdgeACLEntries = "acl_entries"
// EdgeDirectLinks holds the string denoting the direct_links edge name in mutations. // EdgeDirectLinks holds the string denoting the direct_links edge name in mutations.
@ -98,6 +100,11 @@ const (
SharesInverseTable = "shares" SharesInverseTable = "shares"
// SharesColumn is the table column denoting the shares relation/edge. // SharesColumn is the table column denoting the shares relation/edge.
SharesColumn = "file_shares" SharesColumn = "file_shares"
// MultiSharesTable is the table that holds the multi_shares relation/edge. The primary key declared below.
MultiSharesTable = "share_files"
// MultiSharesInverseTable is the table name for the Share entity.
// It exists in this package in order to avoid circular dependency with the "share" package.
MultiSharesInverseTable = "shares"
// ACLEntriesTable is the table that holds the acl_entries relation/edge. // ACLEntriesTable is the table that holds the acl_entries relation/edge.
ACLEntriesTable = "acl_entries" ACLEntriesTable = "acl_entries"
// ACLEntriesInverseTable is the table name for the AclEntry entity. // ACLEntriesInverseTable is the table name for the AclEntry entity.
@ -134,6 +141,9 @@ var (
// EntitiesPrimaryKey and EntitiesColumn2 are the table columns denoting the // EntitiesPrimaryKey and EntitiesColumn2 are the table columns denoting the
// primary key for the entities relation (M2M). // primary key for the entities relation (M2M).
EntitiesPrimaryKey = []string{"file_id", "entity_id"} EntitiesPrimaryKey = []string{"file_id", "entity_id"}
// MultiSharesPrimaryKey and MultiSharesColumn2 are the table columns denoting the
// primary key for the multi_shares relation (M2M).
MultiSharesPrimaryKey = []string{"share_id", "file_id"}
) )
// ValidColumn reports if the column name is valid (part of the table columns). // ValidColumn reports if the column name is valid (part of the table columns).
@ -298,6 +308,20 @@ func ByShares(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
} }
} }
// ByMultiSharesCount orders the results by multi_shares count.
func ByMultiSharesCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborsCount(s, newMultiSharesStep(), opts...)
}
}
// ByMultiShares orders the results by multi_shares terms.
func ByMultiShares(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newMultiSharesStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
// ByACLEntriesCount orders the results by acl_entries count. // ByACLEntriesCount orders the results by acl_entries count.
func ByACLEntriesCount(opts ...sql.OrderTermOption) OrderOption { func ByACLEntriesCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) { return func(s *sql.Selector) {
@ -374,6 +398,13 @@ func newSharesStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.O2M, false, SharesTable, SharesColumn), sqlgraph.Edge(sqlgraph.O2M, false, SharesTable, SharesColumn),
) )
} }
func newMultiSharesStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(MultiSharesInverseTable, FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, MultiSharesTable, MultiSharesPrimaryKey...),
)
}
func newACLEntriesStep() *sqlgraph.Step { func newACLEntriesStep() *sqlgraph.Step {
return sqlgraph.NewStep( return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID), sqlgraph.From(Table, FieldID),

@ -641,6 +641,29 @@ func HasSharesWith(preds ...predicate.Share) predicate.File {
}) })
} }
// HasMultiShares applies the HasEdge predicate on the "multi_shares" edge.
func HasMultiShares() predicate.File {
return predicate.File(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, MultiSharesTable, MultiSharesPrimaryKey...),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasMultiSharesWith applies the HasEdge predicate on the "multi_shares" edge with a given conditions (other predicates).
func HasMultiSharesWith(preds ...predicate.Share) predicate.File {
return predicate.File(func(s *sql.Selector) {
step := newMultiSharesStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// HasACLEntries applies the HasEdge predicate on the "acl_entries" edge. // HasACLEntries applies the HasEdge predicate on the "acl_entries" edge.
func HasACLEntries() predicate.File { func HasACLEntries() predicate.File {
return predicate.File(func(s *sql.Selector) { return predicate.File(func(s *sql.Selector) {

@ -255,6 +255,21 @@ func (fc *FileCreate) AddShares(s ...*Share) *FileCreate {
return fc.AddShareIDs(ids...) return fc.AddShareIDs(ids...)
} }
// AddMultiShareIDs adds the "multi_shares" edge to the Share entity by IDs.
func (fc *FileCreate) AddMultiShareIDs(ids ...int) *FileCreate {
fc.mutation.AddMultiShareIDs(ids...)
return fc
}
// AddMultiShares adds the "multi_shares" edges to the Share entity.
func (fc *FileCreate) AddMultiShares(s ...*Share) *FileCreate {
ids := make([]int, len(s))
for i := range s {
ids[i] = s[i].ID
}
return fc.AddMultiShareIDs(ids...)
}
// AddACLEntryIDs adds the "acl_entries" edge to the AclEntry entity by IDs. // AddACLEntryIDs adds the "acl_entries" edge to the AclEntry entity by IDs.
func (fc *FileCreate) AddACLEntryIDs(ids ...int) *FileCreate { func (fc *FileCreate) AddACLEntryIDs(ids ...int) *FileCreate {
fc.mutation.AddACLEntryIDs(ids...) fc.mutation.AddACLEntryIDs(ids...)
@ -554,6 +569,22 @@ func (fc *FileCreate) createSpec() (*File, *sqlgraph.CreateSpec) {
} }
_spec.Edges = append(_spec.Edges, edge) _spec.Edges = append(_spec.Edges, edge)
} }
if nodes := fc.mutation.MultiSharesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: file.MultiSharesTable,
Columns: file.MultiSharesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(share.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := fc.mutation.ACLEntriesIDs(); len(nodes) > 0 { if nodes := fc.mutation.ACLEntriesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M, Rel: sqlgraph.O2M,

@ -36,6 +36,7 @@ type FileQuery struct {
withMetadata *MetadataQuery withMetadata *MetadataQuery
withEntities *EntityQuery withEntities *EntityQuery
withShares *ShareQuery withShares *ShareQuery
withMultiShares *ShareQuery
withACLEntries *AclEntryQuery withACLEntries *AclEntryQuery
withDirectLinks *DirectLinkQuery withDirectLinks *DirectLinkQuery
// intermediate query (i.e. traversal path). // intermediate query (i.e. traversal path).
@ -228,6 +229,28 @@ func (fq *FileQuery) QueryShares() *ShareQuery {
return query return query
} }
// QueryMultiShares chains the current query on the "multi_shares" edge.
func (fq *FileQuery) QueryMultiShares() *ShareQuery {
query := (&ShareClient{config: fq.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := fq.prepareQuery(ctx); err != nil {
return nil, err
}
selector := fq.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(file.Table, file.FieldID, selector),
sqlgraph.To(share.Table, share.FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, file.MultiSharesTable, file.MultiSharesPrimaryKey...),
)
fromU = sqlgraph.SetNeighbors(fq.driver.Dialect(), step)
return fromU, nil
}
return query
}
// QueryACLEntries chains the current query on the "acl_entries" edge. // QueryACLEntries chains the current query on the "acl_entries" edge.
func (fq *FileQuery) QueryACLEntries() *AclEntryQuery { func (fq *FileQuery) QueryACLEntries() *AclEntryQuery {
query := (&AclEntryClient{config: fq.config}).Query() query := (&AclEntryClient{config: fq.config}).Query()
@ -471,6 +494,7 @@ func (fq *FileQuery) Clone() *FileQuery {
withMetadata: fq.withMetadata.Clone(), withMetadata: fq.withMetadata.Clone(),
withEntities: fq.withEntities.Clone(), withEntities: fq.withEntities.Clone(),
withShares: fq.withShares.Clone(), withShares: fq.withShares.Clone(),
withMultiShares: fq.withMultiShares.Clone(),
withACLEntries: fq.withACLEntries.Clone(), withACLEntries: fq.withACLEntries.Clone(),
withDirectLinks: fq.withDirectLinks.Clone(), withDirectLinks: fq.withDirectLinks.Clone(),
// clone intermediate query. // clone intermediate query.
@ -556,6 +580,17 @@ func (fq *FileQuery) WithShares(opts ...func(*ShareQuery)) *FileQuery {
return fq return fq
} }
// WithMultiShares tells the query-builder to eager-load the nodes that are connected to
// the "multi_shares" edge. The optional arguments are used to configure the query builder of the edge.
func (fq *FileQuery) WithMultiShares(opts ...func(*ShareQuery)) *FileQuery {
query := (&ShareClient{config: fq.config}).Query()
for _, opt := range opts {
opt(query)
}
fq.withMultiShares = query
return fq
}
// WithACLEntries tells the query-builder to eager-load the nodes that are connected to // WithACLEntries tells the query-builder to eager-load the nodes that are connected to
// the "acl_entries" edge. The optional arguments are used to configure the query builder of the edge. // the "acl_entries" edge. The optional arguments are used to configure the query builder of the edge.
func (fq *FileQuery) WithACLEntries(opts ...func(*AclEntryQuery)) *FileQuery { func (fq *FileQuery) WithACLEntries(opts ...func(*AclEntryQuery)) *FileQuery {
@ -656,7 +691,7 @@ func (fq *FileQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*File, e
var ( var (
nodes = []*File{} nodes = []*File{}
_spec = fq.querySpec() _spec = fq.querySpec()
loadedTypes = [9]bool{ loadedTypes = [10]bool{
fq.withOwner != nil, fq.withOwner != nil,
fq.withStoragePolicies != nil, fq.withStoragePolicies != nil,
fq.withParent != nil, fq.withParent != nil,
@ -664,6 +699,7 @@ func (fq *FileQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*File, e
fq.withMetadata != nil, fq.withMetadata != nil,
fq.withEntities != nil, fq.withEntities != nil,
fq.withShares != nil, fq.withShares != nil,
fq.withMultiShares != nil,
fq.withACLEntries != nil, fq.withACLEntries != nil,
fq.withDirectLinks != nil, fq.withDirectLinks != nil,
} }
@ -732,6 +768,13 @@ func (fq *FileQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*File, e
return nil, err return nil, err
} }
} }
if query := fq.withMultiShares; query != nil {
if err := fq.loadMultiShares(ctx, query, nodes,
func(n *File) { n.Edges.MultiShares = []*Share{} },
func(n *File, e *Share) { n.Edges.MultiShares = append(n.Edges.MultiShares, e) }); err != nil {
return nil, err
}
}
if query := fq.withACLEntries; query != nil { if query := fq.withACLEntries; query != nil {
if err := fq.loadACLEntries(ctx, query, nodes, if err := fq.loadACLEntries(ctx, query, nodes,
func(n *File) { n.Edges.ACLEntries = []*AclEntry{} }, func(n *File) { n.Edges.ACLEntries = []*AclEntry{} },
@ -988,6 +1031,67 @@ func (fq *FileQuery) loadShares(ctx context.Context, query *ShareQuery, nodes []
} }
return nil return nil
} }
func (fq *FileQuery) loadMultiShares(ctx context.Context, query *ShareQuery, nodes []*File, init func(*File), assign func(*File, *Share)) error {
edgeIDs := make([]driver.Value, len(nodes))
byID := make(map[int]*File)
nids := make(map[int]map[*File]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(file.MultiSharesTable)
s.Join(joinT).On(s.C(share.FieldID), joinT.C(file.MultiSharesPrimaryKey[0]))
s.Where(sql.InValues(joinT.C(file.MultiSharesPrimaryKey[1]), edgeIDs...))
columns := s.SelectedColumns()
s.Select(joinT.C(file.MultiSharesPrimaryKey[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[*File]struct{}{byID[outValue]: {}}
return assign(columns[1:], values[1:])
}
nids[inValue][byID[outValue]] = struct{}{}
return nil
}
})
})
neighbors, err := withInterceptors[[]*Share](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 "multi_shares" node returned %v`, n.ID)
}
for kn := range nodes {
assign(kn, n)
}
}
return nil
}
func (fq *FileQuery) loadACLEntries(ctx context.Context, query *AclEntryQuery, nodes []*File, init func(*File), assign func(*File, *AclEntry)) error { func (fq *FileQuery) loadACLEntries(ctx context.Context, query *AclEntryQuery, nodes []*File, init func(*File), assign func(*File, *AclEntry)) error {
fks := make([]driver.Value, 0, len(nodes)) fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int]*File) nodeids := make(map[int]*File)

@ -316,6 +316,21 @@ func (fu *FileUpdate) AddShares(s ...*Share) *FileUpdate {
return fu.AddShareIDs(ids...) return fu.AddShareIDs(ids...)
} }
// AddMultiShareIDs adds the "multi_shares" edge to the Share entity by IDs.
func (fu *FileUpdate) AddMultiShareIDs(ids ...int) *FileUpdate {
fu.mutation.AddMultiShareIDs(ids...)
return fu
}
// AddMultiShares adds the "multi_shares" edges to the Share entity.
func (fu *FileUpdate) AddMultiShares(s ...*Share) *FileUpdate {
ids := make([]int, len(s))
for i := range s {
ids[i] = s[i].ID
}
return fu.AddMultiShareIDs(ids...)
}
// AddACLEntryIDs adds the "acl_entries" edge to the AclEntry entity by IDs. // AddACLEntryIDs adds the "acl_entries" edge to the AclEntry entity by IDs.
func (fu *FileUpdate) AddACLEntryIDs(ids ...int) *FileUpdate { func (fu *FileUpdate) AddACLEntryIDs(ids ...int) *FileUpdate {
fu.mutation.AddACLEntryIDs(ids...) fu.mutation.AddACLEntryIDs(ids...)
@ -453,6 +468,27 @@ func (fu *FileUpdate) RemoveShares(s ...*Share) *FileUpdate {
return fu.RemoveShareIDs(ids...) return fu.RemoveShareIDs(ids...)
} }
// ClearMultiShares clears all "multi_shares" edges to the Share entity.
func (fu *FileUpdate) ClearMultiShares() *FileUpdate {
fu.mutation.ClearMultiShares()
return fu
}
// RemoveMultiShareIDs removes the "multi_shares" edge to Share entities by IDs.
func (fu *FileUpdate) RemoveMultiShareIDs(ids ...int) *FileUpdate {
fu.mutation.RemoveMultiShareIDs(ids...)
return fu
}
// RemoveMultiShares removes "multi_shares" edges to Share entities.
func (fu *FileUpdate) RemoveMultiShares(s ...*Share) *FileUpdate {
ids := make([]int, len(s))
for i := range s {
ids[i] = s[i].ID
}
return fu.RemoveMultiShareIDs(ids...)
}
// ClearACLEntries clears all "acl_entries" edges to the AclEntry entity. // ClearACLEntries clears all "acl_entries" edges to the AclEntry entity.
func (fu *FileUpdate) ClearACLEntries() *FileUpdate { func (fu *FileUpdate) ClearACLEntries() *FileUpdate {
fu.mutation.ClearACLEntries() fu.mutation.ClearACLEntries()
@ -845,6 +881,51 @@ func (fu *FileUpdate) sqlSave(ctx context.Context) (n int, err error) {
} }
_spec.Edges.Add = append(_spec.Edges.Add, edge) _spec.Edges.Add = append(_spec.Edges.Add, edge)
} }
if fu.mutation.MultiSharesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: file.MultiSharesTable,
Columns: file.MultiSharesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(share.FieldID, field.TypeInt),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := fu.mutation.RemovedMultiSharesIDs(); len(nodes) > 0 && !fu.mutation.MultiSharesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: file.MultiSharesTable,
Columns: file.MultiSharesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(share.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 := fu.mutation.MultiSharesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: file.MultiSharesTable,
Columns: file.MultiSharesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(share.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if fu.mutation.ACLEntriesCleared() { if fu.mutation.ACLEntriesCleared() {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M, Rel: sqlgraph.O2M,
@ -1235,6 +1316,21 @@ func (fuo *FileUpdateOne) AddShares(s ...*Share) *FileUpdateOne {
return fuo.AddShareIDs(ids...) return fuo.AddShareIDs(ids...)
} }
// AddMultiShareIDs adds the "multi_shares" edge to the Share entity by IDs.
func (fuo *FileUpdateOne) AddMultiShareIDs(ids ...int) *FileUpdateOne {
fuo.mutation.AddMultiShareIDs(ids...)
return fuo
}
// AddMultiShares adds the "multi_shares" edges to the Share entity.
func (fuo *FileUpdateOne) AddMultiShares(s ...*Share) *FileUpdateOne {
ids := make([]int, len(s))
for i := range s {
ids[i] = s[i].ID
}
return fuo.AddMultiShareIDs(ids...)
}
// AddACLEntryIDs adds the "acl_entries" edge to the AclEntry entity by IDs. // AddACLEntryIDs adds the "acl_entries" edge to the AclEntry entity by IDs.
func (fuo *FileUpdateOne) AddACLEntryIDs(ids ...int) *FileUpdateOne { func (fuo *FileUpdateOne) AddACLEntryIDs(ids ...int) *FileUpdateOne {
fuo.mutation.AddACLEntryIDs(ids...) fuo.mutation.AddACLEntryIDs(ids...)
@ -1372,6 +1468,27 @@ func (fuo *FileUpdateOne) RemoveShares(s ...*Share) *FileUpdateOne {
return fuo.RemoveShareIDs(ids...) return fuo.RemoveShareIDs(ids...)
} }
// ClearMultiShares clears all "multi_shares" edges to the Share entity.
func (fuo *FileUpdateOne) ClearMultiShares() *FileUpdateOne {
fuo.mutation.ClearMultiShares()
return fuo
}
// RemoveMultiShareIDs removes the "multi_shares" edge to Share entities by IDs.
func (fuo *FileUpdateOne) RemoveMultiShareIDs(ids ...int) *FileUpdateOne {
fuo.mutation.RemoveMultiShareIDs(ids...)
return fuo
}
// RemoveMultiShares removes "multi_shares" edges to Share entities.
func (fuo *FileUpdateOne) RemoveMultiShares(s ...*Share) *FileUpdateOne {
ids := make([]int, len(s))
for i := range s {
ids[i] = s[i].ID
}
return fuo.RemoveMultiShareIDs(ids...)
}
// ClearACLEntries clears all "acl_entries" edges to the AclEntry entity. // ClearACLEntries clears all "acl_entries" edges to the AclEntry entity.
func (fuo *FileUpdateOne) ClearACLEntries() *FileUpdateOne { func (fuo *FileUpdateOne) ClearACLEntries() *FileUpdateOne {
fuo.mutation.ClearACLEntries() fuo.mutation.ClearACLEntries()
@ -1794,6 +1911,51 @@ func (fuo *FileUpdateOne) sqlSave(ctx context.Context) (_node *File, err error)
} }
_spec.Edges.Add = append(_spec.Edges.Add, edge) _spec.Edges.Add = append(_spec.Edges.Add, edge)
} }
if fuo.mutation.MultiSharesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: file.MultiSharesTable,
Columns: file.MultiSharesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(share.FieldID, field.TypeInt),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := fuo.mutation.RemovedMultiSharesIDs(); len(nodes) > 0 && !fuo.mutation.MultiSharesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: file.MultiSharesTable,
Columns: file.MultiSharesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(share.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 := fuo.mutation.MultiSharesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Table: file.MultiSharesTable,
Columns: file.MultiSharesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(share.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if fuo.mutation.ACLEntriesCleared() { if fuo.mutation.ACLEntriesCleared() {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M, Rel: sqlgraph.O2M,

File diff suppressed because one or more lines are too long

@ -893,6 +893,31 @@ var (
}, },
}, },
} }
// ShareFilesColumns holds the columns for the "share_files" table.
ShareFilesColumns = []*schema.Column{
{Name: "share_id", Type: field.TypeInt},
{Name: "file_id", Type: field.TypeInt},
}
// ShareFilesTable holds the schema information for the "share_files" table.
ShareFilesTable = &schema.Table{
Name: "share_files",
Columns: ShareFilesColumns,
PrimaryKey: []*schema.Column{ShareFilesColumns[0], ShareFilesColumns[1]},
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "share_files_share_id",
Columns: []*schema.Column{ShareFilesColumns[0]},
RefColumns: []*schema.Column{SharesColumns[0]},
OnDelete: schema.Cascade,
},
{
Symbol: "share_files_file_id",
Columns: []*schema.Column{ShareFilesColumns[1]},
RefColumns: []*schema.Column{FilesColumns[0]},
OnDelete: schema.Cascade,
},
},
}
// Tables holds all the tables in the schema. // Tables holds all the tables in the schema.
Tables = []*schema.Table{ Tables = []*schema.Table{
AbuseReportsTable, AbuseReportsTable,
@ -923,6 +948,7 @@ var (
UserGrantsTable, UserGrantsTable,
FileEntitiesTable, FileEntitiesTable,
GroupAllowedPoliciesTable, GroupAllowedPoliciesTable,
ShareFilesTable,
} }
) )
@ -956,4 +982,6 @@ func init() {
FileEntitiesTable.ForeignKeys[1].RefTable = EntitiesTable FileEntitiesTable.ForeignKeys[1].RefTable = EntitiesTable
GroupAllowedPoliciesTable.ForeignKeys[0].RefTable = GroupsTable GroupAllowedPoliciesTable.ForeignKeys[0].RefTable = GroupsTable
GroupAllowedPoliciesTable.ForeignKeys[1].RefTable = StoragePoliciesTable GroupAllowedPoliciesTable.ForeignKeys[1].RefTable = StoragePoliciesTable
ShareFilesTable.ForeignKeys[0].RefTable = SharesTable
ShareFilesTable.ForeignKeys[1].RefTable = FilesTable
} }

@ -6888,6 +6888,9 @@ type FileMutation struct {
shares map[int]struct{} shares map[int]struct{}
removedshares map[int]struct{} removedshares map[int]struct{}
clearedshares bool clearedshares bool
multi_shares map[int]struct{}
removedmulti_shares map[int]struct{}
clearedmulti_shares bool
acl_entries map[int]struct{} acl_entries map[int]struct{}
removedacl_entries map[int]struct{} removedacl_entries map[int]struct{}
clearedacl_entries bool clearedacl_entries bool
@ -7829,6 +7832,60 @@ func (m *FileMutation) ResetShares() {
m.removedshares = nil m.removedshares = nil
} }
// AddMultiShareIDs adds the "multi_shares" edge to the Share entity by ids.
func (m *FileMutation) AddMultiShareIDs(ids ...int) {
if m.multi_shares == nil {
m.multi_shares = make(map[int]struct{})
}
for i := range ids {
m.multi_shares[ids[i]] = struct{}{}
}
}
// ClearMultiShares clears the "multi_shares" edge to the Share entity.
func (m *FileMutation) ClearMultiShares() {
m.clearedmulti_shares = true
}
// MultiSharesCleared reports if the "multi_shares" edge to the Share entity was cleared.
func (m *FileMutation) MultiSharesCleared() bool {
return m.clearedmulti_shares
}
// RemoveMultiShareIDs removes the "multi_shares" edge to the Share entity by IDs.
func (m *FileMutation) RemoveMultiShareIDs(ids ...int) {
if m.removedmulti_shares == nil {
m.removedmulti_shares = make(map[int]struct{})
}
for i := range ids {
delete(m.multi_shares, ids[i])
m.removedmulti_shares[ids[i]] = struct{}{}
}
}
// RemovedMultiShares returns the removed IDs of the "multi_shares" edge to the Share entity.
func (m *FileMutation) RemovedMultiSharesIDs() (ids []int) {
for id := range m.removedmulti_shares {
ids = append(ids, id)
}
return
}
// MultiSharesIDs returns the "multi_shares" edge IDs in the mutation.
func (m *FileMutation) MultiSharesIDs() (ids []int) {
for id := range m.multi_shares {
ids = append(ids, id)
}
return
}
// ResetMultiShares resets all changes to the "multi_shares" edge.
func (m *FileMutation) ResetMultiShares() {
m.multi_shares = nil
m.clearedmulti_shares = false
m.removedmulti_shares = nil
}
// AddACLEntryIDs adds the "acl_entries" edge to the AclEntry entity by ids. // AddACLEntryIDs adds the "acl_entries" edge to the AclEntry entity by ids.
func (m *FileMutation) AddACLEntryIDs(ids ...int) { func (m *FileMutation) AddACLEntryIDs(ids ...int) {
if m.acl_entries == nil { if m.acl_entries == nil {
@ -8306,7 +8363,7 @@ func (m *FileMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation. // AddedEdges returns all edge names that were set/added in this mutation.
func (m *FileMutation) AddedEdges() []string { func (m *FileMutation) AddedEdges() []string {
edges := make([]string, 0, 9) edges := make([]string, 0, 10)
if m.owner != nil { if m.owner != nil {
edges = append(edges, file.EdgeOwner) edges = append(edges, file.EdgeOwner)
} }
@ -8328,6 +8385,9 @@ func (m *FileMutation) AddedEdges() []string {
if m.shares != nil { if m.shares != nil {
edges = append(edges, file.EdgeShares) edges = append(edges, file.EdgeShares)
} }
if m.multi_shares != nil {
edges = append(edges, file.EdgeMultiShares)
}
if m.acl_entries != nil { if m.acl_entries != nil {
edges = append(edges, file.EdgeACLEntries) edges = append(edges, file.EdgeACLEntries)
} }
@ -8377,6 +8437,12 @@ func (m *FileMutation) AddedIDs(name string) []ent.Value {
ids = append(ids, id) ids = append(ids, id)
} }
return ids return ids
case file.EdgeMultiShares:
ids := make([]ent.Value, 0, len(m.multi_shares))
for id := range m.multi_shares {
ids = append(ids, id)
}
return ids
case file.EdgeACLEntries: case file.EdgeACLEntries:
ids := make([]ent.Value, 0, len(m.acl_entries)) ids := make([]ent.Value, 0, len(m.acl_entries))
for id := range m.acl_entries { for id := range m.acl_entries {
@ -8395,7 +8461,7 @@ func (m *FileMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation. // RemovedEdges returns all edge names that were removed in this mutation.
func (m *FileMutation) RemovedEdges() []string { func (m *FileMutation) RemovedEdges() []string {
edges := make([]string, 0, 9) edges := make([]string, 0, 10)
if m.removedchildren != nil { if m.removedchildren != nil {
edges = append(edges, file.EdgeChildren) edges = append(edges, file.EdgeChildren)
} }
@ -8408,6 +8474,9 @@ func (m *FileMutation) RemovedEdges() []string {
if m.removedshares != nil { if m.removedshares != nil {
edges = append(edges, file.EdgeShares) edges = append(edges, file.EdgeShares)
} }
if m.removedmulti_shares != nil {
edges = append(edges, file.EdgeMultiShares)
}
if m.removedacl_entries != nil { if m.removedacl_entries != nil {
edges = append(edges, file.EdgeACLEntries) edges = append(edges, file.EdgeACLEntries)
} }
@ -8445,6 +8514,12 @@ func (m *FileMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id) ids = append(ids, id)
} }
return ids return ids
case file.EdgeMultiShares:
ids := make([]ent.Value, 0, len(m.removedmulti_shares))
for id := range m.removedmulti_shares {
ids = append(ids, id)
}
return ids
case file.EdgeACLEntries: case file.EdgeACLEntries:
ids := make([]ent.Value, 0, len(m.removedacl_entries)) ids := make([]ent.Value, 0, len(m.removedacl_entries))
for id := range m.removedacl_entries { for id := range m.removedacl_entries {
@ -8463,7 +8538,7 @@ func (m *FileMutation) RemovedIDs(name string) []ent.Value {
// ClearedEdges returns all edge names that were cleared in this mutation. // ClearedEdges returns all edge names that were cleared in this mutation.
func (m *FileMutation) ClearedEdges() []string { func (m *FileMutation) ClearedEdges() []string {
edges := make([]string, 0, 9) edges := make([]string, 0, 10)
if m.clearedowner { if m.clearedowner {
edges = append(edges, file.EdgeOwner) edges = append(edges, file.EdgeOwner)
} }
@ -8485,6 +8560,9 @@ func (m *FileMutation) ClearedEdges() []string {
if m.clearedshares { if m.clearedshares {
edges = append(edges, file.EdgeShares) edges = append(edges, file.EdgeShares)
} }
if m.clearedmulti_shares {
edges = append(edges, file.EdgeMultiShares)
}
if m.clearedacl_entries { if m.clearedacl_entries {
edges = append(edges, file.EdgeACLEntries) edges = append(edges, file.EdgeACLEntries)
} }
@ -8512,6 +8590,8 @@ func (m *FileMutation) EdgeCleared(name string) bool {
return m.clearedentities return m.clearedentities
case file.EdgeShares: case file.EdgeShares:
return m.clearedshares return m.clearedshares
case file.EdgeMultiShares:
return m.clearedmulti_shares
case file.EdgeACLEntries: case file.EdgeACLEntries:
return m.clearedacl_entries return m.clearedacl_entries
case file.EdgeDirectLinks: case file.EdgeDirectLinks:
@ -8562,6 +8642,9 @@ func (m *FileMutation) ResetEdge(name string) error {
case file.EdgeShares: case file.EdgeShares:
m.ResetShares() m.ResetShares()
return nil return nil
case file.EdgeMultiShares:
m.ResetMultiShares()
return nil
case file.EdgeACLEntries: case file.EdgeACLEntries:
m.ResetACLEntries() m.ResetACLEntries()
return nil return nil
@ -17390,6 +17473,9 @@ type ShareMutation struct {
cleareduser bool cleareduser bool
file *int file *int
clearedfile bool clearedfile bool
files map[int]struct{}
removedfiles map[int]struct{}
clearedfiles bool
purchases map[int]struct{} purchases map[int]struct{}
removedpurchases map[int]struct{} removedpurchases map[int]struct{}
clearedpurchases bool clearedpurchases bool
@ -18080,6 +18166,60 @@ func (m *ShareMutation) ResetFile() {
m.clearedfile = false m.clearedfile = false
} }
// AddFileIDs adds the "files" edge to the File entity by ids.
func (m *ShareMutation) AddFileIDs(ids ...int) {
if m.files == nil {
m.files = make(map[int]struct{})
}
for i := range ids {
m.files[ids[i]] = struct{}{}
}
}
// ClearFiles clears the "files" edge to the File entity.
func (m *ShareMutation) ClearFiles() {
m.clearedfiles = true
}
// FilesCleared reports if the "files" edge to the File entity was cleared.
func (m *ShareMutation) FilesCleared() bool {
return m.clearedfiles
}
// RemoveFileIDs removes the "files" edge to the File entity by IDs.
func (m *ShareMutation) RemoveFileIDs(ids ...int) {
if m.removedfiles == nil {
m.removedfiles = make(map[int]struct{})
}
for i := range ids {
delete(m.files, ids[i])
m.removedfiles[ids[i]] = struct{}{}
}
}
// RemovedFiles returns the removed IDs of the "files" edge to the File entity.
func (m *ShareMutation) RemovedFilesIDs() (ids []int) {
for id := range m.removedfiles {
ids = append(ids, id)
}
return
}
// FilesIDs returns the "files" edge IDs in the mutation.
func (m *ShareMutation) FilesIDs() (ids []int) {
for id := range m.files {
ids = append(ids, id)
}
return
}
// ResetFiles resets all changes to the "files" edge.
func (m *ShareMutation) ResetFiles() {
m.files = nil
m.clearedfiles = false
m.removedfiles = nil
}
// AddPurchaseIDs adds the "purchases" edge to the SharePurchase entity by ids. // AddPurchaseIDs adds the "purchases" edge to the SharePurchase entity by ids.
func (m *ShareMutation) AddPurchaseIDs(ids ...int) { func (m *ShareMutation) AddPurchaseIDs(ids ...int) {
if m.purchases == nil { if m.purchases == nil {
@ -18504,13 +18644,16 @@ func (m *ShareMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation. // AddedEdges returns all edge names that were set/added in this mutation.
func (m *ShareMutation) AddedEdges() []string { func (m *ShareMutation) AddedEdges() []string {
edges := make([]string, 0, 3) edges := make([]string, 0, 4)
if m.user != nil { if m.user != nil {
edges = append(edges, share.EdgeUser) edges = append(edges, share.EdgeUser)
} }
if m.file != nil { if m.file != nil {
edges = append(edges, share.EdgeFile) edges = append(edges, share.EdgeFile)
} }
if m.files != nil {
edges = append(edges, share.EdgeFiles)
}
if m.purchases != nil { if m.purchases != nil {
edges = append(edges, share.EdgePurchases) edges = append(edges, share.EdgePurchases)
} }
@ -18529,6 +18672,12 @@ func (m *ShareMutation) AddedIDs(name string) []ent.Value {
if id := m.file; id != nil { if id := m.file; id != nil {
return []ent.Value{*id} return []ent.Value{*id}
} }
case share.EdgeFiles:
ids := make([]ent.Value, 0, len(m.files))
for id := range m.files {
ids = append(ids, id)
}
return ids
case share.EdgePurchases: case share.EdgePurchases:
ids := make([]ent.Value, 0, len(m.purchases)) ids := make([]ent.Value, 0, len(m.purchases))
for id := range m.purchases { for id := range m.purchases {
@ -18541,7 +18690,10 @@ func (m *ShareMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation. // RemovedEdges returns all edge names that were removed in this mutation.
func (m *ShareMutation) RemovedEdges() []string { func (m *ShareMutation) RemovedEdges() []string {
edges := make([]string, 0, 3) edges := make([]string, 0, 4)
if m.removedfiles != nil {
edges = append(edges, share.EdgeFiles)
}
if m.removedpurchases != nil { if m.removedpurchases != nil {
edges = append(edges, share.EdgePurchases) edges = append(edges, share.EdgePurchases)
} }
@ -18552,6 +18704,12 @@ func (m *ShareMutation) RemovedEdges() []string {
// the given name in this mutation. // the given name in this mutation.
func (m *ShareMutation) RemovedIDs(name string) []ent.Value { func (m *ShareMutation) RemovedIDs(name string) []ent.Value {
switch name { switch name {
case share.EdgeFiles:
ids := make([]ent.Value, 0, len(m.removedfiles))
for id := range m.removedfiles {
ids = append(ids, id)
}
return ids
case share.EdgePurchases: case share.EdgePurchases:
ids := make([]ent.Value, 0, len(m.removedpurchases)) ids := make([]ent.Value, 0, len(m.removedpurchases))
for id := range m.removedpurchases { for id := range m.removedpurchases {
@ -18564,13 +18722,16 @@ func (m *ShareMutation) RemovedIDs(name string) []ent.Value {
// ClearedEdges returns all edge names that were cleared in this mutation. // ClearedEdges returns all edge names that were cleared in this mutation.
func (m *ShareMutation) ClearedEdges() []string { func (m *ShareMutation) ClearedEdges() []string {
edges := make([]string, 0, 3) edges := make([]string, 0, 4)
if m.cleareduser { if m.cleareduser {
edges = append(edges, share.EdgeUser) edges = append(edges, share.EdgeUser)
} }
if m.clearedfile { if m.clearedfile {
edges = append(edges, share.EdgeFile) edges = append(edges, share.EdgeFile)
} }
if m.clearedfiles {
edges = append(edges, share.EdgeFiles)
}
if m.clearedpurchases { if m.clearedpurchases {
edges = append(edges, share.EdgePurchases) edges = append(edges, share.EdgePurchases)
} }
@ -18585,6 +18746,8 @@ func (m *ShareMutation) EdgeCleared(name string) bool {
return m.cleareduser return m.cleareduser
case share.EdgeFile: case share.EdgeFile:
return m.clearedfile return m.clearedfile
case share.EdgeFiles:
return m.clearedfiles
case share.EdgePurchases: case share.EdgePurchases:
return m.clearedpurchases return m.clearedpurchases
} }
@ -18615,6 +18778,9 @@ func (m *ShareMutation) ResetEdge(name string) error {
case share.EdgeFile: case share.EdgeFile:
m.ResetFile() m.ResetFile()
return nil return nil
case share.EdgeFiles:
m.ResetFiles()
return nil
case share.EdgePurchases: case share.EdgePurchases:
m.ResetPurchases() m.ResetPurchases()
return nil return nil

@ -68,6 +68,9 @@ func (File) Edges() []ent.Edge {
edge.To("metadata", Metadata.Type), edge.To("metadata", Metadata.Type),
edge.To("entities", Entity.Type), edge.To("entities", Entity.Type),
edge.To("shares", Share.Type), edge.To("shares", Share.Type),
// Inverse of Share.files — shares that cover this file as part of a
// multi-file link. Forms the share_files join table.
edge.From("multi_shares", Share.Type).Ref("files"),
edge.To("acl_entries", AclEntry.Type), edge.To("acl_entries", AclEntry.Type),
edge.To("direct_links", DirectLink.Type), edge.To("direct_links", DirectLink.Type),
} }

@ -46,6 +46,9 @@ func (Share) Edges() []ent.Edge {
Ref("shares").Unique(), Ref("shares").Unique(),
edge.From("file", File.Type). edge.From("file", File.Type).
Ref("shares").Unique(), Ref("shares").Unique(),
// All files covered by a multi-file share, anchor included. Empty
// for legacy single-file shares.
edge.To("files", File.Type),
edge.To("purchases", SharePurchase.Type), edge.To("purchases", SharePurchase.Type),
} }
} }

@ -55,11 +55,13 @@ type ShareEdges struct {
User *User `json:"user,omitempty"` User *User `json:"user,omitempty"`
// File holds the value of the file edge. // File holds the value of the file edge.
File *File `json:"file,omitempty"` File *File `json:"file,omitempty"`
// Files holds the value of the files edge.
Files []*File `json:"files,omitempty"`
// Purchases holds the value of the purchases edge. // Purchases holds the value of the purchases edge.
Purchases []*SharePurchase `json:"purchases,omitempty"` Purchases []*SharePurchase `json:"purchases,omitempty"`
// loadedTypes holds the information for reporting if a // loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not. // type was loaded (or requested) in eager-loading or not.
loadedTypes [3]bool loadedTypes [4]bool
} }
// UserOrErr returns the User value or an error if the edge // UserOrErr returns the User value or an error if the edge
@ -88,10 +90,19 @@ func (e ShareEdges) FileOrErr() (*File, error) {
return nil, &NotLoadedError{edge: "file"} return nil, &NotLoadedError{edge: "file"}
} }
// FilesOrErr returns the Files value or an error if the edge
// was not loaded in eager-loading.
func (e ShareEdges) FilesOrErr() ([]*File, error) {
if e.loadedTypes[2] {
return e.Files, nil
}
return nil, &NotLoadedError{edge: "files"}
}
// PurchasesOrErr returns the Purchases value or an error if the edge // PurchasesOrErr returns the Purchases value or an error if the edge
// was not loaded in eager-loading. // was not loaded in eager-loading.
func (e ShareEdges) PurchasesOrErr() ([]*SharePurchase, error) { func (e ShareEdges) PurchasesOrErr() ([]*SharePurchase, error) {
if e.loadedTypes[2] { if e.loadedTypes[3] {
return e.Purchases, nil return e.Purchases, nil
} }
return nil, &NotLoadedError{edge: "purchases"} return nil, &NotLoadedError{edge: "purchases"}
@ -237,6 +248,11 @@ func (s *Share) QueryFile() *FileQuery {
return NewShareClient(s.config).QueryFile(s) return NewShareClient(s.config).QueryFile(s)
} }
// QueryFiles queries the "files" edge of the Share entity.
func (s *Share) QueryFiles() *FileQuery {
return NewShareClient(s.config).QueryFiles(s)
}
// QueryPurchases queries the "purchases" edge of the Share entity. // QueryPurchases queries the "purchases" edge of the Share entity.
func (s *Share) QueryPurchases() *SharePurchaseQuery { func (s *Share) QueryPurchases() *SharePurchaseQuery {
return NewShareClient(s.config).QueryPurchases(s) return NewShareClient(s.config).QueryPurchases(s)
@ -316,10 +332,16 @@ func (e *Share) SetFile(v *File) {
e.Edges.loadedTypes[1] = true e.Edges.loadedTypes[1] = true
} }
// SetFiles manually set the edge as loaded state.
func (e *Share) SetFiles(v []*File) {
e.Edges.Files = v
e.Edges.loadedTypes[2] = true
}
// SetPurchases manually set the edge as loaded state. // SetPurchases manually set the edge as loaded state.
func (e *Share) SetPurchases(v []*SharePurchase) { func (e *Share) SetPurchases(v []*SharePurchase) {
e.Edges.Purchases = v e.Edges.Purchases = v
e.Edges.loadedTypes[2] = true e.Edges.loadedTypes[3] = true
} }
// Shares is a parsable slice of Share. // Shares is a parsable slice of Share.

@ -39,6 +39,8 @@ const (
EdgeUser = "user" EdgeUser = "user"
// EdgeFile holds the string denoting the file edge name in mutations. // EdgeFile holds the string denoting the file edge name in mutations.
EdgeFile = "file" EdgeFile = "file"
// EdgeFiles holds the string denoting the files edge name in mutations.
EdgeFiles = "files"
// EdgePurchases holds the string denoting the purchases edge name in mutations. // EdgePurchases holds the string denoting the purchases edge name in mutations.
EdgePurchases = "purchases" EdgePurchases = "purchases"
// Table holds the table name of the share in the database. // Table holds the table name of the share in the database.
@ -57,6 +59,11 @@ const (
FileInverseTable = "files" FileInverseTable = "files"
// FileColumn is the table column denoting the file relation/edge. // FileColumn is the table column denoting the file relation/edge.
FileColumn = "file_shares" FileColumn = "file_shares"
// FilesTable is the table that holds the files relation/edge. The primary key declared below.
FilesTable = "share_files"
// FilesInverseTable is the table name for the File entity.
// It exists in this package in order to avoid circular dependency with the "file" package.
FilesInverseTable = "files"
// PurchasesTable is the table that holds the purchases relation/edge. // PurchasesTable is the table that holds the purchases relation/edge.
PurchasesTable = "share_purchases" PurchasesTable = "share_purchases"
// PurchasesInverseTable is the table name for the SharePurchase entity. // PurchasesInverseTable is the table name for the SharePurchase entity.
@ -88,6 +95,12 @@ var ForeignKeys = []string{
"user_shares", "user_shares",
} }
var (
// FilesPrimaryKey and FilesColumn2 are the table columns denoting the
// primary key for the files relation (M2M).
FilesPrimaryKey = []string{"share_id", "file_id"}
)
// ValidColumn reports if the column name is valid (part of the table columns). // ValidColumn reports if the column name is valid (part of the table columns).
func ValidColumn(column string) bool { func ValidColumn(column string) bool {
for i := range Columns { for i := range Columns {
@ -194,6 +207,20 @@ func ByFileField(field string, opts ...sql.OrderTermOption) OrderOption {
} }
} }
// ByFilesCount orders the results by files count.
func ByFilesCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborsCount(s, newFilesStep(), opts...)
}
}
// ByFiles orders the results by files terms.
func ByFiles(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newFilesStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
// ByPurchasesCount orders the results by purchases count. // ByPurchasesCount orders the results by purchases count.
func ByPurchasesCount(opts ...sql.OrderTermOption) OrderOption { func ByPurchasesCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) { return func(s *sql.Selector) {
@ -221,6 +248,13 @@ func newFileStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.M2O, true, FileTable, FileColumn), sqlgraph.Edge(sqlgraph.M2O, true, FileTable, FileColumn),
) )
} }
func newFilesStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(FilesInverseTable, FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, FilesTable, FilesPrimaryKey...),
)
}
func newPurchasesStep() *sqlgraph.Step { func newPurchasesStep() *sqlgraph.Step {
return sqlgraph.NewStep( return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID), sqlgraph.From(Table, FieldID),

@ -581,6 +581,29 @@ func HasFileWith(preds ...predicate.File) predicate.Share {
}) })
} }
// HasFiles applies the HasEdge predicate on the "files" edge.
func HasFiles() predicate.Share {
return predicate.Share(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, FilesTable, FilesPrimaryKey...),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasFilesWith applies the HasEdge predicate on the "files" edge with a given conditions (other predicates).
func HasFilesWith(preds ...predicate.File) predicate.Share {
return predicate.Share(func(s *sql.Selector) {
step := newFilesStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// HasPurchases applies the HasEdge predicate on the "purchases" edge. // HasPurchases applies the HasEdge predicate on the "purchases" edge.
func HasPurchases() predicate.Share { func HasPurchases() predicate.Share {
return predicate.Share(func(s *sql.Selector) { return predicate.Share(func(s *sql.Selector) {

@ -196,6 +196,21 @@ func (sc *ShareCreate) SetFile(f *File) *ShareCreate {
return sc.SetFileID(f.ID) return sc.SetFileID(f.ID)
} }
// AddFileIDs adds the "files" edge to the File entity by IDs.
func (sc *ShareCreate) AddFileIDs(ids ...int) *ShareCreate {
sc.mutation.AddFileIDs(ids...)
return sc
}
// AddFiles adds the "files" edges to the File entity.
func (sc *ShareCreate) AddFiles(f ...*File) *ShareCreate {
ids := make([]int, len(f))
for i := range f {
ids[i] = f[i].ID
}
return sc.AddFileIDs(ids...)
}
// AddPurchaseIDs adds the "purchases" edge to the SharePurchase entity by IDs. // AddPurchaseIDs adds the "purchases" edge to the SharePurchase entity by IDs.
func (sc *ShareCreate) AddPurchaseIDs(ids ...int) *ShareCreate { func (sc *ShareCreate) AddPurchaseIDs(ids ...int) *ShareCreate {
sc.mutation.AddPurchaseIDs(ids...) sc.mutation.AddPurchaseIDs(ids...)
@ -407,6 +422,22 @@ func (sc *ShareCreate) createSpec() (*Share, *sqlgraph.CreateSpec) {
_node.file_shares = &nodes[0] _node.file_shares = &nodes[0]
_spec.Edges = append(_spec.Edges, edge) _spec.Edges = append(_spec.Edges, edge)
} }
if nodes := sc.mutation.FilesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: share.FilesTable,
Columns: share.FilesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(file.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := sc.mutation.PurchasesIDs(); len(nodes) > 0 { if nodes := sc.mutation.PurchasesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M, Rel: sqlgraph.O2M,

@ -27,6 +27,7 @@ type ShareQuery struct {
predicates []predicate.Share predicates []predicate.Share
withUser *UserQuery withUser *UserQuery
withFile *FileQuery withFile *FileQuery
withFiles *FileQuery
withPurchases *SharePurchaseQuery withPurchases *SharePurchaseQuery
withFKs bool withFKs bool
// intermediate query (i.e. traversal path). // intermediate query (i.e. traversal path).
@ -109,6 +110,28 @@ func (sq *ShareQuery) QueryFile() *FileQuery {
return query return query
} }
// QueryFiles chains the current query on the "files" edge.
func (sq *ShareQuery) QueryFiles() *FileQuery {
query := (&FileClient{config: sq.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := sq.prepareQuery(ctx); err != nil {
return nil, err
}
selector := sq.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(share.Table, share.FieldID, selector),
sqlgraph.To(file.Table, file.FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, share.FilesTable, share.FilesPrimaryKey...),
)
fromU = sqlgraph.SetNeighbors(sq.driver.Dialect(), step)
return fromU, nil
}
return query
}
// QueryPurchases chains the current query on the "purchases" edge. // QueryPurchases chains the current query on the "purchases" edge.
func (sq *ShareQuery) QueryPurchases() *SharePurchaseQuery { func (sq *ShareQuery) QueryPurchases() *SharePurchaseQuery {
query := (&SharePurchaseClient{config: sq.config}).Query() query := (&SharePurchaseClient{config: sq.config}).Query()
@ -325,6 +348,7 @@ func (sq *ShareQuery) Clone() *ShareQuery {
predicates: append([]predicate.Share{}, sq.predicates...), predicates: append([]predicate.Share{}, sq.predicates...),
withUser: sq.withUser.Clone(), withUser: sq.withUser.Clone(),
withFile: sq.withFile.Clone(), withFile: sq.withFile.Clone(),
withFiles: sq.withFiles.Clone(),
withPurchases: sq.withPurchases.Clone(), withPurchases: sq.withPurchases.Clone(),
// clone intermediate query. // clone intermediate query.
sql: sq.sql.Clone(), sql: sq.sql.Clone(),
@ -354,6 +378,17 @@ func (sq *ShareQuery) WithFile(opts ...func(*FileQuery)) *ShareQuery {
return sq return sq
} }
// WithFiles tells the query-builder to eager-load the nodes that are connected to
// the "files" edge. The optional arguments are used to configure the query builder of the edge.
func (sq *ShareQuery) WithFiles(opts ...func(*FileQuery)) *ShareQuery {
query := (&FileClient{config: sq.config}).Query()
for _, opt := range opts {
opt(query)
}
sq.withFiles = query
return sq
}
// WithPurchases tells the query-builder to eager-load the nodes that are connected to // WithPurchases tells the query-builder to eager-load the nodes that are connected to
// the "purchases" edge. The optional arguments are used to configure the query builder of the edge. // the "purchases" edge. The optional arguments are used to configure the query builder of the edge.
func (sq *ShareQuery) WithPurchases(opts ...func(*SharePurchaseQuery)) *ShareQuery { func (sq *ShareQuery) WithPurchases(opts ...func(*SharePurchaseQuery)) *ShareQuery {
@ -444,9 +479,10 @@ func (sq *ShareQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Share,
nodes = []*Share{} nodes = []*Share{}
withFKs = sq.withFKs withFKs = sq.withFKs
_spec = sq.querySpec() _spec = sq.querySpec()
loadedTypes = [3]bool{ loadedTypes = [4]bool{
sq.withUser != nil, sq.withUser != nil,
sq.withFile != nil, sq.withFile != nil,
sq.withFiles != nil,
sq.withPurchases != nil, sq.withPurchases != nil,
} }
) )
@ -486,6 +522,13 @@ func (sq *ShareQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Share,
return nil, err return nil, err
} }
} }
if query := sq.withFiles; query != nil {
if err := sq.loadFiles(ctx, query, nodes,
func(n *Share) { n.Edges.Files = []*File{} },
func(n *Share, e *File) { n.Edges.Files = append(n.Edges.Files, e) }); err != nil {
return nil, err
}
}
if query := sq.withPurchases; query != nil { if query := sq.withPurchases; query != nil {
if err := sq.loadPurchases(ctx, query, nodes, if err := sq.loadPurchases(ctx, query, nodes,
func(n *Share) { n.Edges.Purchases = []*SharePurchase{} }, func(n *Share) { n.Edges.Purchases = []*SharePurchase{} },
@ -560,6 +603,67 @@ func (sq *ShareQuery) loadFile(ctx context.Context, query *FileQuery, nodes []*S
} }
return nil return nil
} }
func (sq *ShareQuery) loadFiles(ctx context.Context, query *FileQuery, nodes []*Share, init func(*Share), assign func(*Share, *File)) error {
edgeIDs := make([]driver.Value, len(nodes))
byID := make(map[int]*Share)
nids := make(map[int]map[*Share]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(share.FilesTable)
s.Join(joinT).On(s.C(file.FieldID), joinT.C(share.FilesPrimaryKey[1]))
s.Where(sql.InValues(joinT.C(share.FilesPrimaryKey[0]), edgeIDs...))
columns := s.SelectedColumns()
s.Select(joinT.C(share.FilesPrimaryKey[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[*Share]struct{}{byID[outValue]: {}}
return assign(columns[1:], values[1:])
}
nids[inValue][byID[outValue]] = struct{}{}
return nil
}
})
})
neighbors, err := withInterceptors[[]*File](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 "files" node returned %v`, n.ID)
}
for kn := range nodes {
assign(kn, n)
}
}
return nil
}
func (sq *ShareQuery) loadPurchases(ctx context.Context, query *SharePurchaseQuery, nodes []*Share, init func(*Share), assign func(*Share, *SharePurchase)) error { func (sq *ShareQuery) loadPurchases(ctx context.Context, query *SharePurchaseQuery, nodes []*Share, init func(*Share), assign func(*Share, *SharePurchase)) error {
fks := make([]driver.Value, 0, len(nodes)) fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int]*Share) nodeids := make(map[int]*Share)

@ -238,6 +238,21 @@ func (su *ShareUpdate) SetFile(f *File) *ShareUpdate {
return su.SetFileID(f.ID) return su.SetFileID(f.ID)
} }
// AddFileIDs adds the "files" edge to the File entity by IDs.
func (su *ShareUpdate) AddFileIDs(ids ...int) *ShareUpdate {
su.mutation.AddFileIDs(ids...)
return su
}
// AddFiles adds the "files" edges to the File entity.
func (su *ShareUpdate) AddFiles(f ...*File) *ShareUpdate {
ids := make([]int, len(f))
for i := range f {
ids[i] = f[i].ID
}
return su.AddFileIDs(ids...)
}
// AddPurchaseIDs adds the "purchases" edge to the SharePurchase entity by IDs. // AddPurchaseIDs adds the "purchases" edge to the SharePurchase entity by IDs.
func (su *ShareUpdate) AddPurchaseIDs(ids ...int) *ShareUpdate { func (su *ShareUpdate) AddPurchaseIDs(ids ...int) *ShareUpdate {
su.mutation.AddPurchaseIDs(ids...) su.mutation.AddPurchaseIDs(ids...)
@ -270,6 +285,27 @@ func (su *ShareUpdate) ClearFile() *ShareUpdate {
return su return su
} }
// ClearFiles clears all "files" edges to the File entity.
func (su *ShareUpdate) ClearFiles() *ShareUpdate {
su.mutation.ClearFiles()
return su
}
// RemoveFileIDs removes the "files" edge to File entities by IDs.
func (su *ShareUpdate) RemoveFileIDs(ids ...int) *ShareUpdate {
su.mutation.RemoveFileIDs(ids...)
return su
}
// RemoveFiles removes "files" edges to File entities.
func (su *ShareUpdate) RemoveFiles(f ...*File) *ShareUpdate {
ids := make([]int, len(f))
for i := range f {
ids[i] = f[i].ID
}
return su.RemoveFileIDs(ids...)
}
// ClearPurchases clears all "purchases" edges to the SharePurchase entity. // ClearPurchases clears all "purchases" edges to the SharePurchase entity.
func (su *ShareUpdate) ClearPurchases() *ShareUpdate { func (su *ShareUpdate) ClearPurchases() *ShareUpdate {
su.mutation.ClearPurchases() su.mutation.ClearPurchases()
@ -467,6 +503,51 @@ func (su *ShareUpdate) sqlSave(ctx context.Context) (n int, err error) {
} }
_spec.Edges.Add = append(_spec.Edges.Add, edge) _spec.Edges.Add = append(_spec.Edges.Add, edge)
} }
if su.mutation.FilesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: share.FilesTable,
Columns: share.FilesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(file.FieldID, field.TypeInt),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := su.mutation.RemovedFilesIDs(); len(nodes) > 0 && !su.mutation.FilesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: share.FilesTable,
Columns: share.FilesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(file.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 := su.mutation.FilesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: share.FilesTable,
Columns: share.FilesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(file.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if su.mutation.PurchasesCleared() { if su.mutation.PurchasesCleared() {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M, Rel: sqlgraph.O2M,
@ -738,6 +819,21 @@ func (suo *ShareUpdateOne) SetFile(f *File) *ShareUpdateOne {
return suo.SetFileID(f.ID) return suo.SetFileID(f.ID)
} }
// AddFileIDs adds the "files" edge to the File entity by IDs.
func (suo *ShareUpdateOne) AddFileIDs(ids ...int) *ShareUpdateOne {
suo.mutation.AddFileIDs(ids...)
return suo
}
// AddFiles adds the "files" edges to the File entity.
func (suo *ShareUpdateOne) AddFiles(f ...*File) *ShareUpdateOne {
ids := make([]int, len(f))
for i := range f {
ids[i] = f[i].ID
}
return suo.AddFileIDs(ids...)
}
// AddPurchaseIDs adds the "purchases" edge to the SharePurchase entity by IDs. // AddPurchaseIDs adds the "purchases" edge to the SharePurchase entity by IDs.
func (suo *ShareUpdateOne) AddPurchaseIDs(ids ...int) *ShareUpdateOne { func (suo *ShareUpdateOne) AddPurchaseIDs(ids ...int) *ShareUpdateOne {
suo.mutation.AddPurchaseIDs(ids...) suo.mutation.AddPurchaseIDs(ids...)
@ -770,6 +866,27 @@ func (suo *ShareUpdateOne) ClearFile() *ShareUpdateOne {
return suo return suo
} }
// ClearFiles clears all "files" edges to the File entity.
func (suo *ShareUpdateOne) ClearFiles() *ShareUpdateOne {
suo.mutation.ClearFiles()
return suo
}
// RemoveFileIDs removes the "files" edge to File entities by IDs.
func (suo *ShareUpdateOne) RemoveFileIDs(ids ...int) *ShareUpdateOne {
suo.mutation.RemoveFileIDs(ids...)
return suo
}
// RemoveFiles removes "files" edges to File entities.
func (suo *ShareUpdateOne) RemoveFiles(f ...*File) *ShareUpdateOne {
ids := make([]int, len(f))
for i := range f {
ids[i] = f[i].ID
}
return suo.RemoveFileIDs(ids...)
}
// ClearPurchases clears all "purchases" edges to the SharePurchase entity. // ClearPurchases clears all "purchases" edges to the SharePurchase entity.
func (suo *ShareUpdateOne) ClearPurchases() *ShareUpdateOne { func (suo *ShareUpdateOne) ClearPurchases() *ShareUpdateOne {
suo.mutation.ClearPurchases() suo.mutation.ClearPurchases()
@ -997,6 +1114,51 @@ func (suo *ShareUpdateOne) sqlSave(ctx context.Context) (_node *Share, err error
} }
_spec.Edges.Add = append(_spec.Edges.Add, edge) _spec.Edges.Add = append(_spec.Edges.Add, edge)
} }
if suo.mutation.FilesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: share.FilesTable,
Columns: share.FilesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(file.FieldID, field.TypeInt),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := suo.mutation.RemovedFilesIDs(); len(nodes) > 0 && !suo.mutation.FilesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: share.FilesTable,
Columns: share.FilesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(file.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 := suo.mutation.FilesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: share.FilesTable,
Columns: share.FilesPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(file.FieldID, field.TypeInt),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if suo.mutation.PurchasesCleared() { if suo.mutation.PurchasesCleared() {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M, Rel: sqlgraph.O2M,

@ -685,7 +685,10 @@
"viewSetting": "View setting", "viewSetting": "View setting",
"saved": "Saved", "saved": "Saved",
"notSet": "Not set", "notSet": "Not set",
"deleteViewSetting": "Delete view setting" "deleteViewSetting": "Delete view setting",
"shareTargets": "Share targets",
"shareTargetsCount_one": "{{count}} file selected",
"shareTargetsCount_other": "{{count}} files selected"
}, },
"uploader": { "uploader": {
"fileCopyName": "Copy of ", "fileCopyName": "Copy of ",

@ -685,7 +685,10 @@
"viewSetting": "视图设置", "viewSetting": "视图设置",
"saved": "已保存", "saved": "已保存",
"notSet": "未设置", "notSet": "未设置",
"deleteViewSetting": "删除视图设置" "deleteViewSetting": "删除视图设置",
"shareTargets": "分享对象",
"shareTargetsCount_one": "已选择 {{count}} 个项目",
"shareTargetsCount_other": "已选择 {{count}} 个项目"
}, },
"uploader": { "uploader": {
"fileCopyName": "副本_", "fileCopyName": "副本_",

@ -98,6 +98,7 @@ export interface Share {
price?: number; price?: number;
paid?: boolean; paid?: boolean;
purchase_ticket?: string; purchase_ticket?: string;
file_count?: number;
} }
export enum PolicyType { export enum PolicyType {
@ -338,7 +339,8 @@ export interface PatchMetadataService extends MultipleUriService {
} }
export interface ShareCreateService { export interface ShareCreateService {
uri: string; uri?: string;
uris?: string[];
downloads?: number; downloads?: number;
is_private?: boolean; is_private?: boolean;
password?: string; password?: string;

@ -265,7 +265,7 @@ const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => {
</SquareMenuItem> </SquareMenuItem>
)} )}
{displayOpt.showShare && ( {displayOpt.showShare && (
<SquareMenuItem onClick={() => dispatch(openShareDialog(fmIndex, targets[0]))}> <SquareMenuItem onClick={() => dispatch(openShareDialog(fmIndex, targets))}>
<ListItemIcon> <ListItemIcon>
<ShareOutlined fontSize="small" /> <ShareOutlined fontSize="small" />
</ListItemIcon> </ListItemIcon>

@ -248,15 +248,18 @@ export const getActionOpt = (
display.orCapability.enabled(NavigatorCapability.download_file) && display.orCapability.enabled(NavigatorCapability.download_file) &&
(!inShare || display.orCapability.enabled(NavigatorCapability.create_file)); (!inShare || display.orCapability.enabled(NavigatorCapability.create_file));
display.showShare = display.showShare =
targets.length == 1 && targets.length >= 1 &&
!!currentUser && !!currentUser &&
groupBs.enabled(GroupPermission.share) && groupBs.enabled(GroupPermission.share) &&
display.allUpdatable && display.allUpdatable &&
(targets[0].owned || groupBs.enabled(GroupPermission.is_admin)) && targets.every((t) => t.owned || groupBs.enabled(GroupPermission.is_admin)) &&
display.orCapability && display.orCapability &&
display.orCapability.enabled(NavigatorCapability.share) && display.orCapability.enabled(NavigatorCapability.share) &&
(!targets[0].metadata || targets.every(
(!targets[0].metadata[Metadata.share_redirect] && !targets[0].metadata[Metadata.restore_uri])); (t) =>
!t.metadata ||
(!t.metadata[Metadata.share_redirect] && !t.metadata[Metadata.restore_uri]),
);
display.showMove = display.showMove =
display.hasUpdatable && display.hasUpdatable &&
display.orCapability && display.orCapability &&

@ -123,7 +123,9 @@ const ShareDialog = () => {
const open = useAppSelector((state) => state.globalState.shareLinkDialogOpen); const open = useAppSelector((state) => state.globalState.shareLinkDialogOpen);
const target = useAppSelector((state) => state.globalState.shareLinkDialogFile); const target = useAppSelector((state) => state.globalState.shareLinkDialogFile);
const targets = useAppSelector((state) => state.globalState.shareLinkDialogFiles);
const editTarget = useAppSelector((state) => state.globalState.shareLinkDialogShare); const editTarget = useAppSelector((state) => state.globalState.shareLinkDialogShare);
const multiCount = targets && targets.length > 1 ? targets.length : 0;
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -159,7 +161,7 @@ const ShareDialog = () => {
setLoading(true); setLoading(true);
try { try {
const shareLink = await dispatch( const shareLink = await dispatch(
createOrUpdateShareLink(FileManagerIndex.main, target, setting, editTarget?.id), createOrUpdateShareLink(FileManagerIndex.main, target, setting, editTarget?.id, targets),
); );
rememberSetting(setting); rememberSetting(setting);
setShareLink(shareLink); setShareLink(shareLink);
@ -224,12 +226,24 @@ const ShareDialog = () => {
> >
<Box> <Box>
{!shareLink && ( {!shareLink && (
<ShareSettingContent <>
editing={!!editTarget} {multiCount > 0 && (
onSettingChange={setSetting} <FilledTextField
setting={setting} variant={"filled"}
file={target} inputProps={{ readOnly: true }}
/> label={t("application:modals.shareTargets")}
fullWidth
value={t("application:modals.shareTargetsCount", { count: multiCount })}
sx={{ mb: 1 }}
/>
)}
<ShareSettingContent
editing={!!editTarget}
onSettingChange={setSetting}
setting={setting}
file={multiCount > 0 ? undefined : target}
/>
</>
)} )}
{shareLink && ( {shareLink && (
<Stack spacing={1}> <Stack spacing={1}>

@ -147,7 +147,7 @@ const FileSelectedActions = forwardRef(({ targets }: FileSelectedActionsProps, r
)} )}
{displayOpt.showShare && ( {displayOpt.showShare && (
<Tooltip title={t("application:fileManager.share")}> <Tooltip title={t("application:fileManager.share")}>
<ActionButton onClick={() => dispatch(openShareDialog(0, targets[0]))}> <ActionButton onClick={() => dispatch(openShareDialog(0, targets))}>
<ShareOutlined fontSize={"small"} /> <ShareOutlined fontSize={"small"} />
</ActionButton> </ActionButton>
</Tooltip> </Tooltip>

@ -71,7 +71,7 @@ const Wopi = () => {
} }
if (msg.MessageId === "UI_Sharing" || msg.MessageId === "UI_Share") { if (msg.MessageId === "UI_Sharing" || msg.MessageId === "UI_Share") {
dispatch(openShareDialog(FileManagerIndex.main, viewerState?.file)); dispatch(openShareDialog(FileManagerIndex.main, viewerState?.file ? [viewerState.file] : []));
} else if (msg.MessageId == "UI_FileVersions") { } else if (msg.MessageId == "UI_FileVersions") {
dispatch(setVersionControlDialog({ open: true, file: viewerState.file })); dispatch(setVersionControlDialog({ open: true, file: viewerState.file }));
} }

@ -145,6 +145,7 @@ export interface GlobalStateSlice {
// Share link dialog // Share link dialog
shareLinkDialogOpen?: boolean; shareLinkDialogOpen?: boolean;
shareLinkDialogFile?: FileResponse; shareLinkDialogFile?: FileResponse;
shareLinkDialogFiles?: FileResponse[];
shareLinkDialogShare?: Share; shareLinkDialogShare?: Share;
// Version control dialog // Version control dialog
@ -776,11 +777,13 @@ export const globalStateSlice = createSlice({
action: PayloadAction<{ action: PayloadAction<{
open: boolean; open: boolean;
file?: FileResponse; file?: FileResponse;
files?: FileResponse[];
share?: Share; share?: Share;
}>, }>,
) => { ) => {
state.shareLinkDialogOpen = action.payload.open; state.shareLinkDialogOpen = action.payload.open;
state.shareLinkDialogFile = action.payload.file; state.shareLinkDialogFile = action.payload.file;
state.shareLinkDialogFiles = action.payload.files;
state.shareLinkDialogShare = action.payload.share; state.shareLinkDialogShare = action.payload.share;
}, },
closeShareLinkDialog: (state) => { closeShareLinkDialog: (state) => {

@ -960,10 +960,10 @@ export function applyIcon(index: number, files: FileResponse[], icon?: string):
}; };
} }
export function openShareDialog(index: number, src: FileResponse): AppThunk { export function openShareDialog(index: number, src: FileResponse[]): AppThunk {
return async (dispatch, _getState) => { return async (dispatch, _getState) => {
dispatch(closeContextMenu({ index, value: undefined })); dispatch(closeContextMenu({ index, value: undefined }));
dispatch(setShareLinkDialog({ open: true, file: src })); dispatch(setShareLinkDialog({ open: true, file: src[0], files: src }));
}; };
} }

@ -22,10 +22,12 @@ export function createOrUpdateShareLink(
file: FileResponse, file: FileResponse,
setting: ShareSetting, setting: ShareSetting,
existed?: string, existed?: string,
files?: FileResponse[],
): AppThunk<Promise<string>> { ): AppThunk<Promise<string>> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const req: ShareCreateService = { const req: ShareCreateService = {
uri: file.path, uri: file.path,
uris: files && files.length > 1 ? files.map((f) => f.path) : undefined,
is_private: setting.is_private, is_private: setting.is_private,
password: setting.password, password: setting.password,
share_view: setting.share_view, share_view: setting.share_view,
@ -41,15 +43,14 @@ export function createOrUpdateShareLink(
}; };
const res = await dispatch(existed ? sendUpdateShare(req, existed) : sendCreateShare(req)); const res = await dispatch(existed ? sendUpdateShare(req, existed) : sendCreateShare(req));
const shared = files && files.length > 1 ? files : [file];
dispatch( dispatch(
fileUpdated({ fileUpdated({
index, index,
value: [ value: shared.map((f) => ({
{ file: { ...f, shared: true },
file: { ...file, shared: true }, oldPath: f.path,
oldPath: file.path, })),
},
],
}), }),
); );

@ -20,8 +20,9 @@ import (
type ( type (
// Ctx keys for eager loading options. // Ctx keys for eager loading options.
LoadShareFile struct{} LoadShareFile struct{}
LoadShareUser struct{} LoadShareUser struct{}
LoadShareFiles struct{}
) )
var ( var (
@ -68,8 +69,11 @@ type (
Expires *time.Time Expires *time.Time
OwnerID int OwnerID int
FileID int FileID int
Props *types.ShareProps // FileIDs is the full set of a multi-file share, anchor included.
PricePoints int // Create-only: an existing share's file set never changes.
FileIDs []int
Props *types.ShareProps
PricePoints int
} }
ListShareArgs struct { ListShareArgs struct {
@ -157,6 +161,9 @@ func (c *shareClient) Upsert(ctx context.Context, params *CreateShareParams) (*e
if params.PricePoints > 0 { if params.PricePoints > 0 {
query.SetPricePoints(params.PricePoints) query.SetPricePoints(params.PricePoints)
} }
if len(params.FileIDs) > 1 {
query.AddFileIDs(params.FileIDs...)
}
return query.Save(ctx) return query.Save(ctx)
} }
@ -256,7 +263,22 @@ func IsValidShare(share *ent.Share) error {
return ErrSourceFileInvalid return ErrSourceFileInvalid
} }
// Check source file status // Check source file status. Multi-file shares stay valid while at least
// one linked file is alive; single shares require the anchor file.
if len(share.Edges.Files) > 0 {
alive := false
for _, f := range share.Edges.Files {
if f.FileChildren > 0 && f.OwnerID == owner.ID {
alive = true
break
}
}
if !alive {
return ErrSourceFileInvalid
}
return nil
}
file, err := share.Edges.FileOrErr() file, err := share.Edges.FileOrErr()
if err != nil || file.FileChildren == 0 || file.OwnerID != owner.ID { if err != nil || file.FileChildren == 0 || file.OwnerID != owner.ID {
// Source file already deleted // Source file already deleted
@ -377,7 +399,10 @@ func (c *shareClient) listQuery(args *ListShareArgs) *ent.ShareQuery {
} }
if args.FileID > 0 { if args.FileID > 0 {
query.Where(share.HasFileWith(file.ID(args.FileID))) query.Where(share.Or(
share.HasFileWith(file.ID(args.FileID)),
share.HasFilesWith(file.ID(args.FileID)),
))
} }
if len(args.ShareIDs) > 0 { if len(args.ShareIDs) > 0 {
@ -443,6 +468,11 @@ func withShareEagerLoading(ctx context.Context, q *ent.ShareQuery) *ent.ShareQue
withFileEagerLoading(ctx, q) withFileEagerLoading(ctx, q)
}) })
} }
if v, ok := ctx.Value(LoadShareFiles{}).(bool); ok && v {
q.WithFiles(func(q *ent.FileQuery) {
withFileEagerLoading(ctx, q)
})
}
if v, ok := ctx.Value(LoadShareUser{}).(bool); ok && v { if v, ok := ctx.Value(LoadShareUser{}).(bool); ok && v {
q.WithUser(func(q *ent.UserQuery) { q.WithUser(func(q *ent.UserQuery) {
userCtx := context.WithValue(ctx, LoadUserGroup{}, true) userCtx := context.WithValue(ctx, LoadUserGroup{}, true)

@ -46,6 +46,76 @@ func TestIsValidShareChecksOwnerAccess(t *testing.T) {
} }
} }
func TestIsValidShareMultiFile(t *testing.T) {
permissions := &boolset.BooleanSet{}
boolset.Set(types.GroupPermissionShare, true, permissions)
group := &ent.Group{Permissions: permissions}
owner := &ent.User{ID: 1, Status: entuser.StatusActive}
owner.SetGroup(group)
alive := &ent.File{OwnerID: owner.ID, FileChildren: 1}
dead := &ent.File{OwnerID: owner.ID, FileChildren: 0}
foreign := &ent.File{OwnerID: 2, FileChildren: 1}
newShare := func(files ...*ent.File) *ent.Share {
s := &ent.Share{}
s.SetUser(owner)
s.SetFile(dead)
s.Edges.Files = files
return s
}
// Multi-file shares stay valid while at least one linked file is alive.
require.NoError(t, IsValidShare(newShare(dead, alive)))
require.NoError(t, IsValidShare(newShare(alive)))
require.ErrorIs(t, IsValidShare(newShare(dead)), ErrSourceFileInvalid)
require.ErrorIs(t, IsValidShare(newShare(foreign)), ErrSourceFileInvalid)
require.ErrorIs(t, IsValidShare(newShare(dead, foreign)), ErrSourceFileInvalid)
// Anchor-dead but linked-alive shares remain valid.
require.NoError(t, IsValidShare(newShare(dead, dead, alive)))
}
func TestShareUpsertFileIDs(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()
permissions := &boolset.BooleanSet{}
boolset.Set(types.GroupPermissionShare, true, permissions)
group := client.Group.Create().SetName("g").SetPermissions(permissions).SaveX(ctx)
owner := client.User.Create().SetEmail("owner@example.com").SetNick("owner").SetGroup(group).SaveX(ctx)
root := client.File.Create().SetName(RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx)
f1 := client.File.Create().SetName("a.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SetParent(root).SaveX(ctx)
f2 := client.File.Create().SetName("b.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SetParent(root).SaveX(ctx)
shareClient := NewShareClient(client, conf.SQLiteDB, nil)
// Multi-file share: files edge holds the full set, anchor included.
s, err := shareClient.Upsert(ctx, &CreateShareParams{
OwnerID: owner.ID,
FileID: f1.ID,
FileIDs: []int{f1.ID, f2.ID},
})
require.NoError(t, err)
loadCtx := context.WithValue(ctx, LoadShareFiles{}, true)
loaded, err := shareClient.GetByID(loadCtx, s.ID)
require.NoError(t, err)
require.Len(t, loaded.Edges.Files, 2)
// Single-file share: no files edge — legacy behavior unchanged.
single, err := shareClient.Upsert(ctx, &CreateShareParams{
OwnerID: owner.ID,
FileID: f1.ID,
FileIDs: []int{f1.ID},
})
require.NoError(t, err)
loaded, err = shareClient.GetByID(loadCtx, single.ID)
require.NoError(t, err)
require.Empty(t, loaded.Edges.Files)
}
func TestShareClientRevalidatesOwnerGroup(t *testing.T) { func TestShareClientRevalidatesOwnerGroup(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) }) t.Cleanup(func() { require.NoError(t, client.Close()) })

@ -0,0 +1,146 @@
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"
"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/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/samber/lo"
"github.com/stretchr/testify/require"
)
// multiShareFixture seeds an owner with three shareable files spread across
// different folders and one multi-file share covering all of them. The
// visitor has share-download permission like a normal logged-in link opener.
func multiShareFixture(t *testing.T, client *ent.Client, hasher hashid.Encoder) (*ent.User, *ent.Share) {
t.Helper()
ctx := context.Background()
policy := client.StoragePolicy.Create().SetName("local").SetType("local").SaveX(ctx)
permissions := &boolset.BooleanSet{}
boolset.Sets(map[types.GroupPermission]bool{
types.GroupPermissionShare: true,
types.GroupPermissionShareDownload: true,
}, permissions)
group := client.Group.Create().SetName("g").SetPermissions(permissions).
SetStoragePolicies(policy).SaveX(ctx)
owner := client.User.Create().SetEmail("owner@example.com").SetNick("o").SetGroup(group).SaveX(ctx)
ownerRoot := client.File.Create().SetName(inventory.RootFolderName).
SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx)
fileA := client.File.Create().SetName("a.txt").SetType(int(types.FileTypeFile)).
SetOwner(owner).SetParent(ownerRoot).SaveX(ctx)
dirB := client.File.Create().SetName("b_dir").SetType(int(types.FileTypeFolder)).
SetOwner(owner).SetParent(ownerRoot).SaveX(ctx)
client.File.Create().SetName("inner.txt").SetType(int(types.FileTypeFile)).
SetOwner(owner).SetParent(dirB).SaveX(ctx)
sub := client.File.Create().SetName("sub").SetType(int(types.FileTypeFolder)).
SetOwner(owner).SetParent(ownerRoot).SaveX(ctx)
fileC := client.File.Create().SetName("c.txt").SetType(int(types.FileTypeFile)).
SetOwner(owner).SetParent(sub).SaveX(ctx)
share := client.Share.Create().SetUser(owner).SetFile(fileA).
AddFiles(fileA, dirB, fileC).SaveX(ctx)
visitor := client.User.Create().SetEmail("visitor@example.com").SetNick("v").SetGroup(group).SaveX(ctx)
visitor.SetGroup(group)
return visitor, share
}
func multiShareNavigator(t *testing.T, client *ent.Client, hasher hashid.Encoder, u *ent.User) Navigator {
t.Helper()
return NewShareNavigator(
u,
inventory.NewFileClient(client, conf.SQLiteDB, hasher),
inventory.NewShareClient(client, conf.SQLiteDB, hasher),
nil,
nil,
logging.NewConsoleLogger(logging.LevelError),
&setting.DBFS{},
hasher,
)
}
func TestMultiFileShareNavigator(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()
hasher, err := hashid.New("seed-test-salt")
require.NoError(t, err)
visitor, share := multiShareFixture(t, client, hasher)
nav := multiShareNavigator(t, client, hasher, visitor)
uri, err := fs.NewUriFromString(fs.NewShareUri(hashid.EncodeShareID(hasher, share.ID), ""))
require.NoError(t, err)
root, err := nav.To(ctx, uri)
require.NoError(t, err)
require.Equal(t, types.FileTypeFolder, root.Type())
// Root lists the union of linked files from different folders.
res, err := nav.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 100}})
require.NoError(t, err)
require.ElementsMatch(t, []string{"a.txt", "b_dir", "c.txt"},
lo.Map(res.Files, func(f *File, _ int) string { return f.Name() }))
// Paths resolve through the union root into shared folders.
target, err := nav.To(ctx, uri.Join("b_dir", "inner.txt"))
require.NoError(t, err)
require.Equal(t, "inner.txt", target.Name())
require.Equal(t, types.FileTypeFile, target.Type())
// Real filesystem URIs resolve to the owner's actual path.
real := target.Uri(true)
require.NotNil(t, real)
require.Contains(t, real.String(), "b_dir")
// Share URIs stay inside the share filesystem.
require.Contains(t, target.Uri(false).String(), "share")
// Unknown names and files outside the linked set are not resolvable.
_, err = nav.To(ctx, uri.Join("nope.txt"))
require.Error(t, err)
_, err = nav.To(ctx, uri.Join("sub", "x.txt"))
require.Error(t, err)
}
func TestMultiFileShareDropsDeadEntries(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()
hasher, err := hashid.New("seed-test-salt")
require.NoError(t, err)
visitor, share := multiShareFixture(t, client, hasher)
// Deleting one linked file removes it from the listing without
// invalidating the whole share.
dead := share.QueryFiles().Where(entfile.Name("c.txt")).OnlyX(ctx)
client.File.DeleteOne(dead).ExecX(ctx)
nav := multiShareNavigator(t, client, hasher, visitor)
uri, err := fs.NewUriFromString(fs.NewShareUri(hashid.EncodeShareID(hasher, share.ID), ""))
require.NoError(t, err)
root, err := nav.To(ctx, uri)
require.NoError(t, err)
res, err := nav.Children(ctx, root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 100}})
require.NoError(t, err)
require.ElementsMatch(t, []string{"a.txt", "b_dir"},
lo.Map(res.Files, func(f *File, _ int) string { return f.Name() }))
_, err = nav.To(ctx, uri.Join("c.txt"))
require.Error(t, err)
}

@ -64,6 +64,7 @@ type (
*baseNavigator *baseNavigator
shareRoot *File shareRoot *File
singleFileShare bool singleFileShare bool
multiFileShare bool
ownerRoot *File ownerRoot *File
share *ent.Share share *ent.Share
owner *ent.User owner *ent.User
@ -83,6 +84,7 @@ type (
ShareRoot *File ShareRoot *File
OwnerRoot *File OwnerRoot *File
SingleFileShare bool SingleFileShare bool
MultiFileShare bool
Share *ent.Share Share *ent.Share
Owner *ent.User Owner *ent.User
AclCaps *boolset.BooleanSet AclCaps *boolset.BooleanSet
@ -97,6 +99,7 @@ func (n *shareNavigator) PersistState(kv cache.Driver, key string) {
ShareRoot: n.shareRoot, ShareRoot: n.shareRoot,
OwnerRoot: n.ownerRoot, OwnerRoot: n.ownerRoot,
SingleFileShare: n.singleFileShare, SingleFileShare: n.singleFileShare,
MultiFileShare: n.multiFileShare,
Share: n.share, Share: n.share,
Owner: n.owner, Owner: n.owner,
AclCaps: n.aclCaps, AclCaps: n.aclCaps,
@ -111,6 +114,7 @@ func (n *shareNavigator) RestoreState(s State) error {
n.shareRoot = state.ShareRoot n.shareRoot = state.ShareRoot
n.ownerRoot = state.OwnerRoot n.ownerRoot = state.OwnerRoot
n.singleFileShare = state.SingleFileShare n.singleFileShare = state.SingleFileShare
n.multiFileShare = state.MultiFileShare
n.share = state.Share n.share = state.Share
n.aclCaps = state.AclCaps n.aclCaps = state.AclCaps
n.owner = state.Owner n.owner = state.Owner
@ -140,6 +144,7 @@ func (n *shareNavigator) Root(ctx context.Context, path *fs.URI) (*File, error)
ctx = context.WithValue(ctx, inventory.LoadShareUser{}, true) ctx = context.WithValue(ctx, inventory.LoadShareUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadUserGroup{}, true) ctx = context.WithValue(ctx, inventory.LoadUserGroup{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true) ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFiles{}, true)
share, err := n.shareClient.GetByHashID(ctx, path.ID(hashid.EncodeUserID(n.hasher, n.user.ID))) share, err := n.shareClient.GetByHashID(ctx, path.ID(hashid.EncodeUserID(n.hasher, n.user.ID)))
if err != nil { if err != nil {
return nil, ErrShareNotFound.WithError(err) return nil, ErrShareNotFound.WithError(err)
@ -168,29 +173,65 @@ func (n *shareNavigator) Root(ctx context.Context, path *fs.URI) (*File, error)
} }
} }
// Share permission setting should overwrite root folder's permission var ownerRoot *File
n.shareRoot = newFile(nil, share.Edges.File) if len(share.Edges.Files) > 0 {
// Multi-file share: a synthetic folder root unions every linked
// file. The anchor file edge still drives validity, ACL and the
// paid gate.
n.multiFileShare = true
n.shareRoot = newFile(nil, &ent.File{
Type: int(types.FileTypeFolder),
Name: share.Edges.File.Name,
})
n.shareRoot.Path[pathIndexUser] = path.Root()
n.shareRoot.OwnerModel = n.owner
n.shareRoot.IsUserRoot = true
n.shareRoot.disableView = (share.Props == nil || !share.Props.ShareView) && n.user.ID != n.owner.ID
n.shareRoot.CapabilitiesBs = n.Capabilities(false).Capability
for _, m := range share.Edges.Files {
if m.FileChildren == 0 {
// Deleted or trashed — excluded like IsValidShare.
continue
}
child, realRoot, err := n.linkSharedFile(ctx, m)
if err != nil || realRoot.Name() != inventory.RootFolderName {
delete(n.shareRoot.Children, m.Name)
continue
}
_ = child
if ownerRoot == nil {
ownerRoot = realRoot
}
}
if ownerRoot == nil {
return nil, ErrShareNotFound
}
} else {
// Share permission setting should overwrite root folder's permission
n.shareRoot = newFile(nil, share.Edges.File)
// Find the user side root of the file. // Find the user side root of the file.
ownerRoot, err := n.findRoot(ctx, n.shareRoot) ownerRoot, err = n.findRoot(ctx, n.shareRoot)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if n.shareRoot.Type() == types.FileTypeFile { if n.shareRoot.Type() == types.FileTypeFile {
n.singleFileShare = true n.singleFileShare = true
n.shareRoot = n.shareRoot.Parent n.shareRoot = n.shareRoot.Parent
} }
n.shareRoot.Path[pathIndexUser] = path.Root() n.shareRoot.Path[pathIndexUser] = path.Root()
n.shareRoot.OwnerModel = n.owner n.shareRoot.OwnerModel = n.owner
n.shareRoot.IsUserRoot = true n.shareRoot.IsUserRoot = true
n.shareRoot.disableView = (share.Props == nil || !share.Props.ShareView) && n.user.ID != n.owner.ID n.shareRoot.disableView = (share.Props == nil || !share.Props.ShareView) && n.user.ID != n.owner.ID
n.shareRoot.CapabilitiesBs = n.Capabilities(false).Capability n.shareRoot.CapabilitiesBs = n.Capabilities(false).Capability
// Check if any ancestors is deleted // Check if any ancestors is deleted
if ownerRoot.Name() != inventory.RootFolderName { if ownerRoot.Name() != inventory.RootFolderName {
return nil, ErrShareNotFound return nil, ErrShareNotFound
}
} }
if n.user.ID != n.owner.ID && !n.user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionShareDownload)) { if n.user.ID != n.owner.ID && !n.user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionShareDownload)) {
@ -227,6 +268,34 @@ func (n *shareNavigator) To(ctx context.Context, path *fs.URI) (*File, error) {
current, lastAncestor := n.shareRoot, n.shareRoot current, lastAncestor := n.shareRoot, n.shareRoot
elements := path.Elements() elements := path.Elements()
// Multi-file share: the first path element must be one of the linked
// files; deeper elements walk into it like a normal folder share.
if n.multiFileShare && len(elements) > 0 {
first, ok := n.shareRoot.Children[elements[0]]
if !ok {
// Restored navigators may carry an empty child map — repopulate
// from the linked file set before failing.
if _, err := n.latestSharedFiles(ctx); err == nil {
first, ok = n.shareRoot.Children[elements[0]]
}
}
if !ok {
return nil, fs.ErrPathNotExist
}
current = first
var err error
for index := 1; index < len(elements); index++ {
lastAncestor = current
current, err = n.walkNext(ctx, current, elements[index], index == len(elements)-1)
if err != nil {
return lastAncestor, fmt.Errorf("failed to walk into %q: %w", elements[index], err)
}
}
return current, nil
}
// If target is root of single file share, the root itself is the target. // If target is root of single file share, the root itself is the target.
if len(elements) == 1 && n.singleFileShare { if len(elements) == 1 && n.singleFileShare {
file, err := n.latestSharedSingleFile(ctx) file, err := n.latestSharedSingleFile(ctx)
@ -284,9 +353,63 @@ func (n *shareNavigator) Children(ctx context.Context, parent *File, args *ListA
}, nil }, nil
} }
// The synthetic root of a multi-file share lists the union of linked
// files instead of any real folder's children.
if n.multiFileShare && (parent == nil || parent.Model == nil || parent.Model.ID == 0) {
files, err := n.latestSharedFiles(ctx)
if err != nil {
return nil, err
}
return &ListResult{
Files: files,
Pagination: &inventory.PaginationResults{},
}, nil
}
return n.baseNavigator.children(ctx, parent, args) return n.baseNavigator.children(ctx, parent, args)
} }
// linkSharedFile attaches a linked file of a multi-file share under the
// synthetic root, then rebuilds its real parent chain so Uri(true)
// resolves to the owner's filesystem. The returned root is the file's
// user-side root; callers validate it against RootFolderName.
func (n *shareNavigator) linkSharedFile(ctx context.Context, m *ent.File) (*File, *File, error) {
child := newFile(n.shareRoot, m)
child.OwnerModel = n.owner
realRoot, err := n.findRoot(ctx, child)
if err != nil {
return nil, nil, err
}
realRoot.Path[pathIndexRoot] = newMyIDUri(hashid.EncodeUserID(n.hasher, n.owner.ID))
return child, realRoot, nil
}
// latestSharedFiles reloads every linked file of a multi-file share so
// deleted entries drop out of the listing. Each result is also registered
// under shareRoot.Children for To() resolution.
func (n *shareNavigator) latestSharedFiles(ctx context.Context) ([]*File, error) {
files := make([]*File, 0, len(n.share.Edges.Files))
for _, m := range n.share.Edges.Files {
file, err := n.fileClient.GetByID(ctx, m.ID)
if err != nil {
continue
}
child, realRoot, err := n.linkSharedFile(ctx, file)
if err != nil || realRoot.Name() != inventory.RootFolderName {
delete(n.shareRoot.Children, file.Name)
continue
}
files = append(files, child)
}
return files, nil
}
func (n *shareNavigator) latestSharedSingleFile(ctx context.Context) (*File, error) { func (n *shareNavigator) latestSharedSingleFile(ctx context.Context) (*File, error) {
if n.singleFileShare { if n.singleFileShare {
file, err := n.fileClient.GetByID(ctx, n.share.Edges.File.ID) file, err := n.fileClient.GetByID(ctx, n.share.Edges.File.ID)
@ -322,14 +445,35 @@ func (n *shareNavigator) Capabilities(isSearching bool) *fs.NavigatorProps {
// shareCapabilities derives the effective capability set from share props. // shareCapabilities derives the effective capability set from share props.
func (n *shareNavigator) shareCapabilities() *boolset.BooleanSet { func (n *shareNavigator) shareCapabilities() *boolset.BooleanSet {
var bs *boolset.BooleanSet
// Matched ACL entries fully define a non-owner visitor's capabilities; // Matched ACL entries fully define a non-owner visitor's capabilities;
// when no entry matched (nil) share props apply as the link default. // when no entry matched (nil) share props apply as the link default.
if n.aclCaps != nil && n.owner != nil && n.user.ID != n.owner.ID { if n.aclCaps != nil && n.owner != nil && n.user.ID != n.owner.ID {
bs := aclPermsToCapabilities(n.aclCaps) bs = aclPermsToCapabilities(n.aclCaps)
n.stripUnpaid(bs) } else {
return bs bs = n.propsCapabilities()
}
// Multi-file shares are a read/download union: write targets are
// ambiguous at the synthetic root, which owns no real folder row.
if n.multiFileShare {
boolset.Sets(map[NavigatorCapability]bool{
NavigatorCapabilityUploadFile: false,
NavigatorCapabilityCreateFile: false,
NavigatorCapabilityLockFile: false,
NavigatorCapabilityRenameFile: false,
NavigatorCapabilityDeleteFile: false,
NavigatorCapabilitySoftDelete: false,
NavigatorCapabilityUpdateMetadata: false,
}, bs)
} }
n.stripUnpaid(bs)
return bs
}
// propsCapabilities maps share props to the default link capability set.
func (n *shareNavigator) propsCapabilities() *boolset.BooleanSet {
bs := &boolset.BooleanSet{} bs := &boolset.BooleanSet{}
boolset.Sets(map[NavigatorCapability]bool{ boolset.Sets(map[NavigatorCapability]bool{
NavigatorCapabilityListChildren: true, NavigatorCapabilityListChildren: true,
@ -376,7 +520,6 @@ func (n *shareNavigator) shareCapabilities() *boolset.BooleanSet {
boolset.Set(int(NavigatorCapabilitySoftDelete), true, bs) boolset.Set(int(NavigatorCapabilitySoftDelete), true, bs)
} }
n.stripUnpaid(bs)
return bs return bs
} }

@ -84,8 +84,9 @@ type (
} }
ShareManagement interface { ShareManagement interface {
// CreateShare creates a share link for given path // CreateShare creates a share link for given paths. Multiple paths
CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *CreateShareArgs) (*ent.Share, error) // create a multi-file share anchored on the first path.
CreateOrUpdateShare(ctx context.Context, paths []*fs.URI, args *CreateShareArgs) (*ent.Share, error)
} }
Archiver interface { Archiver interface {

@ -311,25 +311,43 @@ func (l *manager) Restore(ctx context.Context, path ...*fs.URI) error {
return l.fs.Restore(ctx, path...) return l.fs.Restore(ctx, path...)
} }
func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *CreateShareArgs) (*ent.Share, error) { func (l *manager) CreateOrUpdateShare(ctx context.Context, paths []*fs.URI, args *CreateShareArgs) (*ent.Share, error) {
file, err := l.fs.Get(ctx, path, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityShare), dbfs.WithNotRoot()) if len(paths) == 0 {
if err != nil { return nil, serializer.NewError(serializer.CodeParamErr, "src file not found", nil)
return nil, serializer.NewError(serializer.CodeNotFound, "src file not found", err)
} }
// Only file owner can share file files := make([]fs.File, 0, len(paths))
if file.OwnerID() != l.user.ID { seen := make(map[int]struct{}, len(paths))
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "permission denied", nil) for _, path := range paths {
} file, err := l.fs.Get(ctx, path, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityShare), dbfs.WithNotRoot())
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "src file not found", err)
}
// Only file owner can share file
if file.OwnerID() != l.user.ID {
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "permission denied", nil)
}
if file.IsSymbolic() { if file.IsSymbolic() {
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "cannot share symbolic file", nil) return nil, serializer.NewError(serializer.CodeNoPermissionErr, "cannot share symbolic file", nil)
}
if _, ok := seen[file.ID()]; !ok {
seen[file.ID()] = struct{}{}
files = append(files, file)
}
} }
var existed *ent.Share file := files[0]
var (
existed *ent.Share
err error
)
shareClient := l.dep.ShareClient() shareClient := l.dep.ShareClient()
if args.ExistedShareID != 0 { if args.ExistedShareID != 0 {
loadShareCtx := context.WithValue(ctx, inventory.LoadShareFile{}, true) loadShareCtx := context.WithValue(ctx, inventory.LoadShareFile{}, true)
loadShareCtx = context.WithValue(loadShareCtx, inventory.LoadShareFiles{}, true)
existed, err = shareClient.GetByID(loadShareCtx, args.ExistedShareID) existed, err = shareClient.GetByID(loadShareCtx, args.ExistedShareID)
if err != nil { if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "failed to get existed share", err) return nil, serializer.NewError(serializer.CodeNotFound, "failed to get existed share", err)
@ -338,6 +356,10 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C
if existed.Edges.File.ID != file.ID() { if existed.Edges.File.ID != file.ID() {
return nil, serializer.NewError(serializer.CodeNotFound, "share link not found", nil) return nil, serializer.NewError(serializer.CodeNotFound, "share link not found", nil)
} }
if len(existed.Edges.Files) > 0 && args.UploadOnly {
return nil, serializer.NewError(serializer.CodeParamErr, "upload-only shares cannot cover multiple files", nil)
}
} }
password := "" password := ""
@ -358,9 +380,11 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C
Note: args.Note, Note: args.Note,
} }
fileIDs := lo.Map(files, func(f fs.File, _ int) int { return f.ID() })
share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{ share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{
OwnerID: file.OwnerID(), OwnerID: file.OwnerID(),
FileID: file.ID(), FileID: file.ID(),
FileIDs: fileIDs,
Password: password, Password: password,
Expires: args.Expire, Expires: args.Expire,
RemainDownloads: args.RemainDownloads, RemainDownloads: args.RemainDownloads,

@ -360,6 +360,10 @@ type Share struct {
// PurchaseTicket is the requester's resume credential; only set for // PurchaseTicket is the requester's resume credential; only set for
// the purchasing user, never for the owner or other visitors. // the purchasing user, never for the owner or other visitors.
PurchaseTicket string `json:"purchase_ticket,omitempty"` PurchaseTicket string `json:"purchase_ticket,omitempty"`
// FileCount is the linked file count of a multi-file share; 0 for
// legacy single-file shares.
FileCount int `json:"file_count,omitempty"`
} }
func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid.Encoder, requester *ent.User, owner *ent.User, func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid.Encoder, requester *ent.User, owner *ent.User,

@ -21,17 +21,18 @@ import (
type ( type (
// ShareCreateService 创建新分享服务 // ShareCreateService 创建新分享服务
ShareCreateService struct { ShareCreateService struct {
Uri string `json:"uri" binding:"required"` Uri string `json:"uri" binding:"required_without=Uris"`
IsPrivate bool `json:"is_private"` Uris []string `json:"uris" binding:"omitempty,min=1,max=50,dive,required"`
Password string `json:"password" binding:"omitempty,max=32"` IsPrivate bool `json:"is_private"`
RemainDownloads int `json:"downloads"` Password string `json:"password" binding:"omitempty,max=32"`
Expire int `json:"expire"` RemainDownloads int `json:"downloads"`
ShareView bool `json:"share_view"` Expire int `json:"expire"`
ShowReadMe bool `json:"show_readme"` ShareView bool `json:"share_view"`
AllowUpload bool `json:"allow_upload"` ShowReadMe bool `json:"show_readme"`
AllowEdit bool `json:"allow_edit"` AllowUpload bool `json:"allow_upload"`
PreviewOnly bool `json:"preview_only"` AllowEdit bool `json:"allow_edit"`
UploadOnly bool `json:"upload_only"` PreviewOnly bool `json:"preview_only"`
UploadOnly bool `json:"upload_only"`
// Optional owner-defined note shown on My Shares (#3570). // Optional owner-defined note shown on My Shares (#3570).
Note string `json:"note" binding:"omitempty,max=255"` Note string `json:"note" binding:"omitempty,max=255"`
// Points price visitors must pay before downloading. 0 = free share. // Points price visitors must pay before downloading. 0 = free share.
@ -87,9 +88,29 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string,
return "", serializer.NewError(serializer.CodeGroupNotAllowed, "Group permission denied for paid share", nil) return "", serializer.NewError(serializer.CodeGroupNotAllowed, "Group permission denied for paid share", nil)
} }
uri, err := fs.NewUriFromString(service.Uri) rawUris := service.Uris
if err != nil { if len(rawUris) == 0 && service.Uri != "" {
return "", serializer.NewError(serializer.CodeParamErr, "unknown uri", err) rawUris = []string{service.Uri}
}
if len(rawUris) > 1 && service.UploadOnly {
return "", serializer.NewError(serializer.CodeParamErr, "upload-only shares cannot cover multiple files", nil)
}
uris := make([]*fs.URI, 0, len(rawUris))
seenUris := make(map[string]struct{}, len(rawUris))
for _, raw := range rawUris {
if _, ok := seenUris[raw]; ok {
continue
}
seenUris[raw] = struct{}{}
uri, err := fs.NewUriFromString(raw)
if err != nil {
return "", serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
}
uris = append(uris, uri)
}
if len(uris) == 0 {
return "", serializer.NewError(serializer.CodeParamErr, "unknown uri", nil)
} }
var expires *time.Time var expires *time.Time
@ -98,7 +119,7 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string,
*expires = time.Now().Add(time.Duration(service.Expire) * time.Second) *expires = time.Now().Add(time.Duration(service.Expire) * time.Second)
} }
share, err := m.CreateOrUpdateShare(c, uri, &manager.CreateShareArgs{ share, err := m.CreateOrUpdateShare(c, uris, &manager.CreateShareArgs{
IsPrivate: service.IsPrivate, IsPrivate: service.IsPrivate,
Password: service.Password, Password: service.Password,
RemainDownloads: service.RemainDownloads, RemainDownloads: service.RemainDownloads,

@ -37,8 +37,15 @@ func BuildListShareResponse(ctx context.Context, res *inventory.ListShareResult,
} }
} }
infos = append(infos, *explorer.BuildShare(ctx, share, base, hasher, requester, share.Edges.User, shareName, sourceType := types.FileType(share.Edges.File.Type)
types.FileType(share.Edges.File.Type), unlocked, expired)) if len(share.Edges.Files) > 0 {
sourceType = types.FileTypeFolder
}
built := explorer.BuildShare(ctx, share, base, hasher, requester, share.Edges.User, shareName,
sourceType, unlocked, expired)
built.FileCount = len(share.Edges.Files)
infos = append(infos, *built)
} }
return &ListShareResponse{ return &ListShareResponse{

@ -66,6 +66,7 @@ func (s *ShareInfoService) Get(c *gin.Context) (*explorer.Share, error) {
ctx := context.WithValue(c, inventory.LoadShareUser{}, true) ctx := context.WithValue(c, inventory.LoadShareUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true) ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFiles{}, true)
share, err := shareClient.GetByID(ctx, hashid.FromContext(c)) share, err := shareClient.GetByID(ctx, hashid.FromContext(c))
if err != nil { if err != nil {
if ent.IsNotFound(err) { if ent.IsNotFound(err) {
@ -91,8 +92,15 @@ func (s *ShareInfoService) Get(c *gin.Context) (*explorer.Share, error) {
} }
base := dep.SettingProvider().SiteURL(c) base := dep.SettingProvider().SiteURL(c)
sourceType := types.FileType(share.Edges.File.Type)
if len(share.Edges.Files) > 0 {
// Multi-file shares render as a folder listing regardless of the
// anchor file's type.
sourceType = types.FileTypeFolder
}
res := explorer.BuildShare(c, share, base, dep.HashIDEncoder(), u, share.Edges.User, share.Edges.File.Name, res := explorer.BuildShare(c, share, base, dep.HashIDEncoder(), u, share.Edges.User, share.Edges.File.Name,
types.FileType(share.Edges.File.Type), unlocked, false) sourceType, unlocked, false)
res.FileCount = len(share.Edges.Files)
// Priced shares resolve the requester's payment state: owner, existing // Priced shares resolve the requester's payment state: owner, existing
// buyer, or bearer of a valid resume ticket. // buyer, or bearer of a valid resume ticket.
@ -129,12 +137,22 @@ func (s *ShareInfoService) Get(c *gin.Context) (*explorer.Share, error) {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Invalid share url", err) return nil, serializer.NewError(serializer.CodeInternalSetting, "Invalid share url", err)
} }
root, err := m.Get(c, shareUri) if len(share.Edges.Files) > 0 {
if err != nil { // For multi-file shares point source_uri at the anchor's real
return nil, serializer.NewError(serializer.CodeNotFound, "File not found", err) // path so the owner edit dialog resolves a concrete file.
} anchor, err := m.Get(c, shareUri.Join(share.Edges.File.Name))
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "File not found", err)
}
res.SourceUri = anchor.Uri(true).String()
} else {
root, err := m.Get(c, shareUri)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "File not found", err)
}
res.SourceUri = root.Uri(true).String() res.SourceUri = root.Uri(true).String()
}
} }
return res, nil return res, nil
@ -170,6 +188,7 @@ func (s *ListShareService) List(c *gin.Context) (*ListShareResponse, error) {
ctx := context.WithValue(c, inventory.LoadShareUser{}, true) ctx := context.WithValue(c, inventory.LoadShareUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true) ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFiles{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileMetadata{}, true) ctx = context.WithValue(ctx, inventory.LoadFileMetadata{}, true)
res, err := shareClient.List(ctx, args) res, err := shareClient.List(ctx, args)
if err != nil { if err != nil {
@ -219,6 +238,7 @@ func (s *ListShareService) ListInUserProfile(c *gin.Context, uid int) (*ListShar
ctx := context.WithValue(c, inventory.LoadShareUser{}, true) ctx := context.WithValue(c, inventory.LoadShareUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true) ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFiles{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileMetadata{}, true) ctx = context.WithValue(ctx, inventory.LoadFileMetadata{}, true)
res, err := shareClient.List(ctx, args) res, err := shareClient.List(ctx, args)
if err != nil { if err != nil {

Loading…
Cancel
Save