Merge pull request #201 from Dvorinka/feat/share-hide-readme

feat: option to hide README file from share listings (upstream #2729)
pull/3587/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit 3bc9e0e0c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -208,6 +208,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [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
- [x] Private space / vault (upstream #3447) — opt-in root folder flagged `sys:vault`; ancestry-based membership (zero flag maintenance; chain-less search results resolved lazily via `file_children`); `vaultNavigator` decorator gating `To`/`Children`/`Walk`/`ExecuteHook`; separate vault password (`salt:sha256`, sensitive) + 30-min cache-backed unlock session, unlock rate-limited 10/h; vault content never shareable and never direct-linkable; search filtered while locked; `vault_enabled`/`vault_unlocked` in user settings; unlock prompt in `ExplorerError`, Private space section in security settings, lock badge on vault folder (#195)
- [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
## 6. Phase D — desktop, all platforms

@ -691,7 +691,9 @@
"deleteViewSetting": "Delete view setting",
"shareTargets": "Share targets",
"shareTargetsCount_one": "{{count}} file selected",
"shareTargetsCount_other": "{{count}} files selected"
"shareTargetsCount_other": "{{count}} files selected",
"hideReadme": "Hide readme file",
"hideReadmeDes": "Keep the README file itself out of the share listing. Visitors still see the rendered readme."
},
"uploader": {
"fileCopyName": "Copy of ",

@ -691,7 +691,9 @@
"deleteViewSetting": "删除视图设置",
"shareTargets": "分享对象",
"shareTargetsCount_one": "已选择 {{count}} 个项目",
"shareTargetsCount_other": "已选择 {{count}} 个项目"
"shareTargetsCount_other": "已选择 {{count}} 个项目",
"hideReadme": "隐藏 README 文件",
"hideReadmeDes": "在分享文件列表中隐藏 README 文件本身,访客仍可看到渲染后的说明内容。"
},
"uploader": {
"fileCopyName": "副本_",

@ -90,6 +90,7 @@ export interface Share {
source_uri?: string;
password?: string;
show_readme?: boolean;
hide_readme?: boolean;
allow_upload?: boolean;
allow_edit?: boolean;
preview_only?: boolean;
@ -360,6 +361,7 @@ export interface ShareCreateService {
expire?: number;
share_view?: boolean;
show_readme?: boolean;
hide_readme?: boolean;
allow_upload?: boolean;
allow_edit?: boolean;
preview_only?: boolean;

@ -61,6 +61,7 @@ const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => {
use_custom_password: true,
share_view: share.share_view,
show_readme: share.show_readme,
hide_readme: share.hide_readme,
allow_upload: share.allow_upload,
allow_edit: share.allow_edit,
preview_only: share.preview_only,

@ -85,6 +85,7 @@ export interface ShareSetting {
password?: string;
share_view?: boolean;
show_readme?: boolean;
hide_readme?: boolean;
allow_upload?: boolean;
allow_edit?: boolean;
preview_only?: boolean;
@ -399,6 +400,16 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS
</AccordionSummary>
<AccordionDetails>
<Trans i18nKey="application:modals.showReadmeDes" components={[<Code />]} />
<StyledListItemButton disabled={!setting.show_readme}>
<ListItemText primary={t("application:modals.hideReadme")} secondary={t("application:modals.hideReadmeDes")} />
<ListItemSecondaryAction>
<Checkbox
checked={setting.show_readme && setting.hide_readme}
disabled={!setting.show_readme}
onChange={() => onSettingChange({ ...setting, hide_readme: !setting.hide_readme })}
/>
</ListItemSecondaryAction>
</StyledListItemButton>
</AccordionDetails>
</Accordion>
</>

@ -1,10 +1,9 @@
import i18next from "i18next";
import { closeSnackbar, enqueueSnackbar, SnackbarKey } from "notistack";
import { getFileInfo, getFileList, getShareInfo, sendCreateShare, sendUpdateShare } from "../../api/api.ts";
import { getFileInfo, getShareInfo, sendCreateShare, sendUpdateShare } from "../../api/api.ts";
import { FileResponse, Share, ShareCreateService } from "../../api/explorer.ts";
import { DefaultCloseAction, OpenReadMeAction } from "../../component/Common/Snackbar/snackbar.tsx";
import { ShareSetting } from "../../component/FileManager/Dialogs/Share/ShareSetting.tsx";
import { getPaginationState } from "../../component/FileManager/Pagination/PaginationFooter.tsx";
import CrUri from "../../util/uri.ts";
import { fileUpdated } from "../fileManagerSlice.ts";
import {
@ -32,6 +31,7 @@ export function createOrUpdateShareLink(
password: setting.password,
share_view: setting.share_view,
show_readme: setting.show_readme,
hide_readme: setting.show_readme ? setting.hide_readme : false,
allow_upload: setting.allow_upload || setting.allow_edit,
allow_edit: setting.allow_edit,
preview_only: setting.preview_only,
@ -147,7 +147,7 @@ const supportedReadMeFiles = ["README.md", "README.txt"];
export function detectReadMe(index: number, isTablet: boolean): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const { files: list, pagination } = getState().fileManager[index]?.list ?? {};
const { files: list } = getState().fileManager[index]?.list ?? {};
if (list) {
// Find readme file from highest to lowest priority
for (const readmeFile of supportedReadMeFiles) {
@ -159,10 +159,11 @@ export function detectReadMe(index: number, isTablet: boolean): AppThunk<Promise
}
}
// Not found in current file list, try to get file directly
// Not found in current file list, try to get file directly. Always
// probe: the readme may be filtered out of the listing entirely
// (hide_readme) or live on a page we have not fetched yet.
const path = getState().fileManager[index]?.pure_path;
const hasMorePages = getPaginationState(pagination).moreItems;
if (path && hasMorePages) {
if (path) {
const uri = new CrUri(path);
for (const readmeFile of supportedReadMeFiles) {
try {

@ -287,6 +287,9 @@ type (
ShareView bool `json:"share_view,omitempty"`
// Whether to automatically show readme file in share view
ShowReadMe bool `json:"show_read_me,omitempty"`
// Whether to hide the readme file itself from the share listing
// (only meaningful together with ShowReadMe)
HideReadMe bool `json:"hide_readme,omitempty"`
// Whether share visitors can upload new files into the shared folder
AllowUpload bool `json:"allow_upload,omitempty"`
// Whether share visitors can rename, move and delete files (implies upload)

@ -3,6 +3,7 @@ package dbfs
import (
"context"
"fmt"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/ent"
@ -15,6 +16,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/samber/lo"
)
var (
@ -367,7 +369,26 @@ func (n *shareNavigator) Children(ctx context.Context, parent *File, args *ListA
}, nil
}
return n.baseNavigator.children(ctx, parent, args)
res, err := n.baseNavigator.children(ctx, parent, args)
if err != nil {
return nil, err
}
// Shares with a rendered readme can keep the file itself out of the
// listing; it stays reachable by direct path for the readme viewer.
if n.share != nil && n.share.Props != nil && n.share.Props.ShowReadMe && n.share.Props.HideReadMe {
res.Files = lo.Filter(res.Files, func(f *File, _ int) bool {
return !readMeFileNames[strings.ToUpper(f.Name())]
})
}
return res, nil
}
// readMeFileNames mirrors the frontend's detection priority list; entries
// are uppercase for case-insensitive matching.
var readMeFileNames = map[string]bool{
"README.MD": true,
"README.TXT": true,
}
// linkSharedFile attaches a linked file of a multi-file share under the

@ -0,0 +1,94 @@
package dbfs
import (
"context"
"testing"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
"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/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/samber/lo"
"github.com/stretchr/testify/require"
)
// readmeShareFixture seeds an owner with a folder containing a README.md and
// a regular file, shared via a folder share with the given props.
func readmeShareFixture(t *testing.T, client *ent.Client, hasher hashid.Encoder, props *types.ShareProps) (*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)
dir := client.File.Create().SetName("docs").SetType(int(types.FileTypeFolder)).
SetOwner(owner).SetParent(ownerRoot).SaveX(ctx)
client.File.Create().SetName("README.md").SetType(int(types.FileTypeFile)).
SetOwner(owner).SetParent(dir).SaveX(ctx)
client.File.Create().SetName("readme.txt").SetType(int(types.FileTypeFile)).
SetOwner(owner).SetParent(dir).SaveX(ctx)
client.File.Create().SetName("notes.txt").SetType(int(types.FileTypeFile)).
SetOwner(owner).SetParent(dir).SaveX(ctx)
share := client.Share.Create().SetUser(owner).SetFile(dir).SetProps(props).SaveX(ctx)
visitor := client.User.Create().SetEmail("visitor@example.com").SetNick("v").SetGroup(group).SaveX(ctx)
visitor.SetGroup(group)
return visitor, share
}
func TestShareHideReadMe(t *testing.T) {
newNav := func(t *testing.T, props *types.ShareProps) (Navigator, *fs.URI, *ent.Client) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
hasher, err := hashid.New("seed-test-salt")
require.NoError(t, err)
visitor, share := readmeShareFixture(t, client, hasher, props)
nav := multiShareNavigator(t, client, hasher, visitor)
uri, err := fs.NewUriFromString(fs.NewShareUri(hashid.EncodeShareID(hasher, share.ID), ""))
require.NoError(t, err)
return nav, uri, client
}
names := func(t *testing.T, nav Navigator, uri *fs.URI) []string {
t.Helper()
root, err := nav.To(context.Background(), uri)
require.NoError(t, err)
res, err := nav.Children(context.Background(), root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 100}})
require.NoError(t, err)
return lo.Map(res.Files, func(f *File, _ int) string { return f.Name() })
}
t.Run("hidden when both props set", func(t *testing.T) {
nav, uri, _ := newNav(t, &types.ShareProps{ShowReadMe: true, HideReadMe: true})
require.ElementsMatch(t, []string{"notes.txt"}, names(t, nav, uri))
// Hidden files remain resolvable by path for the readme viewer.
f, err := nav.To(context.Background(), uri.Join("README.md"))
require.NoError(t, err)
require.Equal(t, "README.md", f.Name())
})
t.Run("listed without hide prop", func(t *testing.T) {
nav, uri, _ := newNav(t, &types.ShareProps{ShowReadMe: true})
require.ElementsMatch(t, []string{"README.md", "readme.txt", "notes.txt"}, names(t, nav, uri))
})
t.Run("listed without readme props", func(t *testing.T) {
nav, uri, _ := newNav(t, &types.ShareProps{})
require.ElementsMatch(t, []string{"README.md", "readme.txt", "notes.txt"}, names(t, nav, uri))
})
}

@ -124,6 +124,7 @@ type (
Expire *time.Time
ShareView bool
ShowReadMe bool
HideReadMe bool
AllowUpload bool
AllowEdit bool
PreviewOnly bool

@ -381,6 +381,7 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, paths []*fs.URI, args
props := &types.ShareProps{
ShareView: args.ShareView,
ShowReadMe: args.ShowReadMe,
HideReadMe: args.HideReadMe && args.ShowReadMe,
AllowUpload: args.AllowUpload || args.AllowEdit,
AllowEdit: args.AllowEdit,
PreviewOnly: args.PreviewOnly,

@ -347,6 +347,7 @@ type Share struct {
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"`
@ -415,6 +416,7 @@ func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid.
res.UploadOnly = s.Props.UploadOnly
// Owner-private note; never sent to share visitors (#3570).
res.Note = s.Props.Note
res.HideReadMe = s.Props.HideReadMe
}
}

@ -29,6 +29,7 @@ type (
Expire int `json:"expire"`
ShareView bool `json:"share_view"`
ShowReadMe bool `json:"show_readme"`
HideReadMe bool `json:"hide_readme"`
AllowUpload bool `json:"allow_upload"`
AllowEdit bool `json:"allow_edit"`
PreviewOnly bool `json:"preview_only"`
@ -127,6 +128,7 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string,
ExistedShareID: existed,
ShareView: service.ShareView,
ShowReadMe: service.ShowReadMe,
HideReadMe: service.HideReadMe,
AllowUpload: service.AllowUpload,
AllowEdit: service.AllowEdit,
PreviewOnly: service.PreviewOnly,

Loading…
Cancel
Save