Merge remote-tracking branch 'origin/master' into feat/markdown-editor-lag

Resolve ROADMAP adjacency: keep both #198 and #199 entries.

Authored By: TDvorak <info@tdvorak.dev>

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
commit 8dcb6da51f

@ -0,0 +1,60 @@
name: Desktop Release
# Tag `desktop-v*` builds and publishes the Tauri desktop app for
# Windows, macOS and Linux. Server releases stay on the `v*` tags
# handled by release.yml (goreleaser).
on:
push:
tags: ['desktop-v*']
workflow_dispatch:
permissions:
contents: write
jobs:
bundle:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
cache-dependency-path: desktop/ui/yarn.lock
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: desktop
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev patchelf
- name: Install UI dependencies
working-directory: desktop/ui
run: yarn install --frozen-lockfile
# tauri-action runs `tauri build` (which triggers beforeBuildCommand ->
# the UI build), produces the platform bundles and attaches them to the
# GitHub release for the pushed tag:
# Windows -> .msi + .exe (NSIS), macOS -> .dmg + .app.tar.gz,
# Linux -> .deb + .AppImage
# MSIX requires separate packaging + store signing; not covered here.
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: desktop
tagName: ${{ github.ref_name }}
releaseName: 'Cloudreve Desktop ${{ github.ref_name }}'
releaseBody: 'Cloudreve Desktop for Linux, macOS, and Windows.'
releaseDraft: false
prerelease: false

