feat: user-customizable trash retention (#8)

Users can override the group's trash retention in preferences (days UI,
stored as seconds in user settings). The override applies when a file
is moved to trash; already-trashed files keep their original expiry,
matching the upstream request.

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

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

@ -855,6 +855,8 @@
"enableVersionRetention": "Enable version retention",
"enableVersionRetentionDes": "If enabled, historical versions of files that meet the conditions will be retained.",
"versionRetention": "Version retention",
"trashRetention": "Trash retention (days)",
"trashRetentionDes": "Days a file stays in the trash before automatic deletion. 0 inherits the group default. Applies to files moved to trash after the change.",
"languageDes": "Select the display language and preferred email language.",
"timezoneDes": "Set the display timezone, default is system timezone",
"externalApps": "External Apps",

@ -855,6 +855,8 @@
"enableVersionRetention": "启用版本保留",
"enableVersionRetentionDes": "启用后,对于符合条件的文件,系统会保留其历史版本",
"versionRetention": "版本保留",
"trashRetention": "回收站保留时间(天)",
"trashRetentionDes": "文件移入回收站后自动删除前保留的天数。0 表示继承用户组默认值,仅影响修改后移入回收站的文件。",
"languageDes": "设置应用展示语言和首选邮件语言",
"timezoneDes": "设置展示时区,默认跟随系统时区",
"externalApps": "外部应用",

@ -144,6 +144,7 @@ export interface UserSettings {
share_links_in_profile: ShareLinksInProfileLevel;
share_default_private?: boolean;
preferred_viewers?: Record<string, string>;
trash_retention?: number;
oauth_grants?: OAuthGrant[];
}
@ -171,6 +172,8 @@ export interface PatchUserSetting {
// Tri-state: "true" / "false" / "" (inherit the site default).
share_default_private?: string;
preferred_viewers?: Record<string, string>;
// Trash retention override in seconds; 0 inherits the group setting.
trash_retention?: number;
}
export interface PasskeyCredentialOption {

@ -86,6 +86,29 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
const [folderClickAction, setFolderClickAction] = useState(
SessionManager.getWithFallback(UserSettings.FolderClickAction),
);
const [trashRetentionDays, setTrashRetentionDays] = useState(
Math.round((setting.trash_retention ?? 0) / 86400),
);
const onTrashRetentionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const days = Math.max(0, parseInt(e.target.value) || 0);
setTrashRetentionDays(days);
};
const saveTrashRetention = () => {
const seconds = trashRetentionDays * 86400;
if (seconds == (setting.trash_retention ?? 0)) {
return;
}
setLoading(true);
dispatch(sendUpdateUserSetting({ trash_retention: seconds }))
.then(() => {
setSetting({ ...setting, trash_retention: seconds });
})
.finally(() => {
setLoading(false);
});
};
const onRetentionCheckChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setVersionRetentionEnabled(e.target.checked);
@ -107,6 +130,7 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
setVersionRetentionEnabled(setting.version_retention_enabled);
setVersionRetentionMax(setting.version_retention_max);
setVersionRetentionExts(setting.version_retention_ext);
setTrashRetentionDays(Math.round((setting.trash_retention ?? 0) / 86400));
}, [setting]);
const selectTimeZone = (value: string) => {
@ -349,6 +373,16 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
</Stack>
</OutlinedSettingBox>
</SettingForm>
<SettingForm title={t("setting.trashRetention")} lgWidth={12}>
<DenseFilledTextField
type="number"
value={trashRetentionDays}
onChange={onTrashRetentionChange}
onBlur={saveTrashRetention}
inputProps={{ min: 0, step: 1 }}
helperText={t("setting.trashRetentionDes")}
/>
</SettingForm>
<SettingForm title={t("setting.syncView")} lgWidth={12}>
<ToggleButtonGroup
color="primary"

@ -24,6 +24,10 @@ type (
// viewer IDs chosen via "always open with". Synced server-side so the
// preference follows the user across devices.
PreferredViewers map[string]string `json:"preferred_viewers,omitempty"`
// TrashRetention overrides the group's trash retention in seconds.
// 0 means inherit the group setting. Applied when a file is moved to
// trash — already-trashed files keep their original expiry.
TrashRetention int `json:"trash_retention,omitempty"`
}
ShareLinksInProfileLevel string

@ -310,11 +310,16 @@ func (f *DBFS) SoftDelete(ctx context.Context, path ...*fs.URI) error {
return serializer.NewError(serializer.CodeDBError, "failed to soft-delete file", err)
}
// Save restore uri into metadata
// Save restore uri into metadata. A user-level retention overrides
// the group default when set.
retention := target.Owner().Edges.Group.Settings.TrashRetention
if us := target.Owner().Settings; us != nil && us.TrashRetention > 0 {
retention = us.TrashRetention
}
if err := fc.UpsertMetadata(ctx, target.Model, map[string]string{
MetadataRestoreUri: target.Uri(true).String(),
MetadataExpectedCollectTime: strconv.FormatInt(
time.Now().Add(time.Duration(target.Owner().Edges.Group.Settings.TrashRetention)*time.Second).Unix(),
time.Now().Add(time.Duration(retention)*time.Second).Unix(),
10),
}, nil); err != nil {
_ = inventory.Rollback(tx)

@ -34,6 +34,7 @@ type UserSettings struct {
ShareLinksInProfile string `json:"share_links_in_profile"`
ShareDefaultPrivate *bool `json:"share_default_private,omitempty"`
PreferredViewers map[string]string `json:"preferred_viewers,omitempty"`
TrashRetention int `json:"trash_retention,omitempty"`
OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"`
}
@ -51,6 +52,7 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa
ShareLinksInProfile: string(u.Settings.ShareLinksInProfile),
ShareDefaultPrivate: u.Settings.ShareDefaultPrivate,
PreferredViewers: u.Settings.PreferredViewers,
TrashRetention: u.Settings.TrashRetention,
OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant {
return BuildOauthGrant(item)
}),

@ -236,6 +236,9 @@ type (
ShareDefaultPrivate *string `json:"share_default_private" binding:"omitempty"`
// PreferredViewers replaces the whole extension → viewer-id map.
PreferredViewers *map[string]string `json:"preferred_viewers" binding:"omitempty"`
// TrashRetention overrides group trash retention, in seconds.
// 0 clears the override. Capped at ~10 years.
TrashRetention *int `json:"trash_retention" binding:"omitempty,min=0,max=315360000"`
}
PatchUserSettingParamsCtx struct{}
)
@ -334,6 +337,11 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error {
saveSetting = true
}
if s.TrashRetention != nil {
u.Settings.TrashRetention = *s.TrashRetention
saveSetting = true
}
if s.CurrentPassword != nil && s.NewPassword != nil {
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
return err

Loading…
Cancel
Save