Merge pull request #182 from Dvorinka/feat/storage-policy-adv
feat(storage): group-allowed policies, per-dir policy, load-balance policy, user-facing relocatepull/3587/head
commit
8c08df9530
File diff suppressed because one or more lines are too long
@ -0,0 +1,82 @@
|
|||||||
|
import { Box, Checkbox, FormControl, ListItemText, SelectChangeEvent } from "@mui/material";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getStoragePolicyList } from "../../../../api/api";
|
||||||
|
import { StoragePolicy } from "../../../../api/dashboard";
|
||||||
|
import { useAppDispatch } from "../../../../redux/hooks";
|
||||||
|
import { DenseSelect, SquareChip } from "../../../Common/StyledComponents";
|
||||||
|
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu";
|
||||||
|
|
||||||
|
export interface PolicyMultiSelectionInputProps {
|
||||||
|
value: number[];
|
||||||
|
onChange: (value: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyMultiSelectionInput picks the pool of storage policies members of a
|
||||||
|
// group may freely switch between. The group's default policy is always
|
||||||
|
// available implicitly and need not be selected here.
|
||||||
|
const PolicyMultiSelectionInput = ({ value, onChange }: PolicyMultiSelectionInputProps) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [policies, setPolicies] = useState<StoragePolicy[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [policyMap, setPolicyMap] = useState<Record<number, StoragePolicy>>({});
|
||||||
|
|
||||||
|
const handleChange = (event: SelectChangeEvent<unknown>) => {
|
||||||
|
const {
|
||||||
|
target: { value: v },
|
||||||
|
} = event;
|
||||||
|
onChange(typeof v === "string" ? v.split(",").map((x) => parseInt(x)) : (v as number[]));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
dispatch(getStoragePolicyList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" }))
|
||||||
|
.then((res) => {
|
||||||
|
setPolicies(res.policies);
|
||||||
|
setPolicyMap(
|
||||||
|
res.policies.reduce(
|
||||||
|
(acc, policy) => {
|
||||||
|
acc[policy.id] = policy;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<number, StoragePolicy>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<DenseSelect
|
||||||
|
multiple
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
sx={{
|
||||||
|
minHeight: 39,
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
MenuProps={{
|
||||||
|
PaperProps: { sx: { maxWidth: 300 } },
|
||||||
|
}}
|
||||||
|
renderValue={(selected) => (
|
||||||
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, py: 0.5 }}>
|
||||||
|
{(selected as number[]).map((id) => (
|
||||||
|
<SquareChip key={id} label={policyMap[id]?.name ?? id} size="small" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{policies.map((p) => (
|
||||||
|
<SquareMenuItem key={p.id} value={p.id}>
|
||||||
|
<Checkbox size="small" checked={value.indexOf(p.id) > -1} />
|
||||||
|
<ListItemText primary={p.name} secondary={`#${p.id} · ${p.type}`} />
|
||||||
|
</SquareMenuItem>
|
||||||
|
))}
|
||||||
|
</DenseSelect>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PolicyMultiSelectionInput;
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
SelectChangeEvent,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getStoragePolicyList } from "../../../../../api/api";
|
||||||
|
import { LBPolicyRef, StoragePolicy } from "../../../../../api/dashboard";
|
||||||
|
import { PolicyType } from "../../../../../api/explorer";
|
||||||
|
import { useAppDispatch } from "../../../../../redux/hooks";
|
||||||
|
import { DenseFilledTextField, DenseSelect, SecondaryButton } from "../../../../Common/StyledComponents";
|
||||||
|
import { SquareMenuItem } from "../../../../FileManager/ContextMenu/ContextMenu";
|
||||||
|
import Dismiss from "../../../../Icons/Dismiss";
|
||||||
|
import SettingForm from "../../../../Pages/Setting/SettingForm";
|
||||||
|
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../../Settings/Settings";
|
||||||
|
import { StoragePolicySettingContext } from "../StoragePolicySettingWrapper";
|
||||||
|
|
||||||
|
// LoadBalanceSection edits the weighted child policies of a load_balance
|
||||||
|
// policy. Only concrete (non-load-balance) policies can be children.
|
||||||
|
const LoadBalanceSection = () => {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
const { values, setPolicy } = useContext(StoragePolicySettingContext);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [policies, setPolicies] = useState<StoragePolicy[]>([]);
|
||||||
|
|
||||||
|
const isLB = values.type === PolicyType.load_balance;
|
||||||
|
const refs = values.settings?.lb_policies ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLB) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch(getStoragePolicyList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" })).then(
|
||||||
|
(res) => {
|
||||||
|
setPolicies(res.policies.filter((p) => p.type !== PolicyType.load_balance && p.id !== values.id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [isLB]);
|
||||||
|
|
||||||
|
const setRefs = useCallback(
|
||||||
|
(next: LBPolicyRef[]) => {
|
||||||
|
setPolicy((p: StoragePolicy) => ({
|
||||||
|
...p,
|
||||||
|
settings: { ...p.settings, lb_policies: next },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[setPolicy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addChild = useCallback(() => {
|
||||||
|
const used = new Set(refs.map((r) => r.policy));
|
||||||
|
const candidate = policies.find((p) => !used.has(p.id));
|
||||||
|
if (!candidate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRefs([...refs, { policy: candidate.id, weight: 1 }]);
|
||||||
|
}, [refs, policies, setRefs]);
|
||||||
|
|
||||||
|
const onChildChange = useCallback(
|
||||||
|
(index: number, e: SelectChangeEvent<unknown>) => {
|
||||||
|
const next = refs.slice();
|
||||||
|
next[index] = { ...next[index], policy: e.target.value as number };
|
||||||
|
setRefs(next);
|
||||||
|
},
|
||||||
|
[refs, setRefs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onWeightChange = useCallback(
|
||||||
|
(index: number, v: string) => {
|
||||||
|
const w = parseInt(v);
|
||||||
|
const next = refs.slice();
|
||||||
|
next[index] = { ...next[index], weight: isNaN(w) ? undefined : Math.max(1, w) };
|
||||||
|
setRefs(next);
|
||||||
|
},
|
||||||
|
[refs, setRefs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeChild = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setRefs(refs.filter((_, i) => i !== index));
|
||||||
|
},
|
||||||
|
[refs, setRefs],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isLB) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingSection>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{t("policy.loadBalance")}
|
||||||
|
</Typography>
|
||||||
|
<SettingSectionContent>
|
||||||
|
<SettingForm title={t("policy.lbChildren")} lgWidth={12}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>{t("policy.lbChildPolicy")}</TableCell>
|
||||||
|
<TableCell width={140}>{t("policy.lbWeight")}</TableCell>
|
||||||
|
<TableCell width={48} />
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{refs.map((r, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<DenseSelect value={r.policy} onChange={(e) => onChildChange(i, e)}>
|
||||||
|
{policies.map((p) => (
|
||||||
|
<SquareMenuItem
|
||||||
|
key={p.id}
|
||||||
|
value={p.id}
|
||||||
|
disabled={refs.some((x, xi) => xi !== i && x.policy === p.id)}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</SquareMenuItem>
|
||||||
|
))}
|
||||||
|
</DenseSelect>
|
||||||
|
</FormControl>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DenseFilledTextField
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
|
value={r.weight ?? 1}
|
||||||
|
onChange={(e) => onWeightChange(i, e.target.value)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton size="small" onClick={() => removeChild(i)}>
|
||||||
|
<Dismiss fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<SecondaryButton onClick={addChild} disabled={refs.length >= policies.length}>
|
||||||
|
{t("policy.lbAddChild")}
|
||||||
|
</SecondaryButton>
|
||||||
|
</Box>
|
||||||
|
<NoMarginHelperText>{t("policy.lbChildrenDes")}</NoMarginHelperText>
|
||||||
|
</SettingForm>
|
||||||
|
</SettingSectionContent>
|
||||||
|
</SettingSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadBalanceSection;
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import { Button } from "@mui/material";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StoragePolicy } from "../../../../../api/dashboard";
|
||||||
|
import { PolicyType } from "../../../../../api/explorer";
|
||||||
|
import { DenseFilledTextField } from "../../../../Common/StyledComponents";
|
||||||
|
import SettingForm from "../../../../Pages/Setting/SettingForm";
|
||||||
|
import { NoMarginHelperText } from "../../../Settings/Settings";
|
||||||
|
import { AddWizardProps } from "../../AddWizardDialog";
|
||||||
|
|
||||||
|
// LBWizard creates a bare load_balance policy; weighted children are bound on
|
||||||
|
// the policy edit page.
|
||||||
|
const LBWizard = ({ onSubmit }: AddWizardProps) => {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [policy, setPolicy] = useState<StoragePolicy>({
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
type: PolicyType.load_balance,
|
||||||
|
settings: {
|
||||||
|
lb_policies: [],
|
||||||
|
},
|
||||||
|
edges: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!formRef.current?.checkValidity()) {
|
||||||
|
formRef.current?.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(policy);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form ref={formRef} onSubmit={handleSubmit}>
|
||||||
|
<SettingForm title={t("policy.name")} lgWidth={12}>
|
||||||
|
<DenseFilledTextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
value={policy.name}
|
||||||
|
onChange={(e) => setPolicy({ ...policy, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<NoMarginHelperText>{t("policy.lbWizardDes")}</NoMarginHelperText>
|
||||||
|
</SettingForm>
|
||||||
|
<Button variant="contained" color="primary" sx={{ mt: 2 }} onClick={handleSubmit}>
|
||||||
|
{t("policy.create")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LBWizard;
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
ListItemText,
|
||||||
|
SelectChangeEvent,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useSnackbar } from "notistack";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getAllowedPolicies, relocateToPolicy, setPreferredPolicy } from "../../../api/api";
|
||||||
|
import { Metadata, StoragePolicyBrief } from "../../../api/explorer";
|
||||||
|
import { closeStoragePolicyDialog } from "../../../redux/globalStateSlice";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
|
||||||
|
import { refreshFileList } from "../../../redux/thunks/filemanager";
|
||||||
|
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar";
|
||||||
|
import { DenseSelect } from "../../Common/StyledComponents";
|
||||||
|
import { SquareMenuItem } from "../ContextMenu/ContextMenu";
|
||||||
|
import { FileManagerIndex } from "../FileManager";
|
||||||
|
|
||||||
|
// StoragePolicyDialog switches the storage policy for either a folder's
|
||||||
|
// preferred upload policy ("dir") or a file/folder's physical entities
|
||||||
|
// ("relocate"), choosing from the group's allowed set.
|
||||||
|
const StoragePolicyDialog = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
const open = useAppSelector((state) => state.globalState.storagePolicyDialogOpen);
|
||||||
|
const mode = useAppSelector((state) => state.globalState.storagePolicyDialogMode);
|
||||||
|
const file = useAppSelector((state) => state.globalState.storagePolicyDialogFile);
|
||||||
|
|
||||||
|
const [policies, setPolicies] = useState<StoragePolicyBrief[]>([]);
|
||||||
|
const [value, setValue] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isDir = mode === "dir";
|
||||||
|
const current = file?.metadata?.[Metadata.preferred_policy] ?? "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValue(isDir ? current : "");
|
||||||
|
dispatch(getAllowedPolicies()).then((res) => setPolicies(res ?? []));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const onClose = () => dispatch(closeStoragePolicyDialog());
|
||||||
|
|
||||||
|
const onChange = (e: SelectChangeEvent<unknown>) => {
|
||||||
|
setValue(e.target.value as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
const uri = file.path;
|
||||||
|
const req = isDir
|
||||||
|
? dispatch(setPreferredPolicy({ uri, policy: value })).then(() => {
|
||||||
|
enqueueSnackbar(t("fileManager.policySaved"), { variant: "success", action: DefaultCloseAction });
|
||||||
|
})
|
||||||
|
: dispatch(relocateToPolicy({ uri, policy: value })).then(() => {
|
||||||
|
enqueueSnackbar(t("fileManager.relocateSubmitted"), { variant: "success", action: DefaultCloseAction });
|
||||||
|
});
|
||||||
|
req.then(() => {
|
||||||
|
onClose();
|
||||||
|
dispatch(refreshFileList(FileManagerIndex.main));
|
||||||
|
}).finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>{isDir ? t("fileManager.dirPolicyTitle") : t("fileManager.relocateTitle")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText sx={{ mb: 2 }}>
|
||||||
|
{isDir
|
||||||
|
? t("fileManager.dirPolicyDes", { name: file?.name ?? "" })
|
||||||
|
: t("fileManager.relocateDes", { name: file?.name ?? "" })}
|
||||||
|
</DialogContentText>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<DenseSelect value={value} onChange={onChange} disabled={loading}>
|
||||||
|
{isDir && (
|
||||||
|
<SquareMenuItem value="">
|
||||||
|
<ListItemText primary={t("fileManager.policyInherit")} />
|
||||||
|
</SquareMenuItem>
|
||||||
|
)}
|
||||||
|
{policies.map((p) => (
|
||||||
|
<SquareMenuItem key={p.id} value={p.id}>
|
||||||
|
<ListItemText
|
||||||
|
primary={p.name}
|
||||||
|
secondary={p.is_default ? t("fileManager.policyGroupDefault") : undefined}
|
||||||
|
/>
|
||||||
|
</SquareMenuItem>
|
||||||
|
))}
|
||||||
|
</DenseSelect>
|
||||||
|
</FormControl>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>{t("common:cancel")}</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={loading || (!isDir && !value) || (isDir && value === current)}
|
||||||
|
>
|
||||||
|
{isDir ? t("common:save") : t("fileManager.relocateStart")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StoragePolicyDialog;
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListByGroup(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pc := NewStoragePolicyClient(client, nil)
|
||||||
|
mk := func(name string, status storagepolicy.Status) *ent.StoragePolicy {
|
||||||
|
return client.StoragePolicy.Create().
|
||||||
|
SetName(name).SetType("local").SetStatus(status).SaveX(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("legacy default included when no M2M rows", func(t *testing.T) {
|
||||||
|
def := mk("def", storagepolicy.StatusActive)
|
||||||
|
g := client.Group.Create().SetName("g1").SetPermissions(&boolset.BooleanSet{}).
|
||||||
|
SetStoragePolicies(def).SaveX(ctx)
|
||||||
|
|
||||||
|
got, err := pc.ListByGroup(ctx, g)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
require.Equal(t, def.ID, got[0].ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowed set union with default, active only, ordered by id", func(t *testing.T) {
|
||||||
|
def := mk("def", storagepolicy.StatusActive)
|
||||||
|
a := mk("a", storagepolicy.StatusActive)
|
||||||
|
b := mk("b", storagepolicy.StatusActive)
|
||||||
|
susp := mk("susp", storagepolicy.StatusSuspended)
|
||||||
|
g := client.Group.Create().SetName("g2").SetPermissions(&boolset.BooleanSet{}).
|
||||||
|
SetStoragePolicies(def).AddAllowedPolicies(a, b, susp).SaveX(ctx)
|
||||||
|
|
||||||
|
got, err := pc.ListByGroup(ctx, g)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 3)
|
||||||
|
ids := []int{got[0].ID, got[1].ID, got[2].ID}
|
||||||
|
require.IsIncreasing(t, ids)
|
||||||
|
require.ElementsMatch(t, []int{a.ID, b.ID, def.ID}, ids)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("suspended default not included", func(t *testing.T) {
|
||||||
|
def := mk("def", storagepolicy.StatusSuspended)
|
||||||
|
a := mk("a", storagepolicy.StatusActive)
|
||||||
|
g := client.Group.Create().SetName("g3").SetPermissions(&boolset.BooleanSet{}).
|
||||||
|
SetStoragePolicies(def).AddAllowedPolicies(a).SaveX(ctx)
|
||||||
|
|
||||||
|
got, err := pc.ListByGroup(ctx, g)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
require.Equal(t, a.ID, got[0].ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no usable policy returns empty", func(t *testing.T) {
|
||||||
|
def := mk("def", storagepolicy.StatusSuspended)
|
||||||
|
g := client.Group.Create().SetName("g4").SetPermissions(&boolset.BooleanSet{}).
|
||||||
|
SetStoragePolicies(def).SaveX(ctx)
|
||||||
|
|
||||||
|
got, err := pc.ListByGroup(ctx, g)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLoadBalance(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pc := NewStoragePolicyClient(client, nil)
|
||||||
|
|
||||||
|
t.Run("skips suspended, missing and nested children", func(t *testing.T) {
|
||||||
|
nested := client.StoragePolicy.Create().SetName("nested").SetType(types.PolicyTypeLoadBalance).
|
||||||
|
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||||
|
susp := client.StoragePolicy.Create().SetName("susp").SetType("local").
|
||||||
|
SetStatus(storagepolicy.StatusSuspended).SaveX(ctx)
|
||||||
|
ok := client.StoragePolicy.Create().SetName("ok").SetType("local").
|
||||||
|
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||||
|
lb := client.StoragePolicy.Create().SetName("lb").SetType(types.PolicyTypeLoadBalance).
|
||||||
|
SetStatus(storagepolicy.StatusActive).
|
||||||
|
SetSettings(&types.PolicySetting{LBPolicies: []types.LBPolicyRef{
|
||||||
|
{PolicyID: nested.ID, Weight: 10},
|
||||||
|
{PolicyID: susp.ID, Weight: 10},
|
||||||
|
{PolicyID: 99999, Weight: 10},
|
||||||
|
{PolicyID: ok.ID, Weight: 10},
|
||||||
|
}}).SaveX(ctx)
|
||||||
|
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
got, err := pc.ResolveLoadBalance(ctx, lb)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, ok.ID, got.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("weight of zero treated as one", func(t *testing.T) {
|
||||||
|
zero := client.StoragePolicy.Create().SetName("zero").SetType("local").
|
||||||
|
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||||
|
lb := client.StoragePolicy.Create().SetName("lb").SetType(types.PolicyTypeLoadBalance).
|
||||||
|
SetStatus(storagepolicy.StatusActive).
|
||||||
|
SetSettings(&types.PolicySetting{LBPolicies: []types.LBPolicyRef{
|
||||||
|
{PolicyID: zero.ID, Weight: 0},
|
||||||
|
}}).SaveX(ctx)
|
||||||
|
|
||||||
|
got, err := pc.ResolveLoadBalance(ctx, lb)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, zero.ID, got.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no usable children errors", func(t *testing.T) {
|
||||||
|
lb := client.StoragePolicy.Create().SetName("lb").SetType(types.PolicyTypeLoadBalance).
|
||||||
|
SetStatus(storagepolicy.StatusActive).
|
||||||
|
SetSettings(&types.PolicySetting{LBPolicies: []types.LBPolicyRef{
|
||||||
|
{PolicyID: 424242, Weight: 5},
|
||||||
|
}}).SaveX(ctx)
|
||||||
|
|
||||||
|
_, err := pc.ResolveLoadBalance(ctx, lb)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,138 @@
|
|||||||
|
package dbfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||||
|
entfile "github.com/cloudreve/Cloudreve/v4/ent/file"
|
||||||
|
entmetadata "github.com/cloudreve/Cloudreve/v4/ent/metadata"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/schema"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pickPolicyFixture builds an owner in a group whose allowed set is
|
||||||
|
// [a, b] with `def` as the legacy default, and a dir chain root/dir/target.
|
||||||
|
func pickPolicyFixture(t *testing.T, client *ent.Client) (*ent.Group, *ent.StoragePolicy, *ent.StoragePolicy, *ent.StoragePolicy, *ent.User, *ent.File, *ent.File) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mk := func(name string) *ent.StoragePolicy {
|
||||||
|
return client.StoragePolicy.Create().SetName(name).SetType("local").
|
||||||
|
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||||
|
}
|
||||||
|
def := mk("def")
|
||||||
|
a := mk("a")
|
||||||
|
b := mk("b")
|
||||||
|
|
||||||
|
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).
|
||||||
|
SetStoragePolicies(def).AddAllowedPolicies(a, b).SaveX(ctx)
|
||||||
|
owner := client.User.Create().SetEmail("o@example.com").SetNick("o").
|
||||||
|
SetGroup(group).SetSettings(&types.UserSetting{PreferredPolicy: b.ID}).SaveX(ctx)
|
||||||
|
|
||||||
|
root := client.File.Create().SetName(inventory.RootFolderName).
|
||||||
|
SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx)
|
||||||
|
dir := client.File.Create().SetName("dir").SetType(int(types.FileTypeFolder)).
|
||||||
|
SetOwner(owner).SetParent(root).SaveX(ctx)
|
||||||
|
return group, def, a, b, owner, root, dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickPolicyDBFS(t *testing.T, client *ent.Client, user *ent.User) *DBFS {
|
||||||
|
t.Helper()
|
||||||
|
hasher, err := hashid.New("pick-test-salt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return &DBFS{
|
||||||
|
user: user,
|
||||||
|
fileClient: inventory.NewFileClient(client, conf.SQLiteDB, hasher),
|
||||||
|
storagePolicyClient: inventory.NewStoragePolicyClient(client, nil),
|
||||||
|
hasher: hasher,
|
||||||
|
l: logging.NewConsoleLogger(logging.LevelError),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapChain(models ...*ent.File) *File {
|
||||||
|
var parent *File
|
||||||
|
for _, m := range models {
|
||||||
|
parent = &File{Model: m, Parent: parent}
|
||||||
|
}
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPreferredMarker(t *testing.T, client *ent.Client, f *ent.File, hasher hashid.Encoder, policyID int) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := schema.SkipSoftDelete(context.Background())
|
||||||
|
client.Metadata.Delete().Where(entmetadata.Name(MetadataPreferredPolicy),
|
||||||
|
entmetadata.HasFileWith(entfile.ID(f.ID))).ExecX(ctx)
|
||||||
|
client.Metadata.Create().SetFile(f).SetName(MetadataPreferredPolicy).
|
||||||
|
SetValue(hashid.EncodePolicyID(hasher, policyID)).SaveX(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshChain reloads the file rows so previously lazy-loaded metadata edges
|
||||||
|
// do not leak between subtests.
|
||||||
|
func freshChain(t *testing.T, client *ent.Client, ids ...int) *File {
|
||||||
|
t.Helper()
|
||||||
|
models := make([]*ent.File, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
models[i] = client.File.GetX(context.Background(), id)
|
||||||
|
}
|
||||||
|
return wrapChain(models...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickPolicyPrecedence(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
group, def, a, b, owner, root, dir := pickPolicyFixture(t, client)
|
||||||
|
owner.SetGroup(group)
|
||||||
|
allowed := []*ent.StoragePolicy{a, b, def}
|
||||||
|
|
||||||
|
f := pickPolicyDBFS(t, client, owner)
|
||||||
|
|
||||||
|
t.Run("directory marker wins over user preference", func(t *testing.T) {
|
||||||
|
setPreferredMarker(t, client, dir, f.hasher, a.ID)
|
||||||
|
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
|
||||||
|
require.Equal(t, a.ID, got.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("user preference wins over group default", func(t *testing.T) {
|
||||||
|
client.Metadata.Delete().ExecX(schema.SkipSoftDelete(ctx))
|
||||||
|
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
|
||||||
|
require.Equal(t, b.ID, got.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid marker stops ancestor inheritance", func(t *testing.T) {
|
||||||
|
// Root carries a valid marker, but the nearer dir marker points at a
|
||||||
|
// policy outside the allowed set — must not fall through to root's.
|
||||||
|
outside := client.StoragePolicy.Create().SetName("outside").SetType("local").
|
||||||
|
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||||
|
setPreferredMarker(t, client, root, f.hasher, a.ID)
|
||||||
|
setPreferredMarker(t, client, dir, f.hasher, outside.ID)
|
||||||
|
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
|
||||||
|
require.Equal(t, b.ID, got.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("other user's tree ignores user preference", func(t *testing.T) {
|
||||||
|
other := client.User.Create().SetEmail("x@example.com").SetNick("x").SetGroup(group).SaveX(ctx)
|
||||||
|
otherFs := pickPolicyDBFS(t, client, other)
|
||||||
|
got := otherFs.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed)
|
||||||
|
require.Equal(t, def.ID, got.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("group default applies without preferences", func(t *testing.T) {
|
||||||
|
ownerNoPref := client.User.Create().SetEmail("np@example.com").SetNick("np").
|
||||||
|
SetGroup(group).SetSettings(&types.UserSetting{}).SaveX(ctx)
|
||||||
|
ownerNoPref.SetGroup(group)
|
||||||
|
fsNoPref := pickPolicyDBFS(t, client, ownerNoPref)
|
||||||
|
got := fsNoPref.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), ownerNoPref, allowed)
|
||||||
|
require.Equal(t, def.ID, got.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,238 @@
|
|||||||
|
package explorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/workflows"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StoragePolicyBrief is the user-facing view of an allowed storage policy.
|
||||||
|
type StoragePolicyBrief struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
IsDefault bool `json:"is_default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
// AllowedPolicyService lists the storage policies the caller's group may use.
|
||||||
|
AllowedPolicyService struct{}
|
||||||
|
AllowedPolicyParamCtx struct{}
|
||||||
|
|
||||||
|
// PreferredPolicyService sets or clears a directory's preferred storage
|
||||||
|
// policy. New uploads inside the directory use it.
|
||||||
|
PreferredPolicyService struct {
|
||||||
|
Uri string `json:"uri" binding:"required"`
|
||||||
|
Policy string `json:"policy"`
|
||||||
|
}
|
||||||
|
PreferredPolicyParamCtx struct{}
|
||||||
|
|
||||||
|
// FileRelocateService moves a file's or folder's entities to another
|
||||||
|
// storage policy via the resumable relocation task.
|
||||||
|
FileRelocateService struct {
|
||||||
|
Uri string `json:"uri" binding:"required"`
|
||||||
|
Policy string `json:"policy" binding:"required"`
|
||||||
|
}
|
||||||
|
FileRelocateParamCtx struct{}
|
||||||
|
|
||||||
|
// FileRelocateResponse carries the created relocation task id.
|
||||||
|
FileRelocateResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// allowedGroupPolicies resolves the group's usable policy set and maps each
|
||||||
|
// to a brief. The group's configured default is marked.
|
||||||
|
func allowedGroupPolicies(c *gin.Context, group *ent.Group, dep dependency.Dep) ([]*ent.StoragePolicy, []*StoragePolicyBrief, error) {
|
||||||
|
allowed, err := dep.StoragePolicyClient().ListByGroup(c, group)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, serializer.NewError(serializer.CodeDBError, "Failed to list storage policies", err)
|
||||||
|
}
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return nil, nil, serializer.NewError(serializer.CodeNoPermissionErr, "No storage policy is available for your group", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher := dep.HashIDEncoder()
|
||||||
|
briefs := lo.Map(allowed, func(p *ent.StoragePolicy, _ int) *StoragePolicyBrief {
|
||||||
|
return &StoragePolicyBrief{
|
||||||
|
ID: hashid.EncodePolicyID(hasher, p.ID),
|
||||||
|
Name: p.Name,
|
||||||
|
Type: p.Type,
|
||||||
|
IsDefault: p.ID == group.StoragePolicyID,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return allowed, briefs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AllowedPolicyService) Get(c *gin.Context) ([]*StoragePolicyBrief, error) {
|
||||||
|
user := inventory.UserFromContext(c)
|
||||||
|
if user.Edges.Group == nil {
|
||||||
|
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "Group not loaded", nil)
|
||||||
|
}
|
||||||
|
_, briefs, err := allowedGroupPolicies(c, user.Edges.Group, dependency.FromContext(c))
|
||||||
|
return briefs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeAllowedPolicy validates a hashid-encoded policy id against the
|
||||||
|
// group's allowed set and returns the matching policy.
|
||||||
|
func decodeAllowedPolicy(c *gin.Context, dep dependency.Dep, group *ent.Group, raw string) (*ent.StoragePolicy, error) {
|
||||||
|
id, err := dep.HashIDEncoder().Decode(raw, hashid.PolicyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid storage policy", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := dep.StoragePolicyClient().ListByGroup(c, group)
|
||||||
|
if err != nil {
|
||||||
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list storage policies", err)
|
||||||
|
}
|
||||||
|
for _, p := range allowed {
|
||||||
|
if p.ID == id {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "Storage policy is not available for your group", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownedPolicyFile resolves the target and enforces ownership, returning the
|
||||||
|
// fs file for metadata access.
|
||||||
|
func ownedPolicyFile(c *gin.Context, dep dependency.Dep, uriRaw string) (*dbfs.File, error) {
|
||||||
|
user := inventory.UserFromContext(c)
|
||||||
|
uri, err := fs.NewUriFromString(uriRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := manager.NewFileManager(dep, user)
|
||||||
|
defer m.Recycle()
|
||||||
|
|
||||||
|
f, err := m.Get(c, uri, dbfs.WithFileEntities())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||||
|
}
|
||||||
|
file, ok := f.(*dbfs.File)
|
||||||
|
if !ok {
|
||||||
|
return nil, serializer.NewError(serializer.CodeParamErr, "Unsupported file system", nil)
|
||||||
|
}
|
||||||
|
if file.OwnerID() != user.ID {
|
||||||
|
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "Only the owner can manage storage policies", nil)
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PreferredPolicyService) Update(c *gin.Context) (*StoragePolicyBrief, error) {
|
||||||
|
dep := dependency.FromContext(c)
|
||||||
|
user := inventory.UserFromContext(c)
|
||||||
|
|
||||||
|
file, err := ownedPolicyFile(c, dep, s.Uri)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if file.Type() != types.FileTypeFolder {
|
||||||
|
return nil, serializer.NewError(serializer.CodeParamErr, "Preferred storage policy applies to folders only", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var policy *ent.StoragePolicy
|
||||||
|
if s.Policy != "" {
|
||||||
|
policy, err = decodeAllowedPolicy(c, dep, user.Edges.Group, s.Policy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fc := dep.FileClient()
|
||||||
|
if policy == nil {
|
||||||
|
err = fc.RemoveMetadata(c, file.Model, dbfs.MetadataPreferredPolicy)
|
||||||
|
} else {
|
||||||
|
err = fc.UpsertMetadata(c, file.Model, map[string]string{
|
||||||
|
dbfs.MetadataPreferredPolicy: hashid.EncodePolicyID(dep.HashIDEncoder(), policy.ID),
|
||||||
|
}, map[string]bool{dbfs.MetadataPreferredPolicy: true})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update metadata", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if policy == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &StoragePolicyBrief{
|
||||||
|
ID: hashid.EncodePolicyID(dep.HashIDEncoder(), policy.ID),
|
||||||
|
Name: policy.Name,
|
||||||
|
Type: policy.Type,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileRelocateService) Create(c *gin.Context) (*FileRelocateResponse, error) {
|
||||||
|
dep := dependency.FromContext(c)
|
||||||
|
user := inventory.UserFromContext(c)
|
||||||
|
|
||||||
|
policy, err := decodeAllowedPolicy(c, dep, user.Edges.Group, s.Policy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A load-balance target resolves to one concrete child at enqueue time.
|
||||||
|
if policy.Type == types.PolicyTypeLoadBalance {
|
||||||
|
policy, err = dep.StoragePolicyClient().ResolveLoadBalance(c, policy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to resolve load-balanced storage policy", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := ownedPolicyFile(c, dep, s.Uri)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth is a countdown in the walk implementation; a large value walks
|
||||||
|
// the whole subtree. Entities already on the destination are skipped by
|
||||||
|
// the task, so they are filtered out of the request entirely.
|
||||||
|
pending := lo.Filter(file.Model.Edges.Entities, func(e *ent.Entity, _ int) bool {
|
||||||
|
return e.StoragePolicyEntities != policy.ID
|
||||||
|
})
|
||||||
|
entityIDs := lo.Map(pending, func(e *ent.Entity, _ int) int { return e.ID })
|
||||||
|
if file.Type() == types.FileTypeFolder {
|
||||||
|
uri, _ := fs.NewUriFromString(s.Uri)
|
||||||
|
m := manager.NewFileManager(dep, user)
|
||||||
|
defer m.Recycle()
|
||||||
|
err = m.Walk(c, uri, 1<<30, func(f fs.File, _ int) error {
|
||||||
|
dbFile, ok := f.(*dbfs.File)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, e := range dbFile.Model.Edges.Entities {
|
||||||
|
if e.StoragePolicyEntities != policy.ID {
|
||||||
|
entityIDs = append(entityIDs, e.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, dbfs.WithFileEntities())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to walk folder: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entityIDs = lo.Uniq(entityIDs)
|
||||||
|
if len(entityIDs) == 0 {
|
||||||
|
return nil, serializer.NewError(serializer.CodeParamErr, "Nothing to relocate", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := workflows.NewRelocateTask(c, entityIDs, policy.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create relocation task", err)
|
||||||
|
}
|
||||||
|
if err := dep.IoIntenseQueue(c).QueueTask(c, t); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to submit task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FileRelocateResponse{ID: hashid.EncodeTaskID(dep.HashIDEncoder(), t.ID())}, nil
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
package explorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// policyDepStub exposes only the dependencies decodeAllowedPolicy touches.
|
||||||
|
type policyDepStub struct {
|
||||||
|
dependency.Dep
|
||||||
|
policyClient inventory.StoragePolicyClient
|
||||||
|
hasher hashid.Encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *policyDepStub) StoragePolicyClient() inventory.StoragePolicyClient { return d.policyClient }
|
||||||
|
func (d *policyDepStub) HashIDEncoder() hashid.Encoder { return d.hasher }
|
||||||
|
|
||||||
|
func TestDecodeAllowedPolicy(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mk := func(name string) *ent.StoragePolicy {
|
||||||
|
return client.StoragePolicy.Create().SetName(name).SetType("local").
|
||||||
|
SetStatus(storagepolicy.StatusActive).SaveX(ctx)
|
||||||
|
}
|
||||||
|
def := mk("def")
|
||||||
|
a := mk("a")
|
||||||
|
outside := mk("outside")
|
||||||
|
|
||||||
|
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).
|
||||||
|
SetStoragePolicies(def).AddAllowedPolicies(a).SaveX(ctx)
|
||||||
|
|
||||||
|
hasher, err := hashid.New("policy-test-salt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
dep := &policyDepStub{policyClient: inventory.NewStoragePolicyClient(client, nil), hasher: hasher}
|
||||||
|
|
||||||
|
engine := gin.New()
|
||||||
|
engine.ContextWithFallback = true
|
||||||
|
c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
|
||||||
|
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||||
|
util.WithValue(c, dependency.DepCtx{}, dep)
|
||||||
|
|
||||||
|
t.Run("allowed policy decodes", func(t *testing.T) {
|
||||||
|
got, err := decodeAllowedPolicy(c, dep, group, hashid.EncodePolicyID(hasher, a.ID))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, a.ID, got.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("legacy default policy decodes", func(t *testing.T) {
|
||||||
|
got, err := decodeAllowedPolicy(c, dep, group, hashid.EncodePolicyID(hasher, def.ID))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, def.ID, got.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("policy outside the group set is rejected", func(t *testing.T) {
|
||||||
|
_, err := decodeAllowedPolicy(c, dep, group, hashid.EncodePolicyID(hasher, outside.ID))
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid hashid is rejected", func(t *testing.T) {
|
||||||
|
_, err := decodeAllowedPolicy(c, dep, group, "!!!")
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in new issue