@ -205,8 +205,8 @@ Order = user-visible value first; each ships with backend + UI + tests.
- Own security review on top of upstream fixes: session/token entropy audit, SSRF guard re-test (NAT64 class), rate limiting on auth endpoints
- Fix upstream bug backlog by impact: ~~#3574 OOM~~ (done — paged tree walk + batched delete), ~~#3118/#3005 WebDAV large-file~~ (done — Content-Range assembly into one session; non-local policies get honest 501; single-PUT giant-file 500s are proxy/client timeouts, not fixable server-side), ~~#3375 SMTP auth discovery~~ (done — `smtp_auth` setting)
- #3454 (PG FK on upload) is **Pro-only** — `audit_logs` doesn't exist in this codebase. When B.5 adds our own audit log: insert the audit row in the same tx *after* the file row, never before.
- [x] #198 (upstream #3581) — "import files" task shows source storage policy "unknown": `ImportTaskState.PolicyName` baked at creation (resolved best-effort via `StoragePolicyClient`), summary emits `dst_policy_name` so admin views of other users' tasks work without a policy lookup; `policyOptionCache` retyped to `StoragePolicyBrief[]` and populated from `getAllowedPolicies()` at session init as fallback for legacy tasks
- [x] #199 (upstream #3584) — markdown editor lag: root cause was per-keystroke React re-renders (changedValue state fed back into the editor's initial-markdown prop) re-running every plugin's `update()` hook (RealmWithPlugins has a dep-less effect). `MarkdownEditor` is now `memo`'d with a `useMemo`'d plugins array + stable `translation`; `MarkdownViewer` keeps edits in a ref (read at save) and passes the immutable loaded content — zero re-renders per keystroke
- [ ] #198 (upstream #3581) — "import files" task shows source storage policy "unknown": check task props → policy name resolution in admin import path (Pro report, likely same code path in CE)
- [ ] #200 (upstream #3586) — Pro crash on SIGHUP; log shows a clean signal-driven shutdown, no stack trace — watch for a CE repro, likely not actionable yet
- [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety)
- [x] Tag management page (upstream #2962) — owner-scoped `tag:` metadata stats/rename/recolor/delete in `inventory.FileClient`, `GET/PATCH/DELETE /file/tag` routes, Settings → Tags tab with merge-on-rename semantics
@ -215,6 +215,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] Saved share links (#147) — `POST /file/create` accepts `type: share` + `share_id`/`share_password`, materializing a symbolic shortcut (`sys:shared_redirect`) that lists under My Files and Shared with me; "Save to my files" in the share popover + "Save share link" dialog on /shares
- [x] Share `hide_readme` option (upstream #2729 item 6) — `ShareProps.HideReadMe` (only meaningful with `ShowReadMe`); share navigator filters `README.md`/`README.txt` (case-insensitive) from listings while direct-path resolution stays open for the readme viewer; `detectReadMe` URI fallback now probes unconditionally; owner-only `hide_readme` on share responses; Share dialog nested checkbox
- [x] 2FA recovery codes (upstream #2729 item 3) — `user.two_factor_backup_codes` sensitive JSON of `salt:sha256` digests; `PUT /user/setting/2fa/backup` regenerates 10 one-time codes behind a valid TOTP (rate-limited 5/h); `Verify2FA` falls back to single-use code consumption on TOTP failure; codes invalidated on secret rotation/disable; login phase gains a recovery-code input mode; security settings show remaining count + regenerate dialog
- [x] Public share directory (upstream #2729 items 4+5) — `share.listed_publicly` opt-in column gated by new `GroupPermissionSharePublicList` group bit; rejected on password-protected shares at the service layer and normalized off at creation; `GET /share/listed` anonymous endpoint (rate-limited 60/min/IP, cursor pagination) listing only non-expired passwordless listed shares with case-insensitive name search across anchor + covered files; `listed_publicly` owner-visible in share responses; `/discover` page (anonymous-visible nav item + sign-in link), admin group Share section switch, en+zh locales
## 6. Phase D — desktop, all platforms
@ -224,10 +225,11 @@ Goal: Windows + macOS + Linux from the `desktop/` tree in this repo.
|---|---|---|---|
| Placeholders/hydration | cfapi (keep) | File Provider ext (Swift bridge) | FUSE (`fuser`) or plain sync folder |
| Shell integration | shellext (keep) | Finder sync extension | Nautilus/Dolphin plugin (later) |
| Notifications | win32_notif → replace | `tauri-plugin-notification` (all platforms) | same |
| Notifications | win32_notif | `mac_notification_sys` | `notify_rust` |
| Sync core | shared: `cloudreve-api`, `inventory`, `tasks`, `uploader`, `drive/sync` | same | same |
- Port order: (1) strip `win32_notif`→tauri notifications (all platforms benefit), (2) abstract `drive/` behind a `HydrationProvider` trait (cfapi impl on Windows, stub→FUSE on Linux, FileProvider on macOS), (3) CI matrix build all 3, (4) MSIX→also ship .dmg/.AppImage/.deb.
- Status: (1) notifications already per-OS (`win32_notif` / `notify_rust` / `mac_notification_sys`) — no abstraction needed; (2) hydration abstracted via `drive/placeholder` cfg swap — `cfapi` on Windows, `placeholder_non_windows` full-sync adapter elsewhere (FUSE / File Provider still open); (3) CI matrix builds + tests all 3 OSes; (4) packaging: `desktop-release.yml` on `desktop-v*` tags ships .msi/.exe (Windows), .dmg (macOS), .deb/.AppImage (Linux) — MSIX deferred (needs store signing).
- Verified on Linux: `cargo test --workspace` green (49 tests), `cargo tauri build` produces working .deb + .AppImage.
- Feature fallback on Linux/macOS until providers land: full sync without placeholders (download-on-access still works via sync engine).
## 7. Phase E — Android app (native, no iOS)

@ -6,8 +6,8 @@
"build": {
"frontendDist": "../ui/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "yarn run dev",
"beforeBuildCommand": "yarn run build"
"beforeDevCommand": "yarn --cwd ui run dev",
"beforeBuildCommand": "yarn --cwd ui run build"
},
"app": {
"enableGTKAppId": true,
@ -38,7 +38,7 @@
},
"bundle": {
"active": true,
"targets": [],
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",

File diff suppressed because one or more lines are too long

@ -587,6 +587,7 @@ var (
{Name: "expires", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "remain_downloads", Type: field.TypeInt, Nullable: true},
{Name: "price_points", Type: field.TypeInt, Default: 0},
{Name: "listed_publicly", Type: field.TypeBool, Default: false},
{Name: "props", Type: field.TypeJSON, Nullable: true},
{Name: "file_shares", Type: field.TypeInt, Nullable: true},
{Name: "user_shares", Type: field.TypeInt, Nullable: true},
@ -599,13 +600,13 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "shares_files_shares",
Columns: []*schema.Column{SharesColumns[11]},
Columns: []*schema.Column{SharesColumns[12]},
RefColumns: []*schema.Column{FilesColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "shares_users_shares",
Columns: []*schema.Column{SharesColumns[12]},
Columns: []*schema.Column{SharesColumns[13]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.SetNull,
},

@ -17467,6 +17467,7 @@ type ShareMutation struct {
addremain_downloads *int
price_points *int
addprice_points *int
listed_publicly *bool
props **types.ShareProps
clearedFields map[string]struct{}
user *int
@ -18039,6 +18040,42 @@ func (m *ShareMutation) ResetPricePoints() {
m.addprice_points = nil
}
// SetListedPublicly sets the "listed_publicly" field.
func (m *ShareMutation) SetListedPublicly(b bool) {
m.listed_publicly = &b
}
// ListedPublicly returns the value of the "listed_publicly" field in the mutation.
func (m *ShareMutation) ListedPublicly() (r bool, exists bool) {
v := m.listed_publicly
if v == nil {
return
}
return *v, true
}
// OldListedPublicly returns the old "listed_publicly" field's value of the Share entity.
// If the Share object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ShareMutation) OldListedPublicly(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldListedPublicly is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldListedPublicly requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldListedPublicly: %w", err)
}
return oldValue.ListedPublicly, nil
}
// ResetListedPublicly resets all changes to the "listed_publicly" field.
func (m *ShareMutation) ResetListedPublicly() {
m.listed_publicly = nil
}
// SetProps sets the "props" field.
func (m *ShareMutation) SetProps(tp *types.ShareProps) {
m.props = &tp
@ -18308,7 +18345,7 @@ func (m *ShareMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *ShareMutation) Fields() []string {
fields := make([]string, 0, 10)
fields := make([]string, 0, 11)
if m.created_at != nil {
fields = append(fields, share.FieldCreatedAt)
}
@ -18336,6 +18373,9 @@ func (m *ShareMutation) Fields() []string {
if m.price_points != nil {
fields = append(fields, share.FieldPricePoints)
}
if m.listed_publicly != nil {
fields = append(fields, share.FieldListedPublicly)
}
if m.props != nil {
fields = append(fields, share.FieldProps)
}
@ -18365,6 +18405,8 @@ func (m *ShareMutation) Field(name string) (ent.Value, bool) {
return m.RemainDownloads()
case share.FieldPricePoints:
return m.PricePoints()
case share.FieldListedPublicly:
return m.ListedPublicly()
case share.FieldProps:
return m.Props()
}
@ -18394,6 +18436,8 @@ func (m *ShareMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldRemainDownloads(ctx)
case share.FieldPricePoints:
return m.OldPricePoints(ctx)
case share.FieldListedPublicly:
return m.OldListedPublicly(ctx)
case share.FieldProps:
return m.OldProps(ctx)
}
@ -18468,6 +18512,13 @@ func (m *ShareMutation) SetField(name string, value ent.Value) error {
}
m.SetPricePoints(v)
return nil
case share.FieldListedPublicly:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetListedPublicly(v)
return nil
case share.FieldProps:
v, ok := value.(*types.ShareProps)
if !ok {
@ -18635,6 +18686,9 @@ func (m *ShareMutation) ResetField(name string) error {
case share.FieldPricePoints:
m.ResetPricePoints()
return nil
case share.FieldListedPublicly:
m.ResetListedPublicly()
return nil
case share.FieldProps:
m.ResetProps()
return nil

@ -495,6 +495,10 @@ func init() {
share.DefaultPricePoints = shareDescPricePoints.Default.(int)
// share.PricePointsValidator is a validator for the "price_points" field. It is called by the builders before save.
share.PricePointsValidator = shareDescPricePoints.Validators[0].(func(int) error)
// shareDescListedPublicly is the schema descriptor for listed_publicly field.
shareDescListedPublicly := shareFields[6].Descriptor()
// share.DefaultListedPublicly holds the default value on creation for the listed_publicly field.
share.DefaultListedPublicly = shareDescListedPublicly.Default.(bool)
sharepurchaseMixin := schema.SharePurchase{}.Mixin()
sharepurchaseMixinHooks0 := sharepurchaseMixin[0].Hooks()
sharepurchase.Hooks[0] = sharepurchaseMixinHooks0[0]

@ -35,6 +35,10 @@ func (Share) Fields() []ent.Field {
field.Int("price_points").
Default(0).
NonNegative(),
// Listed in the public share directory. Never settable on
// password-protected shares; enforced at the service layer.
field.Bool("listed_publicly").
Default(false),
field.JSON("props", &types.ShareProps{}).Optional(),
}
}

@ -39,6 +39,8 @@ type Share struct {
RemainDownloads *int `json:"remain_downloads,omitempty"`
// PricePoints holds the value of the "price_points" field.
PricePoints int `json:"price_points,omitempty"`
// ListedPublicly holds the value of the "listed_publicly" field.
ListedPublicly bool `json:"listed_publicly,omitempty"`
// Props holds the value of the "props" field.
Props *types.ShareProps `json:"props,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
@ -115,6 +117,8 @@ func (*Share) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case share.FieldProps:
values[i] = new([]byte)
case share.FieldListedPublicly:
values[i] = new(sql.NullBool)
case share.FieldID, share.FieldViews, share.FieldDownloads, share.FieldRemainDownloads, share.FieldPricePoints:
values[i] = new(sql.NullInt64)
case share.FieldPassword:
@ -203,6 +207,12 @@ func (s *Share) assignValues(columns []string, values []any) error {
} else if value.Valid {
s.PricePoints = int(value.Int64)
}
case share.FieldListedPublicly:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field listed_publicly", values[i])
} else if value.Valid {
s.ListedPublicly = value.Bool
}
case share.FieldProps:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field props", values[i])
@ -314,6 +324,9 @@ func (s *Share) String() string {
builder.WriteString("price_points=")
builder.WriteString(fmt.Sprintf("%v", s.PricePoints))
builder.WriteString(", ")
builder.WriteString("listed_publicly=")
builder.WriteString(fmt.Sprintf("%v", s.ListedPublicly))
builder.WriteString(", ")
builder.WriteString("props=")
builder.WriteString(fmt.Sprintf("%v", s.Props))
builder.WriteByte(')')

@ -33,6 +33,8 @@ const (
FieldRemainDownloads = "remain_downloads"
// FieldPricePoints holds the string denoting the price_points field in the database.
FieldPricePoints = "price_points"
// FieldListedPublicly holds the string denoting the listed_publicly field in the database.
FieldListedPublicly = "listed_publicly"
// FieldProps holds the string denoting the props field in the database.
FieldProps = "props"
// EdgeUser holds the string denoting the user edge name in mutations.
@ -85,6 +87,7 @@ var Columns = []string{
FieldExpires,
FieldRemainDownloads,
FieldPricePoints,
FieldListedPublicly,
FieldProps,
}
@ -138,6 +141,8 @@ var (
DefaultPricePoints int
// PricePointsValidator is a validator for the "price_points" field. It is called by the builders before save.
PricePointsValidator func(int) error
// DefaultListedPublicly holds the default value on creation for the "listed_publicly" field.
DefaultListedPublicly bool
)
// OrderOption defines the ordering options for the Share queries.
@ -193,6 +198,11 @@ func ByPricePoints(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPricePoints, opts...).ToFunc()
}
// ByListedPublicly orders the results by the listed_publicly field.
func ByListedPublicly(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldListedPublicly, opts...).ToFunc()
}
// ByUserField orders the results by user field.
func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {

@ -100,6 +100,11 @@ func PricePoints(v int) predicate.Share {
return predicate.Share(sql.FieldEQ(FieldPricePoints, v))
}
// ListedPublicly applies equality check predicate on the "listed_publicly" field. It's identical to ListedPubliclyEQ.
func ListedPublicly(v bool) predicate.Share {
return predicate.Share(sql.FieldEQ(FieldListedPublicly, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.Share {
return predicate.Share(sql.FieldEQ(FieldCreatedAt, v))
@ -525,6 +530,16 @@ func PricePointsLTE(v int) predicate.Share {
return predicate.Share(sql.FieldLTE(FieldPricePoints, v))
}
// ListedPubliclyEQ applies the EQ predicate on the "listed_publicly" field.
func ListedPubliclyEQ(v bool) predicate.Share {
return predicate.Share(sql.FieldEQ(FieldListedPublicly, v))
}
// ListedPubliclyNEQ applies the NEQ predicate on the "listed_publicly" field.
func ListedPubliclyNEQ(v bool) predicate.Share {
return predicate.Share(sql.FieldNEQ(FieldListedPublicly, v))
}
// PropsIsNil applies the IsNil predicate on the "props" field.
func PropsIsNil() predicate.Share {
return predicate.Share(sql.FieldIsNull(FieldProps))

@ -152,6 +152,20 @@ func (sc *ShareCreate) SetNillablePricePoints(i *int) *ShareCreate {
return sc
}
// SetListedPublicly sets the "listed_publicly" field.
func (sc *ShareCreate) SetListedPublicly(b bool) *ShareCreate {
sc.mutation.SetListedPublicly(b)
return sc
}
// SetNillableListedPublicly sets the "listed_publicly" field if the given value is not nil.
func (sc *ShareCreate) SetNillableListedPublicly(b *bool) *ShareCreate {
if b != nil {
sc.SetListedPublicly(*b)
}
return sc
}
// SetProps sets the "props" field.
func (sc *ShareCreate) SetProps(tp *types.ShareProps) *ShareCreate {
sc.mutation.SetProps(tp)
@ -289,6 +303,10 @@ func (sc *ShareCreate) defaults() error {
v := share.DefaultPricePoints
sc.mutation.SetPricePoints(v)
}
if _, ok := sc.mutation.ListedPublicly(); !ok {
v := share.DefaultListedPublicly
sc.mutation.SetListedPublicly(v)
}
return nil
}
@ -314,6 +332,9 @@ func (sc *ShareCreate) check() error {
return &ValidationError{Name: "price_points", err: fmt.Errorf(`ent: validator failed for field "Share.price_points": %w`, err)}
}
}
if _, ok := sc.mutation.ListedPublicly(); !ok {
return &ValidationError{Name: "listed_publicly", err: errors.New(`ent: missing required field "Share.listed_publicly"`)}
}
return nil
}
@ -384,6 +405,10 @@ func (sc *ShareCreate) createSpec() (*Share, *sqlgraph.CreateSpec) {
_spec.SetField(share.FieldPricePoints, field.TypeInt, value)
_node.PricePoints = value
}
if value, ok := sc.mutation.ListedPublicly(); ok {
_spec.SetField(share.FieldListedPublicly, field.TypeBool, value)
_node.ListedPublicly = value
}
if value, ok := sc.mutation.Props(); ok {
_spec.SetField(share.FieldProps, field.TypeJSON, value)
_node.Props = value
@ -650,6 +675,18 @@ func (u *ShareUpsert) AddPricePoints(v int) *ShareUpsert {
return u
}
// SetListedPublicly sets the "listed_publicly" field.
func (u *ShareUpsert) SetListedPublicly(v bool) *ShareUpsert {
u.Set(share.FieldListedPublicly, v)
return u
}
// UpdateListedPublicly sets the "listed_publicly" field to the value that was provided on create.
func (u *ShareUpsert) UpdateListedPublicly() *ShareUpsert {
u.SetExcluded(share.FieldListedPublicly)
return u
}
// SetProps sets the "props" field.
func (u *ShareUpsert) SetProps(v *types.ShareProps) *ShareUpsert {
u.Set(share.FieldProps, v)
@ -881,6 +918,20 @@ func (u *ShareUpsertOne) UpdatePricePoints() *ShareUpsertOne {
})
}
// SetListedPublicly sets the "listed_publicly" field.
func (u *ShareUpsertOne) SetListedPublicly(v bool) *ShareUpsertOne {
return u.Update(func(s *ShareUpsert) {
s.SetListedPublicly(v)
})
}
// UpdateListedPublicly sets the "listed_publicly" field to the value that was provided on create.
func (u *ShareUpsertOne) UpdateListedPublicly() *ShareUpsertOne {
return u.Update(func(s *ShareUpsert) {
s.UpdateListedPublicly()
})
}
// SetProps sets the "props" field.
func (u *ShareUpsertOne) SetProps(v *types.ShareProps) *ShareUpsertOne {
return u.Update(func(s *ShareUpsert) {
@ -1286,6 +1337,20 @@ func (u *ShareUpsertBulk) UpdatePricePoints() *ShareUpsertBulk {
})
}
// SetListedPublicly sets the "listed_publicly" field.
func (u *ShareUpsertBulk) SetListedPublicly(v bool) *ShareUpsertBulk {
return u.Update(func(s *ShareUpsert) {
s.SetListedPublicly(v)
})
}
// UpdateListedPublicly sets the "listed_publicly" field to the value that was provided on create.
func (u *ShareUpsertBulk) UpdateListedPublicly() *ShareUpsertBulk {
return u.Update(func(s *ShareUpsert) {
s.UpdateListedPublicly()
})
}
// SetProps sets the "props" field.
func (u *ShareUpsertBulk) SetProps(v *types.ShareProps) *ShareUpsertBulk {
return u.Update(func(s *ShareUpsert) {

@ -188,6 +188,20 @@ func (su *ShareUpdate) AddPricePoints(i int) *ShareUpdate {
return su
}
// SetListedPublicly sets the "listed_publicly" field.
func (su *ShareUpdate) SetListedPublicly(b bool) *ShareUpdate {
su.mutation.SetListedPublicly(b)
return su
}
// SetNillableListedPublicly sets the "listed_publicly" field if the given value is not nil.
func (su *ShareUpdate) SetNillableListedPublicly(b *bool) *ShareUpdate {
if b != nil {
su.SetListedPublicly(*b)
}
return su
}
// SetProps sets the "props" field.
func (su *ShareUpdate) SetProps(tp *types.ShareProps) *ShareUpdate {
su.mutation.SetProps(tp)
@ -439,6 +453,9 @@ func (su *ShareUpdate) sqlSave(ctx context.Context) (n int, err error) {
if value, ok := su.mutation.AddedPricePoints(); ok {
_spec.AddField(share.FieldPricePoints, field.TypeInt, value)
}
if value, ok := su.mutation.ListedPublicly(); ok {
_spec.SetField(share.FieldListedPublicly, field.TypeBool, value)
}
if value, ok := su.mutation.Props(); ok {
_spec.SetField(share.FieldProps, field.TypeJSON, value)
}
@ -769,6 +786,20 @@ func (suo *ShareUpdateOne) AddPricePoints(i int) *ShareUpdateOne {
return suo
}
// SetListedPublicly sets the "listed_publicly" field.
func (suo *ShareUpdateOne) SetListedPublicly(b bool) *ShareUpdateOne {
suo.mutation.SetListedPublicly(b)
return suo
}
// SetNillableListedPublicly sets the "listed_publicly" field if the given value is not nil.
func (suo *ShareUpdateOne) SetNillableListedPublicly(b *bool) *ShareUpdateOne {
if b != nil {
suo.SetListedPublicly(*b)
}
return suo
}
// SetProps sets the "props" field.
func (suo *ShareUpdateOne) SetProps(tp *types.ShareProps) *ShareUpdateOne {
suo.mutation.SetProps(tp)
@ -1050,6 +1081,9 @@ func (suo *ShareUpdateOne) sqlSave(ctx context.Context) (_node *Share, err error
if value, ok := suo.mutation.AddedPricePoints(); ok {
_spec.AddField(share.FieldPricePoints, field.TypeInt, value)
}
if value, ok := suo.mutation.ListedPublicly(); ok {
_spec.SetField(share.FieldListedPublicly, field.TypeBool, value)
}
if value, ok := suo.mutation.Props(); ok {
_spec.SetField(share.FieldProps, field.TypeJSON, value)
}

@ -71,6 +71,7 @@
"sso_no_email": "The identity provider did not return an email address.",
"sso_account_unavailable": "No account is available for this identity. Registration may be restricted."
},
"browsePublicShares": "Browse public shares",
"inputBackupCode": "Enter one of your recovery codes",
"useBackupCode": "Use a recovery code instead",
"use2FACode": "Use authenticator code instead"
@ -168,7 +169,8 @@
"myProfile": "My profile",
"dashboard": "Dashboard",
"addAccount": "Add account",
"signedOut": "Signed out"
"signedOut": "Signed out",
"discover": "Discover"
},
"fileManager": {
"continueInDesktop": "Authorization successful, please continue in the desktop client.",
@ -692,6 +694,8 @@
"shareTargets": "Share targets",
"shareTargetsCount_one": "{{count}} file selected",
"shareTargetsCount_other": "{{count}} files selected",
"publicListing": "List in public directory",
"publicListingDes": "Show this share on the Discover page, visible to anyone including anonymous visitors. Not available for password-protected shares.",
"hideReadme": "Hide readme file",
"hideReadmeDes": "Keep the README file itself out of the share listing. Visitors still see the rendered readme."
},
@ -1196,5 +1200,8 @@
"announcement": {
"title": "Announcement",
"dontShowAgain": "Don't show this again"
},
"discover": {
"searchPlaceholder": "Search public shares"
}
}

@ -1407,7 +1407,9 @@
"new": "New group",
"editGroup": "Edit {{group}}",
"switchablePolicies": "Switchable storage policies",
"switchablePoliciesDes": "Policies members of this group can freely switch to. The default policy above is always available."
"switchablePoliciesDes": "Policies members of this group can freely switch to. The default policy above is always available.",
"sharePublicList": "Public share directory",
"sharePublicListDes": "When enabled, users can opt their share links into the public directory (Discover page). Password-protected shares cannot be listed."
},
"user": {
"createdAt": "Created at",
@ -1935,4 +1937,4 @@
"open": "Reopen report"
}
}
}
}

@ -71,6 +71,7 @@
"sso_no_email": "身份提供方未返回邮箱地址。",
"sso_account_unavailable": "此身份没有可用账号,注册可能受限。"
},
"browsePublicShares": "浏览公开分享",
"inputBackupCode": "请输入您的备用恢复代码",
"useBackupCode": "改用备用恢复代码",
"use2FACode": "改用验证器验证码"
@ -168,7 +169,8 @@
"myProfile": "个人主页",
"dashboard": "管理面板",
"addAccount": "添加账号",
"signedOut": "已退出登录"
"signedOut": "已退出登录",
"discover": "发现"
},
"fileManager": {
"continueInDesktop": "授权成功,请在桌面客户端中继续操作。",
@ -692,6 +694,8 @@
"shareTargets": "分享对象",
"shareTargetsCount_one": "已选择 {{count}} 个项目",
"shareTargetsCount_other": "已选择 {{count}} 个项目",
"publicListing": "收录到公开目录",
"publicListingDes": "将此分享显示在“发现”页,任何人(包括未登录访客)可见。带密码的分享不可用。",
"hideReadme": "隐藏 README 文件",
"hideReadmeDes": "在分享文件列表中隐藏 README 文件本身,访客仍可看到渲染后的说明内容。"
},
@ -1196,5 +1200,8 @@
"announcement": {
"title": "公告",
"dontShowAgain": "不再显示"
},
"discover": {
"searchPlaceholder": "搜索公开分享"
}
}

@ -1407,7 +1407,9 @@
"new": "新建用户组",
"editGroup": "编辑 {{group}}",
"switchablePolicies": "可切换存储策略",
"switchablePoliciesDes": "该组成员可以自由切换使用的存储策略。上方默认策略始终可用。"
"switchablePoliciesDes": "该组成员可以自由切换使用的存储策略。上方默认策略始终可用。",
"sharePublicList": "公开分享目录",
"sharePublicListDes": "开启后,用户可以将分享链接收录到公开目录(发现页)。带密码的分享无法被收录。"
},
"user": {
"createdAt": "创建时间",
@ -1934,4 +1936,4 @@
"open": "重新打开举报"
}
}
}
}

@ -94,7 +94,7 @@ import {
} from "./explorer.ts";
import { AppError, Code, CrHeaders, defaultOpts, isRequestAbortedError, send, ThunkResponse } from "./request.ts";
import { CreateDavAccountService, DavAccount, ListDavAccountsResponse, ListDavAccountsService } from "./setting.ts";
import { ListShareResponse, ListShareService } from "./share.ts";
import { ListPublicShareService, ListShareResponse, ListShareService } from "./share.ts";
import { CaptchaResponse, SiteConfig } from "./site.ts";
import {
AppRegistration,
@ -1323,6 +1323,25 @@ export function getShares(req: ListShareService): ThunkResponse<ListShareRespons
};
}
// getPublicShares lists shares opted into the public directory. No auth
// required; anonymous visitors get the same response shape.
export function getPublicShares(req: ListPublicShareService): ThunkResponse<ListShareResponse> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/share/listed",
{
method: "GET",
params: req,
},
{
...defaultOpts,
},
),
);
};
}
export function getDavAccounts(req: ListDavAccountsService): ThunkResponse<ListDavAccountsResponse> {
return async (dispatch, _getState) => {
return await dispatch(

@ -96,6 +96,7 @@ export interface Share {
preview_only?: boolean;
upload_only?: boolean;
note?: string;
listed_publicly?: boolean;
price?: number;
paid?: boolean;
purchase_ticket?: string;
@ -368,6 +369,7 @@ export interface ShareCreateService {
upload_only?: boolean;
note?: string;
price_points?: number;
listed_publicly?: boolean;
}
export interface CreateFileService {

@ -22,6 +22,11 @@ export interface ListShareService {
next_page_token?: string;
}
// Public directory listing — anonymous-accessible, optional name search.
export interface ListPublicShareService extends ListShareService {
query?: string;
}
export interface ListShareResponse {
shares: Share[];
pagination: PaginationResults;

@ -111,6 +111,7 @@ export const GroupPermission = {
admin_events: 27,
admin_reports: 28,
share_sell: 29,
share_public_list: 30,
};
// Delegated admin section bits — is_admin implies all of them.

@ -37,6 +37,7 @@ export interface TaskSummary {
dst?: string;
src_multiple?: string[];
dst_policy_id?: string;
dst_policy_name?: string;
failed?: number;
total?: number;
download?: DownloadTaskStatus;

@ -57,6 +57,16 @@ const ShareSection = () => {
[setGroup],
);
const onSharePublicListChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.share_public_list, e.target.checked).toString(),
}));
},
[setGroup],
);
const onSetExplicitUserChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
@ -127,6 +137,22 @@ const ShareSection = () => {
<NoMarginHelperText>{t("group.allowDownloadShareDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{values?.id != AnonymousGroupID && (
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={permission.enabled(GroupPermission.share_public_list)}
onChange={onSharePublicListChange}
/>
}
label={t("group.sharePublicList")}
/>
<NoMarginHelperText>{t("group.sharePublicListDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
{values?.id != AnonymousGroupID && (
<SettingForm lgWidth={5}>
<FormControl fullWidth>

@ -67,6 +67,7 @@ const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => {
preview_only: share.preview_only,
upload_only: share.upload_only,
note: share.note,
listed_publicly: share.listed_publicly,
price_points: share.price && share.price > 0 ? share.price : undefined,
downloads: share.remain_downloads != undefined && share.remain_downloads > 0,

@ -29,6 +29,7 @@ import Edit from "../../../Icons/Edit.tsx";
import Eye from "../../../Icons/Eye.tsx";
import EyeOff from "../../../Icons/EyeOff.tsx";
import FolderAdd from "../../../Icons/FolderAdd.tsx";
import Globe from "../../../Icons/Globe.tsx";
import RenameOutlined from "../../../Icons/RenameOutlined.tsx";
import TableSettingsOutlined from "../../../Icons/TableSettings.tsx";
import Timer from "../../../Icons/Timer.tsx";
@ -91,6 +92,7 @@ export interface ShareSetting {
preview_only?: boolean;
upload_only?: boolean;
note?: string;
listed_publicly?: boolean;
downloads?: boolean;
expires?: boolean;
price_points?: number;
@ -146,7 +148,14 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS
};
const handleCheck = (
prop: "is_private" | "share_view" | "show_readme" | "preview_only" | "expires" | "downloads",
prop:
| "is_private"
| "share_view"
| "show_readme"
| "preview_only"
| "expires"
| "downloads"
| "listed_publicly",
) => () => {
if (!setting[prop]) {
handleExpand(prop)(null, true);
@ -280,6 +289,24 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS
</FormControl>
</AccordionDetails>
</Accordion>
<Accordion expanded={expanded === "listed_publicly"} onChange={handleExpand("listed_publicly")}>
<AccordionSummary aria-controls="panel-listed-content" id="panel-listed-header">
<StyledListItemButton>
<ListItemIcon>
<Globe />
</ListItemIcon>
<ListItemText primary={t("application:modals.publicListing")} />
<ListItemSecondaryAction>
<Checkbox
checked={!!setting.listed_publicly}
disabled={!!setting.is_private}
onChange={handleCheck("listed_publicly")}
/>
</ListItemSecondaryAction>
</StyledListItemButton>
</AccordionSummary>
<AccordionDetails>{t("application:modals.publicListingDes")}</AccordionDetails>
</Accordion>
<Accordion expanded={expanded === "preview_only"} onChange={handleExpand("preview_only")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton>

@ -22,6 +22,8 @@ import DataHistogram from "../../Icons/DataHistogram.tsx";
import DataHistogramFilled from "../../Icons/DataHistogramFilled.tsx";
import Folder from "../../Icons/Folder.tsx";
import FolderOutlined from "../../Icons/FolderOutlined.tsx";
import Globe from "../../Icons/Globe.tsx";
import GlobeFilled from "../../Icons/GlobeFilled.tsx";
import HomeOutlined from "../../Icons/HomeOutlined.tsx";
import Payment from "../../Icons/Payment.tsx";
import PaymentFilled from "../../Icons/PaymentFilled.tsx";
@ -92,6 +94,13 @@ const ShopNavigationItem: NavigationItem = {
path: "/shop",
};
// Public share directory — rendered for anonymous visitors too.
const DiscoverNavigationItem: NavigationItem = {
label: "navbar.discover",
icon: [GlobeFilled, Globe],
path: "/discover",
};
export const SideNavItemComponent = ({ item }: { item: NavigationItem }) => {
const { t } = useTranslation("application");
const navigate = useNavigate();
@ -273,6 +282,7 @@ const PageNavigation = () => {
return (
<>
<SideNavItemComponent item={DiscoverNavigationItem} />
{isLogin && (
<Box>
<>

@ -0,0 +1,153 @@
import { Box, Container, FormControl, Grid, ListItemText, Stack } from "@mui/material";
import { debounce } from "lodash";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getPublicShares } from "../../../api/api.ts";
import { Share } from "../../../api/explorer.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx";
import Nothing from "../../Common/Nothing.tsx";
import { DenseSelect } from "../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
import Search from "../../Icons/Search.tsx";
import PageContainer from "../PageContainer.tsx";
import PageHeader from "../PageHeader.tsx";
import ShareCard from "../Shares/ShareCard.tsx";
const defaultPageSize = 50;
// Discover renders the public share directory: shares whose owners opted
// into public listing. Anonymous-accessible.
const Discover = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [nextPageToken, setNextPageToken] = useState<string | undefined>("");
const [shares, setShares] = useState<Share[]>([]);
const [loading, setLoading] = useState(false);
const [orderDirection, setOrderDirection] = useState("desc");
const [query, setQuery] = useState("");
const loadNextPage = useCallback(
(originShares: Share[], token?: string, direction?: string, q?: string) => () => {
setLoading(true);
dispatch(
getPublicShares({
page_size: defaultPageSize,
order_direction: direction ?? orderDirection,
next_page_token: token,
query: q ?? query,
}),
)
.then((res) => {
setShares([...originShares, ...res.shares]);
setNextPageToken(res.pagination?.next_token);
})
.catch(() => {
setNextPageToken(undefined);
})
.finally(() => {
setLoading(false);
});
},
[dispatch, orderDirection, query],
);
const refresh = useCallback(
(direction?: string, q?: string) => {
loadNextPage([], "", direction, q)();
},
[loadNextPage],
);
useEffect(() => {
refresh();
}, []);
const onQueryChange = useMemo(
() =>
debounce((value: string) => {
setQuery(value);
refresh(undefined, value);
}, 400),
[refresh],
);
useEffect(() => () => onQueryChange.cancel(), [onQueryChange]);
const onShareDeleted = useCallback((id: string) => {
setShares((shares) => shares.filter((share) => share.id !== id));
}, []);
return (
<PageContainer>
<Container maxWidth="lg">
<PageHeader
secondaryAction={
<Stack direction="row" spacing={1} alignItems="center">
<FormControl variant="outlined">
<DenseSelect
variant="outlined"
value={orderDirection}
onChange={(e) => {
setOrderDirection(e.target.value as string);
refresh(e.target.value as string);
}}
>
<SquareMenuItem value={"desc"}>
<ListItemText slotProps={{ primary: { variant: "body2" } }}>
{t("application:share.createdAtDesc")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={"asc"}>
<ListItemText slotProps={{ primary: { variant: "body2" } }}>
{t("application:share.createdAtAsc")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
</FormControl>
</Stack>
}
onRefresh={() => refresh()}
loading={loading}
title={t("application:navbar.discover")}
/>
<OutlineIconTextField
variant="outlined"
icon={<Search />}
placeholder={t("application:discover.searchPlaceholder")}
fullWidth
size="small"
sx={{ mb: 2 }}
onChange={(e) => onQueryChange(e.target.value)}
/>
<Grid container spacing={1}>
{shares.map((share) => (
<ShareCard share={share} key={share.id} onShareDeleted={onShareDeleted} />
))}
{nextPageToken != undefined && (
<>
{[...Array(4)].map((_, i) => (
<ShareCard
onShareDeleted={onShareDeleted}
onLoad={i == 0 ? loadNextPage(shares, nextPageToken) : undefined}
loading={true}
key={i == 0 ? nextPageToken : i}
/>
))}
</>
)}
</Grid>
{nextPageToken == undefined && shares.length == 0 && !loading && (
<Box sx={{ p: 1, width: "100%", textAlign: "center" }}>
<Nothing size={0.8} top={63} primary={t("setting.listEmpty")} />
</Box>
)}
</Container>
</PageContainer>
);
};
export default Discover;

@ -1,10 +1,10 @@
import { ArrowBackIos } from "@mui/icons-material";
import { LoadingButton } from "@mui/lab";
import { Box, Button, Typography } from "@mui/material";
import { Box, Button, Link, Typography } from "@mui/material";
import { enqueueSnackbar } from "notistack";
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import {
getOauthAppRegistration,
@ -538,6 +538,13 @@ const SignIn = ({ oauthConsent }: SignInProps) => {
<Box>
<PageTitle title={isOAuthFlow ? t("oauth.authorize") : t("login.signIn")} />
<EmailLogin oauthConsent={oauthConsent} />
{!isOAuthFlow && (
<Box sx={{ mt: 2, textAlign: "center" }}>
<Link component={RouterLink} to="/discover" variant="body2">
{t("login.browsePublicShares")}
</Link>
</Box>
)}
</Box>
);
};

@ -5,5 +5,6 @@ import Devices from "./Devices/Devices.tsx";
import Setting from "./Setting/Setting.tsx";
import Profile from "./Profile/Profile.tsx";
import Shop from "./Shop/Shop.tsx";
import Discover from "./Discover/Discover.tsx";
export { Setting, TaskList, ShareList, DownloadList, Devices, Profile, Shop };
export { Setting, TaskList, ShareList, DownloadList, Devices, Profile, Shop, Discover };

@ -93,9 +93,11 @@ const TaskSummaryTitle = ({ type, summary, isInDashboard = false }: TaskSummaryT
<Trans
i18nKey="setting.importFileTo"
values={{
policy: policyOption
? policyOption.find((p) => p.id == summary?.props.dst_policy_id)?.name ?? "Unknown"
: "",
policy:
summary?.props.dst_policy_name ||
(policyOption
? policyOption.find((p) => p.id == summary?.props.dst_policy_id)?.name ?? "Unknown"
: "Unknown"),
}}
components={[
<StyledFileBadge

@ -4,7 +4,7 @@ import {
DirectLink,
FileResponse,
Share,
StoragePolicy,
StoragePolicyBrief,
Viewer,
ViewerSession,
} from "../api/explorer.ts";
@ -284,7 +284,7 @@ export interface GlobalStateSlice {
uploadRawFiles?: File[];
uploadRawPromiseId?: string[];
policyOptionCache?: StoragePolicy[];
policyOptionCache?: StoragePolicyBrief[];
// Search popup
searchPopupOpen?: boolean;
@ -429,7 +429,7 @@ export const globalStateSlice = createSlice({
closeRemoteDownloadDialog: (state) => {
state.remoteDownloadDialogOpen = false;
},
setPolicyOptionCache: (state, action: PayloadAction<StoragePolicy[] | undefined>) => {
setPolicyOptionCache: (state, action: PayloadAction<StoragePolicyBrief[] | undefined>) => {
state.policyOptionCache = action.payload;
},
resetDialogs: (state) => {

@ -1,6 +1,6 @@
import i18next from "i18next";
import { enqueueSnackbar } from "notistack";
import { getUserInfo, sendSignout } from "../../api/api.ts";
import { getAllowedPolicies, getUserInfo, sendSignout } from "../../api/api.ts";
import { LoginResponse, User } from "../../api/user.ts";
import { DefaultCloseAction } from "../../component/Common/Snackbar/snackbar.tsx";
import { router } from "../../router";
@ -43,7 +43,12 @@ export function setTargetSession(session: LoginResponse): AppThunk {
dispatch(setDrawerWidth(SessionManager.getWithFallback(UserSettings.DrawerWidth)));
dispatch(setDarkMode(SessionManager.get(UserSettings.PreferredDarkMode)));
// TODO: clear fm cache
dispatch(setPolicyOptionCache());
// Populate the policy id→name cache used by task summaries (e.g.
// import tasks show the source policy). Fire-and-forget so a slow or
// failing request never blocks login.
dispatch(getAllowedPolicies())
.then((policies) => dispatch(setPolicyOptionCache(policies)))
.catch(() => {});
dispatch(clearSessionCache({ index: 0, value: undefined }));
refreshTimeZone();
};

@ -44,6 +44,7 @@ export function createOrUpdateShareLink(
upload_only: setting.upload_only,
note: setting.note?.trim() || undefined,
price_points: setting.price_points ?? undefined,
listed_publicly: setting.listed_publicly,
downloads: setting.downloads && setting.downloads_val.value > 0 ? setting.downloads_val.value : undefined,
expire: setting.expires && setting.expires_val.value > 0 ? setting.expires_val.value : undefined,
};

@ -265,6 +265,13 @@ export const router = createBrowserRouter([
return { Component: ShareList };
},
},
{
path: "/discover",
async lazy() {
let { Discover } = await import("../component/Pages/Pages");
return { Component: Discover };
},
},
{
path: "/connect",
async lazy() {

@ -74,6 +74,8 @@ type (
FileIDs []int
Props *types.ShareProps
PricePoints int
// ListedPublicly lists the share in the public share directory.
ListedPublicly bool
}
ListShareArgs struct {
@ -82,6 +84,12 @@ type (
FileID int
PublicOnly bool
ShareIDs []int
// ListedOnly restricts to shares opted into the public directory
// (listed_publicly = true, no password, not expired).
ListedOnly bool
// Query filters ListedOnly shares by (partial, case-insensitive)
// name of the anchor or any covered file.
Query string
}
ListShareResult struct {
*PaginationResults
@ -139,6 +147,7 @@ func (c *shareClient) Upsert(ctx context.Context, params *CreateShareParams) (*e
}
createQuery.SetPricePoints(params.PricePoints)
createQuery.SetListedPublicly(params.ListedPublicly)
return createQuery.Save(ctx)
}
@ -161,6 +170,9 @@ func (c *shareClient) Upsert(ctx context.Context, params *CreateShareParams) (*e
if params.PricePoints > 0 {
query.SetPricePoints(params.PricePoints)
}
if params.ListedPublicly {
query.SetListedPublicly(true)
}
if len(params.FileIDs) > 1 {
query.AddFileIDs(params.FileIDs...)
}
@ -398,6 +410,22 @@ func (c *shareClient) listQuery(args *ListShareArgs) *ent.ShareQuery {
query.Where(share.PasswordIsNil())
}
if args.ListedOnly {
// Defense-in-depth: the directory requires both the opt-in flag and
// no password, so a share edited private later still drops out.
query.Where(
share.ListedPublicly(true),
share.PasswordIsNil(),
share.Or(share.ExpiresIsNil(), share.ExpiresGT(time.Now())),
)
if args.Query != "" {
query.Where(share.Or(
share.HasFileWith(file.NameContainsFold(args.Query)),
share.HasFilesWith(file.NameContainsFold(args.Query)),
))
}
}
if args.FileID > 0 {
query.Where(share.Or(
share.HasFileWith(file.ID(args.FileID)),

@ -3,6 +3,7 @@ package inventory
import (
"context"
"testing"
"time"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
@ -116,6 +117,87 @@ func TestShareUpsertFileIDs(t *testing.T) {
require.Empty(t, loaded.Edges.Files)
}
func TestShareClientListListedOnly(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)
mkfile := func(name string) *ent.File {
return client.File.Create().SetName(name).SetType(int(types.FileTypeFile)).SetOwner(owner).SetParent(root).SaveX(ctx)
}
shareClient := NewShareClient(client, conf.SQLiteDB, nil)
past := time.Now().Add(-time.Hour)
// Visible: listed, no password, unexpired.
listed, err := shareClient.Upsert(ctx, &CreateShareParams{
OwnerID: owner.ID, FileID: mkfile("public-report.pdf").ID, ListedPublicly: true,
})
require.NoError(t, err)
// Hidden: not opted in.
_, err = shareClient.Upsert(ctx, &CreateShareParams{
OwnerID: owner.ID, FileID: mkfile("private-notes.txt").ID,
})
require.NoError(t, err)
// Hidden: opted in but password-protected (e.g. edited private later).
_, err = shareClient.Upsert(ctx, &CreateShareParams{
OwnerID: owner.ID, FileID: mkfile("secret.zip").ID, Password: "pw", ListedPublicly: true,
})
require.NoError(t, err)
// Hidden: opted in but expired.
_, err = shareClient.Upsert(ctx, &CreateShareParams{
OwnerID: owner.ID, FileID: mkfile("old.zip").ID, ListedPublicly: true, Expires: &past,
})
require.NoError(t, err)
// Visible via covered-file name match (multi-file share).
anchor := mkfile("bundle")
multi, err := shareClient.Upsert(ctx, &CreateShareParams{
OwnerID: owner.ID,
FileID: anchor.ID,
FileIDs: []int{anchor.ID, mkfile("quarterly-figures.xlsx").ID},
ListedPublicly: true,
})
require.NoError(t, err)
listArgs := func(query string) *ListShareArgs {
return &ListShareArgs{
PaginationArgs: &PaginationArgs{PageSize: 20},
ListedOnly: true,
Query: query,
}
}
ids := func(res *ListShareResult) []int {
out := make([]int, 0, len(res.Shares))
for _, s := range res.Shares {
out = append(out, s.ID)
}
return out
}
res, err := shareClient.List(ctx, listArgs(""))
require.NoError(t, err)
require.ElementsMatch(t, []int{listed.ID, multi.ID}, ids(res))
// Case-insensitive substring match on anchor name.
res, err = shareClient.List(ctx, listArgs("REPORT"))
require.NoError(t, err)
require.Equal(t, []int{listed.ID}, ids(res))
// Covered-file name match surfaces the multi-file share.
res, err = shareClient.List(ctx, listArgs("quarterly"))
require.NoError(t, err)
require.Equal(t, []int{multi.ID}, ids(res))
res, err = shareClient.List(ctx, listArgs("nonexistent"))
require.NoError(t, err)
require.Empty(t, res.Shares)
}
func TestShareClientRevalidatesOwnerGroup(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })

@ -362,6 +362,9 @@ const (
// GroupPermissionShareSell allows members to set a points price on
// shares they create (paid shares).
GroupPermissionShareSell
// GroupPermissionSharePublicList allows members to list their shares in
// the public share directory exposed to anonymous visitors.
GroupPermissionSharePublicList
)
// AclPermission is a bit position in an ACL entry's permission bitmask.

@ -131,6 +131,9 @@ type (
UploadOnly bool
Note string
PricePoints int
// ListedPublicly includes the share in the public share directory.
// Normalized to false for password-protected shares at creation.
ListedPublicly bool
}
FullTextSearchResults struct {

@ -400,6 +400,7 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, paths []*fs.URI, args
Existed: existed,
Props: props,
PricePoints: args.PricePoints,
ListedPublicly: args.ListedPublicly && password == "",
})
if err != nil {

@ -32,6 +32,7 @@ type (
}
ImportTaskState struct {
PolicyID int `json:"policy_id"`
PolicyName string `json:"policy_name,omitempty"`
Src string `json:"src"`
Recursive bool `json:"is_recursive"`
Dst string `json:"dst"`
@ -55,12 +56,13 @@ func init() {
queue.RegisterResumableTaskFactory(queue.ImportTaskType, NewImportTaskFromModel)
}
func NewImportTask(ctx context.Context, u *ent.User, src string, recursive bool, dst string, policyID int) (queue.Task, error) {
func NewImportTask(ctx context.Context, u *ent.User, src string, recursive bool, dst string, policyID int, policyName string) (queue.Task, error) {
state := &ImportTaskState{
Src: src,
Recursive: recursive,
Dst: dst,
PolicyID: policyID,
Src: src,
Recursive: recursive,
Dst: dst,
PolicyID: policyID,
PolicyName: policyName,
}
stateBytes, err := json.Marshal(state)
if err != nil {
@ -235,6 +237,7 @@ func (m *ImportTask) Summarize(hasher hashid.Encoder) *queue.Summary {
SummaryKeySrcStr: m.state.Src,
SummaryKeyFailed: m.state.Failed,
SummaryKeySrcDstPolicyID: hashid.EncodePolicyID(hasher, m.state.PolicyID),
SummaryKeyDstPolicyName: m.state.PolicyName,
},
}
}

@ -0,0 +1,60 @@
package workflows
import (
"context"
"testing"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/queue"
"github.com/stretchr/testify/require"
)
func TestImportTaskSummarizeIncludesPolicyName(t *testing.T) {
hasher, err := hashid.New("test-salt")
require.NoError(t, err)
owner := &ent.User{ID: 1}
task, err := NewImportTask(context.Background(), owner, "/src/path", true, "cloudreve://my/dst", 7, "Local Storage")
require.NoError(t, err)
summary := task.Summarize(hasher)
require.NotNil(t, summary)
require.Equal(t, "Local Storage", summary.Props[SummaryKeyDstPolicyName])
require.Equal(t, hashid.EncodePolicyID(hasher, 7), summary.Props[SummaryKeySrcDstPolicyID])
require.Equal(t, "/src/path", summary.Props[SummaryKeySrcStr])
require.Equal(t, "cloudreve://my/dst", summary.Props[SummaryKeyDst])
}
func TestImportTaskSummarizeLegacyState(t *testing.T) {
hasher, err := hashid.New("test-salt")
require.NoError(t, err)
// Tasks created before PolicyName existed have an empty name; the
// summary must still carry the encoded policy id for client-side lookup.
owner := &ent.User{ID: 1}
task, err := NewImportTask(context.Background(), owner, "/src/path", false, "cloudreve://my/dst", 7, "")
require.NoError(t, err)
summary := task.Summarize(hasher)
require.NotNil(t, summary)
require.Equal(t, "", summary.Props[SummaryKeyDstPolicyName])
require.Equal(t, hashid.EncodePolicyID(hasher, 7), summary.Props[SummaryKeySrcDstPolicyID])
}
func TestImportTaskFromModelSummarize(t *testing.T) {
hasher, err := hashid.New("test-salt")
require.NoError(t, err)
owner := &ent.User{ID: 1}
created, err := NewImportTask(context.Background(), owner, "/src/path", true, "cloudreve://my/dst", 7, "S3 Backup")
require.NoError(t, err)
// Round-trip through the persisted model state.
restored := NewImportTaskFromModel(created.(*ImportTask).Task)
require.Equal(t, queue.ImportTaskType, restored.Type())
summary := restored.Summarize(hasher)
require.NotNil(t, summary)
require.Equal(t, "S3 Backup", summary.Props[SummaryKeyDstPolicyName])
}

@ -86,6 +86,7 @@ const (
SummaryKeySrcMultiple = "src_multiple"
SummaryKeySrcDstPolicyID = "dst_policy_id"
SummaryKeyDstPolicyName = "dst_policy_name"
SummaryKeyFailed = "failed"
SummaryKeyTotal = "total"
)

@ -68,6 +68,22 @@ func ListShare(c *gin.Context) {
}
}
// ListPublicShares lists shares opted into the public directory.
// Anonymous-accessible.
func ListPublicShares(c *gin.Context) {
service := ParametersFromContext[*share.ListPublicShareService](c, share.ListPublicShareParamCtx{})
resp, err := service.ListPublic(c)
if respondErr(c, err) {
return
}
if resp != nil {
c.JSON(200, serializer.Response{
Data: resp,
})
}
}
// DeleteShare 删除分享
func DeleteShare(c *gin.Context) {
err := share.DeleteShare(c, hashid.FromContext(c))

@ -967,6 +967,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.FromQuery[sharesvc.ShareInfoService](sharesvc.ShareInfoParamCtx{}),
controllers.GetShare,
)
// Public share directory listing (anonymous-accessible)
share.GET("listed",
middleware.RateLimitByIP("share_listed", 60, time.Minute),
controllers.FromQuery[sharesvc.ListPublicShareService](sharesvc.ListPublicShareParamCtx{}),
controllers.ListPublicShares,
)
// Purchase a paid share with credits
share.POST("purchase/:id",
middleware.LoginRequired(),

@ -55,6 +55,7 @@ func TestMasterRouteWiring(t *testing.T) {
"PATCH /api/v4/file/tag",
"DELETE /api/v4/file/tag",
"POST /api/v4/share/purchase/:id",
"GET /api/v4/share/listed",
"GET /api/v4/session/qq/login",
"GET /api/v4/session/qq/callback",
"DELETE /api/v4/user/setting/sso_binding/:provider",

@ -339,15 +339,16 @@ type Share struct {
Size int64 `json:"size"`
// Only viewable by owner
IsPrivate bool `json:"is_private,omitempty"`
Password string `json:"password,omitempty"`
ShareView bool `json:"share_view,omitempty"`
AllowUpload bool `json:"allow_upload,omitempty"`
AllowEdit bool `json:"allow_edit,omitempty"`
PreviewOnly bool `json:"preview_only,omitempty"`
UploadOnly bool `json:"upload_only,omitempty"`
Note string `json:"note,omitempty"`
HideReadMe bool `json:"hide_readme,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
Password string `json:"password,omitempty"`
ListedPublicly bool `json:"listed_publicly,omitempty"`
ShareView bool `json:"share_view,omitempty"`
AllowUpload bool `json:"allow_upload,omitempty"`
AllowEdit bool `json:"allow_edit,omitempty"`
PreviewOnly bool `json:"preview_only,omitempty"`
UploadOnly bool `json:"upload_only,omitempty"`
Note string `json:"note,omitempty"`
HideReadMe bool `json:"hide_readme,omitempty"`
// Only viewable if explicitly unlocked by owner
SourceUri string `json:"source_uri,omitempty"`
@ -408,6 +409,7 @@ func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid.
if requester.ID == owner.ID {
res.IsPrivate = s.Password != ""
res.ListedPublicly = s.ListedPublicly
res.ShareView = s.Props != nil && s.Props.ShareView
if s.Props != nil {
res.AllowUpload = s.Props.AllowUpload

@ -423,8 +423,15 @@ func (service *ImportWorkflowService) CreateImportTask(c *gin.Context) (*TaskRes
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid destination", err)
}
// Resolve policy name for the task summary; best-effort, the task itself
// validates the policy at execution time.
var policyName string
if policy, err := dep.StoragePolicyClient().GetPolicyByID(c, service.PolicyID); err == nil {
policyName = policy.Name
}
// Create task
t, err := workflows.NewImportTask(c, owner, service.Src, service.Recursive, dst.Join(service.Dst).String(), service.PolicyID)
t, err := workflows.NewImportTask(c, owner, service.Src, service.Recursive, dst.Join(service.Dst).String(), service.PolicyID, policyName)
if err != nil {
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err)
}

@ -38,6 +38,10 @@ type (
Note string `json:"note" binding:"omitempty,max=255"`
// Points price visitors must pay before downloading. 0 = free share.
PricePoints int `json:"price_points" binding:"omitempty,min=0"`
// ListedPublicly opts the share into the public share directory.
// Requires the group's public-listing permission and is rejected on
// password-protected shares.
ListedPublicly bool `json:"listed_publicly"`
}
ShareCreateParamCtx struct{}
@ -89,6 +93,15 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string,
return "", serializer.NewError(serializer.CodeGroupNotAllowed, "Group permission denied for paid share", nil)
}
if service.ListedPublicly {
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionSharePublicList)) {
return "", serializer.NewError(serializer.CodeGroupNotAllowed, "Group permission denied for public share listing", nil)
}
if service.IsPrivate {
return "", serializer.NewError(serializer.CodeParamErr, "password-protected shares cannot be publicly listed", nil)
}
}
rawUris := service.Uris
if len(rawUris) == 0 && service.Uri != "" {
rawUris = []string{service.Uri}
@ -135,6 +148,7 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string,
UploadOnly: service.UploadOnly,
Note: service.Note,
PricePoints: service.PricePoints,
ListedPublicly: service.ListedPublicly,
})
if err != nil {
return "", err

@ -248,3 +248,47 @@ func (s *ListShareService) ListInUserProfile(c *gin.Context, uid int) (*ListShar
base := dep.SettingProvider().SiteURL(ctx)
return BuildListShareResponse(ctx, res, hasher, base, user, false), nil
}
type (
// ListPublicShareService lists shares opted into the public directory.
// Anonymous-accessible; only non-expired, non-password shares surface.
ListPublicShareService struct {
PageSize int `form:"page_size" binding:"required,min=10,max=100"`
Query string `form:"query" binding:"omitempty,max=100"`
OrderBy string `form:"order_by"`
OrderDirection string `form:"order_direction"`
NextPageToken string `form:"next_page_token"`
}
ListPublicShareParamCtx struct{}
)
func (s *ListPublicShareService) ListPublic(c *gin.Context) (*ListShareResponse, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
shareClient := dep.ShareClient()
args := &inventory.ListShareArgs{
PaginationArgs: &inventory.PaginationArgs{
UseCursorPagination: true,
PageToken: s.NextPageToken,
PageSize: s.PageSize,
Order: inventory.OrderDirection(s.OrderDirection),
OrderBy: s.OrderBy,
},
ListedOnly: true,
Query: strings.TrimSpace(s.Query),
}
ctx := context.WithValue(c, inventory.LoadShareUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFiles{}, true)
res, err := shareClient.List(ctx, args)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list shares", err)
}
base := dep.SettingProvider().SiteURL(ctx)
// unlocked=true: listed shares carry no password, and the anonymous
// share/info endpoint already exposes the same fields to visitors.
return BuildListShareResponse(ctx, res, dep.HashIDEncoder(), base, user, true), nil
}

Loading…
Cancel
Save