Merge 58e48a449e into 943e5340d0
This commit is contained in:
commit
79f130c0f5
17
cmd/users.go
17
cmd/users.go
@ -80,6 +80,9 @@ func addUserFlags(flags *pflag.FlagSet) {
|
|||||||
flags.Bool("dateFormat", false, "use date format (true for absolute time, false for relative)")
|
flags.Bool("dateFormat", false, "use date format (true for absolute time, false for relative)")
|
||||||
flags.Bool("hideDotfiles", false, "hide dotfiles")
|
flags.Bool("hideDotfiles", false, "hide dotfiles")
|
||||||
flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users")
|
flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users")
|
||||||
|
flags.Float64("quota.limit", 0, "quota limit in KB, MB, GB or TB (0 = unlimited)")
|
||||||
|
flags.String("quota.unit", "GB", "quota unit (KB, MB, GB or TB)")
|
||||||
|
flags.Bool("quota.enforce", false, "enforce quota (hard limit)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAndParseViewMode(flags *pflag.FlagSet) (users.ViewMode, error) {
|
func getAndParseViewMode(flags *pflag.FlagSet) (users.ViewMode, error) {
|
||||||
@ -136,6 +139,20 @@ func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all
|
|||||||
defaults.Sorting.Asc, err = flags.GetBool(flag.Name)
|
defaults.Sorting.Asc, err = flags.GetBool(flag.Name)
|
||||||
case "hideDotfiles":
|
case "hideDotfiles":
|
||||||
defaults.HideDotfiles, err = flags.GetBool(flag.Name)
|
defaults.HideDotfiles, err = flags.GetBool(flag.Name)
|
||||||
|
case "quota.limit":
|
||||||
|
var quotaLimit float64
|
||||||
|
quotaLimit, err = flags.GetFloat64(flag.Name)
|
||||||
|
if err == nil {
|
||||||
|
var quotaUnit string
|
||||||
|
quotaUnit, err = flags.GetString("quota.unit")
|
||||||
|
if err == nil {
|
||||||
|
defaults.QuotaLimit, err = users.ConvertToBytes(quotaLimit, quotaUnit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "quota.unit":
|
||||||
|
defaults.QuotaUnit, err = flags.GetString(flag.Name)
|
||||||
|
case "quota.enforce":
|
||||||
|
defaults.EnforceQuota, err = flags.GetBool(flag.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -52,13 +52,16 @@ options you want to change.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaults := settings.UserDefaults{
|
defaults := settings.UserDefaults{
|
||||||
Scope: user.Scope,
|
Scope: user.Scope,
|
||||||
Locale: user.Locale,
|
Locale: user.Locale,
|
||||||
ViewMode: user.ViewMode,
|
ViewMode: user.ViewMode,
|
||||||
SingleClick: user.SingleClick,
|
SingleClick: user.SingleClick,
|
||||||
Perm: user.Perm,
|
Perm: user.Perm,
|
||||||
Sorting: user.Sorting,
|
Sorting: user.Sorting,
|
||||||
Commands: user.Commands,
|
Commands: user.Commands,
|
||||||
|
QuotaLimit: user.QuotaLimit,
|
||||||
|
QuotaUnit: user.QuotaUnit,
|
||||||
|
EnforceQuota: user.EnforceQuota,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = getUserDefaults(flags, &defaults, false)
|
err = getUserDefaults(flags, &defaults, false)
|
||||||
@ -73,6 +76,9 @@ options you want to change.`,
|
|||||||
user.Perm = defaults.Perm
|
user.Perm = defaults.Perm
|
||||||
user.Commands = defaults.Commands
|
user.Commands = defaults.Commands
|
||||||
user.Sorting = defaults.Sorting
|
user.Sorting = defaults.Sorting
|
||||||
|
user.QuotaLimit = defaults.QuotaLimit
|
||||||
|
user.QuotaUnit = defaults.QuotaUnit
|
||||||
|
user.EnforceQuota = defaults.EnforceQuota
|
||||||
user.LockPassword, err = flags.GetBool("lockPassword")
|
user.LockPassword, err = flags.GetBool("lockPassword")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -22,6 +22,8 @@ var (
|
|||||||
ErrInvalidRequestParams = errors.New("invalid request params")
|
ErrInvalidRequestParams = errors.New("invalid request params")
|
||||||
ErrSourceIsParent = errors.New("source is parent")
|
ErrSourceIsParent = errors.New("source is parent")
|
||||||
ErrRootUserDeletion = errors.New("user with id 1 can't be deleted")
|
ErrRootUserDeletion = errors.New("user with id 1 can't be deleted")
|
||||||
|
ErrQuotaExceeded = errors.New("quota exceeded")
|
||||||
|
ErrInvalidQuotaUnit = errors.New("invalid quota unit")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ErrShortPassword struct {
|
type ErrShortPassword struct {
|
||||||
|
|||||||
@ -47,6 +47,11 @@ export async function upload(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Do not retry for quota exceeded (413 Request Entity Too Large)
|
||||||
|
if (status === 413) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onError: function (error: Error | tus.DetailedError) {
|
onError: function (error: Error | tus.DetailedError) {
|
||||||
@ -56,12 +61,29 @@ export async function upload(
|
|||||||
return reject(error);
|
return reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const message =
|
let message = "Upload failed";
|
||||||
error instanceof tus.DetailedError
|
|
||||||
? error.originalResponse === null
|
if (error instanceof tus.DetailedError) {
|
||||||
? "000 No connection"
|
if (error.originalResponse === null) {
|
||||||
: error.originalResponse.getBody()
|
message = "000 No connection";
|
||||||
: "Upload failed";
|
} else {
|
||||||
|
const status = error.originalResponse.getStatus();
|
||||||
|
|
||||||
|
// Handle quota exceeded error (413)
|
||||||
|
if (status === 413) {
|
||||||
|
const quotaUsed = error.originalResponse.getHeader("X-Quota-Used");
|
||||||
|
const quotaLimit = error.originalResponse.getHeader("X-Quota-Limit");
|
||||||
|
|
||||||
|
if (quotaUsed && quotaLimit) {
|
||||||
|
message = `Cannot upload file: storage quota exceeded. Current usage: ${quotaUsed}, Limit: ${quotaLimit}`;
|
||||||
|
} else {
|
||||||
|
message = "Cannot upload file: storage quota exceeded";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message = error.originalResponse.getBody();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
|
|||||||
@ -41,3 +41,7 @@ export async function remove(id: number) {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getQuota(id: number) {
|
||||||
|
return fetchJSON<IQuotaInfo>(`/api/users/${id}/quota`, {});
|
||||||
|
}
|
||||||
|
|||||||
@ -90,9 +90,9 @@
|
|||||||
v-if="isFiles && !disableUsedPercentage"
|
v-if="isFiles && !disableUsedPercentage"
|
||||||
style="width: 90%; margin: 2em 2.5em 3em 2.5em"
|
style="width: 90%; margin: 2em 2.5em 3em 2.5em"
|
||||||
>
|
>
|
||||||
<progress-bar :val="usage.usedPercentage" size="small"></progress-bar>
|
<progress-bar :val="usagePercentage" size="small"></progress-bar>
|
||||||
<br />
|
<br />
|
||||||
{{ usage.used }} of {{ usage.total }} used
|
{{ usedFormatted }} of {{ totalFormatted }} used
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="credits">
|
<p class="credits">
|
||||||
@ -161,6 +161,37 @@ export default {
|
|||||||
disableExternal: () => disableExternal,
|
disableExternal: () => disableExternal,
|
||||||
disableUsedPercentage: () => disableUsedPercentage,
|
disableUsedPercentage: () => disableUsedPercentage,
|
||||||
canLogout: () => !noAuth && (loginPage || logoutPage !== "/login"),
|
canLogout: () => !noAuth && (loginPage || logoutPage !== "/login"),
|
||||||
|
hasQuotaData() {
|
||||||
|
return this.user?.quotaLimit && this.user.quotaLimit > 0;
|
||||||
|
},
|
||||||
|
quotaLimitBytes() {
|
||||||
|
// QuotaLimit is already stored in bytes in the backend
|
||||||
|
return this.user?.quotaLimit || 0;
|
||||||
|
},
|
||||||
|
quotaUsedBytes() {
|
||||||
|
// QuotaUsed is already in bytes
|
||||||
|
return this.user?.quotaUsed || 0;
|
||||||
|
},
|
||||||
|
// Unified properties that use quota if available, otherwise disk usage
|
||||||
|
usagePercentage() {
|
||||||
|
if (this.hasQuotaData) {
|
||||||
|
if (this.quotaLimitBytes === 0) return 0;
|
||||||
|
return Math.min(Math.round((this.quotaUsedBytes / this.quotaLimitBytes) * 100), 100);
|
||||||
|
}
|
||||||
|
return this.usage.usedPercentage;
|
||||||
|
},
|
||||||
|
usedFormatted() {
|
||||||
|
if (this.hasQuotaData) {
|
||||||
|
return prettyBytes(this.quotaUsedBytes, { binary: true });
|
||||||
|
}
|
||||||
|
return this.usage.used;
|
||||||
|
},
|
||||||
|
totalFormatted() {
|
||||||
|
if (this.hasQuotaData) {
|
||||||
|
return prettyBytes(this.quotaLimitBytes, { binary: true });
|
||||||
|
}
|
||||||
|
return this.usage.total;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
|
...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
|
||||||
@ -168,6 +199,11 @@ export default {
|
|||||||
this.usageAbortController.abort();
|
this.usageAbortController.abort();
|
||||||
},
|
},
|
||||||
async fetchUsage() {
|
async fetchUsage() {
|
||||||
|
// If user has quota, don't fetch disk usage
|
||||||
|
if (this.hasQuotaData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const path = this.$route.path.endsWith("/")
|
const path = this.$route.path.endsWith("/")
|
||||||
? this.$route.path
|
? this.$route.path
|
||||||
: this.$route.path + "/";
|
: this.$route.path + "/";
|
||||||
|
|||||||
207
frontend/src/components/settings/QuotaInput.vue
Normal file
207
frontend/src/components/settings/QuotaInput.vue
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
<template>
|
||||||
|
<div class="quota-input">
|
||||||
|
<h3>{{ t("settings.quota") }}</h3>
|
||||||
|
<div class="quota-controls">
|
||||||
|
<div class="input-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="localEnforceQuota"
|
||||||
|
@change="updateQuota"
|
||||||
|
/>
|
||||||
|
{{ t("settings.enforceQuota") }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="input-group" v-if="localEnforceQuota">
|
||||||
|
<label>{{ t("settings.quotaLimit") }}</label>
|
||||||
|
<div class="quota-input-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="localQuotaValue"
|
||||||
|
@input="updateQuota"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
:disabled="!localEnforceQuota"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-model="localQuotaUnit"
|
||||||
|
@change="updateQuota"
|
||||||
|
:disabled="!localEnforceQuota"
|
||||||
|
>
|
||||||
|
<option value="KB">KB</option>
|
||||||
|
<option value="MB">MB</option>
|
||||||
|
<option value="GB">GB</option>
|
||||||
|
<option value="TB">TB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
quotaLimit?: number;
|
||||||
|
quotaUnit?: string;
|
||||||
|
enforceQuota?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
quotaLimit: 0,
|
||||||
|
quotaUnit: "GB",
|
||||||
|
enforceQuota: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:quotaLimit", value: number): void;
|
||||||
|
(e: "update:quotaUnit", value: string): void;
|
||||||
|
(e: "update:enforceQuota", value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const localQuotaValue = ref(0);
|
||||||
|
const localQuotaUnit = ref("GB");
|
||||||
|
const localEnforceQuota = ref(false);
|
||||||
|
|
||||||
|
const KB = 1024;
|
||||||
|
const MB = 1024 * KB;
|
||||||
|
const GB = 1024 * MB;
|
||||||
|
const TB = 1024 * GB;
|
||||||
|
|
||||||
|
function convertFromBytes(bytes: number, unit: string): number {
|
||||||
|
switch (unit) {
|
||||||
|
case "KB":
|
||||||
|
return bytes / KB;
|
||||||
|
case "MB":
|
||||||
|
return bytes / MB;
|
||||||
|
case "TB":
|
||||||
|
return bytes / TB;
|
||||||
|
case "GB":
|
||||||
|
default:
|
||||||
|
return bytes / GB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToBytes(value: number, unit: string): number {
|
||||||
|
switch (unit) {
|
||||||
|
case "KB":
|
||||||
|
return value * KB;
|
||||||
|
case "MB":
|
||||||
|
return value * MB;
|
||||||
|
case "TB":
|
||||||
|
return value * TB;
|
||||||
|
case "GB":
|
||||||
|
default:
|
||||||
|
return value * GB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuota() {
|
||||||
|
emit("update:enforceQuota", localEnforceQuota.value);
|
||||||
|
emit("update:quotaUnit", localQuotaUnit.value);
|
||||||
|
|
||||||
|
if (localEnforceQuota.value) {
|
||||||
|
const bytes = convertToBytes(localQuotaValue.value, localQuotaUnit.value);
|
||||||
|
emit("update:quotaLimit", bytes);
|
||||||
|
} else {
|
||||||
|
emit("update:quotaLimit", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.quotaLimit, props.quotaUnit, props.enforceQuota],
|
||||||
|
() => {
|
||||||
|
localEnforceQuota.value = props.enforceQuota;
|
||||||
|
localQuotaUnit.value = props.quotaUnit || "GB";
|
||||||
|
|
||||||
|
if (props.quotaLimit > 0) {
|
||||||
|
localQuotaValue.value = convertFromBytes(
|
||||||
|
props.quotaLimit,
|
||||||
|
localQuotaUnit.value
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
localQuotaValue.value = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
localEnforceQuota.value = props.enforceQuota;
|
||||||
|
localQuotaUnit.value = props.quotaUnit || "GB";
|
||||||
|
|
||||||
|
if (props.quotaLimit > 0) {
|
||||||
|
localQuotaValue.value = convertFromBytes(
|
||||||
|
props.quotaLimit,
|
||||||
|
localQuotaUnit.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quota-input {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-input h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-input-row input[type="number"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-input-row select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled,
|
||||||
|
select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
174
frontend/src/components/settings/QuotaUsage.vue
Normal file
174
frontend/src/components/settings/QuotaUsage.vue
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<div class="quota-usage" v-if="quotaLimit > 0">
|
||||||
|
<h3>{{ t("settings.quotaUsage") }}</h3>
|
||||||
|
<div class="quota-bar-container">
|
||||||
|
<div class="quota-bar">
|
||||||
|
<div
|
||||||
|
class="quota-fill"
|
||||||
|
:class="usageClass"
|
||||||
|
:style="{ width: usagePercentage + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="quota-text">
|
||||||
|
<span>{{ formatSize(quotaUsed) }} / {{ formatSize(quotaLimit) }}</span>
|
||||||
|
<span class="quota-percentage" :class="usageClass">
|
||||||
|
{{ usagePercentage.toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="usagePercentage >= 95" class="quota-warning critical">
|
||||||
|
<i class="material-icons">warning</i>
|
||||||
|
{{ t("settings.quotaExceeded") }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="usagePercentage >= 80" class="quota-warning">
|
||||||
|
<i class="material-icons">info</i>
|
||||||
|
{{ t("settings.quotaWarning") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
quotaUsed: number;
|
||||||
|
quotaLimit: number;
|
||||||
|
quotaUnit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
quotaUnit: "GB",
|
||||||
|
});
|
||||||
|
|
||||||
|
const usagePercentage = computed(() => {
|
||||||
|
if (props.quotaLimit === 0) return 0;
|
||||||
|
return Math.min((props.quotaUsed / props.quotaLimit) * 100, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageClass = computed(() => {
|
||||||
|
const percentage = usagePercentage.value;
|
||||||
|
if (percentage >= 95) return "critical";
|
||||||
|
if (percentage >= 80) return "warning";
|
||||||
|
return "normal";
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
const TB = 1024 * 1024 * 1024 * 1024;
|
||||||
|
const GB = 1024 * 1024 * 1024;
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
const KB = 1024;
|
||||||
|
|
||||||
|
if (bytes >= TB) {
|
||||||
|
return `${(bytes / TB).toFixed(2)} TB`;
|
||||||
|
}
|
||||||
|
if (bytes >= GB) {
|
||||||
|
return `${(bytes / GB).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
if (bytes >= MB) {
|
||||||
|
return `${(bytes / MB).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
if (bytes >= KB) {
|
||||||
|
return `${(bytes / KB).toFixed(2)} KB`;
|
||||||
|
}
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quota-usage {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-usage h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-bar-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
background-color: var(--background-hover);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-fill {
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.3s ease, background-color 0.3s ease;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-fill.normal {
|
||||||
|
background-color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-fill.warning {
|
||||||
|
background-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-fill.critical {
|
||||||
|
background-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-text {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-percentage {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-percentage.normal {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-percentage.warning {
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-percentage.critical {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-warning.critical {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border-color: #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-warning i {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -58,6 +58,13 @@
|
|||||||
<permissions v-model:perm="user.perm" />
|
<permissions v-model:perm="user.perm" />
|
||||||
<commands v-if="enableExec" v-model:commands="user.commands" />
|
<commands v-if="enableExec" v-model:commands="user.commands" />
|
||||||
|
|
||||||
|
<quota-input
|
||||||
|
v-if="!isDefault && isLoggedInUserAdmin"
|
||||||
|
v-model:quota-limit="user.quotaLimit"
|
||||||
|
v-model:quota-unit="user.quotaUnit"
|
||||||
|
v-model:enforce-quota="user.enforceQuota"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="!isDefault">
|
<div v-if="!isDefault">
|
||||||
<h3>{{ t("settings.rules") }}</h3>
|
<h3>{{ t("settings.rules") }}</h3>
|
||||||
<p class="small">{{ t("settings.rulesHelp") }}</p>
|
<p class="small">{{ t("settings.rulesHelp") }}</p>
|
||||||
@ -71,11 +78,15 @@ import Languages from "./Languages.vue";
|
|||||||
import Rules from "./Rules.vue";
|
import Rules from "./Rules.vue";
|
||||||
import Permissions from "./Permissions.vue";
|
import Permissions from "./Permissions.vue";
|
||||||
import Commands from "./Commands.vue";
|
import Commands from "./Commands.vue";
|
||||||
|
import QuotaInput from "./QuotaInput.vue";
|
||||||
import { enableExec } from "@/utils/constants";
|
import { enableExec } from "@/utils/constants";
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const isLoggedInUserAdmin = computed(() => authStore.user?.perm.admin);
|
||||||
|
|
||||||
const createUserDirData = ref<boolean | null>(null);
|
const createUserDirData = ref<boolean | null>(null);
|
||||||
const originalUserScope = ref<string | null>(null);
|
const originalUserScope = ref<string | null>(null);
|
||||||
|
|||||||
@ -61,7 +61,8 @@
|
|||||||
"forbidden": "You don't have permissions to access this.",
|
"forbidden": "You don't have permissions to access this.",
|
||||||
"internal": "Something really went wrong.",
|
"internal": "Something really went wrong.",
|
||||||
"notFound": "This location can't be reached.",
|
"notFound": "This location can't be reached.",
|
||||||
"connection": "The server can't be reached."
|
"connection": "The server can't be reached.",
|
||||||
|
"quotaExceeded": "Cannot upload file: storage quota exceeded. Current usage: {used}, Limit: {limit}"
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"body": "Body",
|
"body": "Body",
|
||||||
@ -258,7 +259,14 @@
|
|||||||
"userManagement": "User Management",
|
"userManagement": "User Management",
|
||||||
"userUpdated": "User updated!",
|
"userUpdated": "User updated!",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"users": "Users"
|
"users": "Users",
|
||||||
|
"quota": "Storage Quota",
|
||||||
|
"quotaLimit": "Quota Limit",
|
||||||
|
"quotaUnit": "Unit",
|
||||||
|
"enforceQuota": "Enforce Quota (Hard Limit)",
|
||||||
|
"quotaUsage": "Quota Usage",
|
||||||
|
"quotaExceeded": "Storage quota exceeded",
|
||||||
|
"quotaWarning": "Approaching storage quota limit"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
|
|||||||
15
frontend/src/types/user.d.ts
vendored
15
frontend/src/types/user.d.ts
vendored
@ -14,6 +14,10 @@ interface IUser {
|
|||||||
viewMode: ViewModeType;
|
viewMode: ViewModeType;
|
||||||
sorting?: Sorting;
|
sorting?: Sorting;
|
||||||
aceEditorTheme: string;
|
aceEditorTheme: string;
|
||||||
|
quotaLimit: number;
|
||||||
|
quotaUnit: string;
|
||||||
|
enforceQuota: boolean;
|
||||||
|
quotaUsed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewModeType = "list" | "mosaic" | "mosaic gallery";
|
type ViewModeType = "list" | "mosaic" | "mosaic gallery";
|
||||||
@ -31,6 +35,9 @@ interface IUserForm {
|
|||||||
hideDotfiles?: boolean;
|
hideDotfiles?: boolean;
|
||||||
singleClick?: boolean;
|
singleClick?: boolean;
|
||||||
dateFormat?: boolean;
|
dateFormat?: boolean;
|
||||||
|
quotaLimit?: number;
|
||||||
|
quotaUnit?: string;
|
||||||
|
enforceQuota?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Permissions {
|
interface Permissions {
|
||||||
@ -64,4 +71,12 @@ interface IRegexp {
|
|||||||
raw: string;
|
raw: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IQuotaInfo {
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
unit: string;
|
||||||
|
enforce: boolean;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
type UserTheme = "light" | "dark" | "";
|
type UserTheme = "light" | "dark" | "";
|
||||||
|
|||||||
@ -99,6 +99,10 @@ const fetchData = async () => {
|
|||||||
rules: [],
|
rules: [],
|
||||||
lockPassword: false,
|
lockPassword: false,
|
||||||
id: 0,
|
id: 0,
|
||||||
|
quotaLimit: 0,
|
||||||
|
quotaUnit: "GB",
|
||||||
|
enforceQuota: false,
|
||||||
|
quotaUsed: 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const id = Array.isArray(route.params.id)
|
const id = Array.isArray(route.params.id)
|
||||||
|
|||||||
21
http/auth.go
21
http/auth.go
@ -34,6 +34,10 @@ type userInfo struct {
|
|||||||
DateFormat bool `json:"dateFormat"`
|
DateFormat bool `json:"dateFormat"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
AceEditorTheme string `json:"aceEditorTheme"`
|
AceEditorTheme string `json:"aceEditorTheme"`
|
||||||
|
QuotaLimit uint64 `json:"quotaLimit"`
|
||||||
|
QuotaUnit string `json:"quotaUnit"`
|
||||||
|
EnforceQuota bool `json:"enforceQuota"`
|
||||||
|
QuotaUsed uint64 `json:"quotaUsed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type authToken struct {
|
type authToken struct {
|
||||||
@ -202,6 +206,19 @@ func renewHandler(tokenExpireTime time.Duration) handleFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.User, tokenExpirationTime time.Duration) (int, error) {
|
func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.User, tokenExpirationTime time.Duration) (int, error) {
|
||||||
|
// Calculate current quota usage if quota is enabled
|
||||||
|
var quotaUsed uint64
|
||||||
|
if user.QuotaLimit > 0 {
|
||||||
|
used, err := users.CalculateUserQuota(user.Fs, user.Scope)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but don't fail login - just set usage to 0
|
||||||
|
log.Printf("Failed to calculate quota for user %s: %v", user.Username, err)
|
||||||
|
quotaUsed = 0
|
||||||
|
} else {
|
||||||
|
quotaUsed = used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
claims := &authToken{
|
claims := &authToken{
|
||||||
User: userInfo{
|
User: userInfo{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
@ -215,6 +232,10 @@ func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.Use
|
|||||||
DateFormat: user.DateFormat,
|
DateFormat: user.DateFormat,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
AceEditorTheme: user.AceEditorTheme,
|
AceEditorTheme: user.AceEditorTheme,
|
||||||
|
QuotaLimit: user.QuotaLimit,
|
||||||
|
QuotaUnit: user.QuotaUnit,
|
||||||
|
EnforceQuota: user.EnforceQuota,
|
||||||
|
QuotaUsed: quotaUsed,
|
||||||
},
|
},
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
|||||||
@ -59,6 +59,7 @@ func NewHandler(
|
|||||||
users.Handle("/{id:[0-9]+}", monkey(userPutHandler, "")).Methods("PUT")
|
users.Handle("/{id:[0-9]+}", monkey(userPutHandler, "")).Methods("PUT")
|
||||||
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
|
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
|
||||||
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
|
users.Handle("/{id:[0-9]+}", monkey(userDeleteHandler, "")).Methods("DELETE")
|
||||||
|
users.Handle("/{id:[0-9]+}/quota", monkey(userQuotaHandler, "")).Methods("GET")
|
||||||
|
|
||||||
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
|
api.PathPrefix("/resources").Handler(monkey(resourceGetHandler, "/api/resources")).Methods("GET")
|
||||||
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
|
api.PathPrefix("/resources").Handler(monkey(resourceDeleteHandler(fileCache), "/api/resources")).Methods("DELETE")
|
||||||
|
|||||||
65
http/quota.go
Normal file
65
http/quota.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package fbhttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
fberrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||||
|
"github.com/filebrowser/filebrowser/v2/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withQuotaCheck wraps a handler function with quota validation middleware.
|
||||||
|
// It checks if the user's quota would be exceeded by the operation before allowing it.
|
||||||
|
func withQuotaCheck(fn handleFunc) handleFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
|
// Skip quota check if user has no quota limit or quota is not enforced
|
||||||
|
if d.user.QuotaLimit == 0 || !d.user.EnforceQuota {
|
||||||
|
return fn(w, r, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the file size from the request
|
||||||
|
fileSize, err := getFileSize(r)
|
||||||
|
if err != nil {
|
||||||
|
// If we can't determine file size, allow the operation
|
||||||
|
// (it will be checked during actual write)
|
||||||
|
return fn(w, r, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the operation would exceed quota
|
||||||
|
exceeded, currentUsage, err := users.CheckQuotaExceeded(d.user, fileSize)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if exceeded {
|
||||||
|
// Return 413 Payload Too Large with quota information
|
||||||
|
w.Header().Set("X-Quota-Limit", users.FormatQuotaDisplay(d.user.QuotaLimit))
|
||||||
|
w.Header().Set("X-Quota-Used", users.FormatQuotaDisplay(currentUsage))
|
||||||
|
return http.StatusRequestEntityTooLarge, fberrors.ErrQuotaExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quota check passed, proceed with the operation
|
||||||
|
return fn(w, r, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFileSize extracts the file size from the HTTP request.
|
||||||
|
// It checks the Upload-Length header (for TUS uploads) first, then Content-Length.
|
||||||
|
func getFileSize(r *http.Request) (int64, error) {
|
||||||
|
// Try to get size from Upload-Length header (TUS protocol)
|
||||||
|
if uploadLength := r.Header.Get("Upload-Length"); uploadLength != "" {
|
||||||
|
size, err := strconv.ParseInt(uploadLength, 10, 64)
|
||||||
|
if err == nil && size > 0 {
|
||||||
|
return size, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get size from Content-Length header
|
||||||
|
if r.ContentLength > 0 {
|
||||||
|
return r.ContentLength, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither header is available, return 0
|
||||||
|
// This might happen with chunked transfer encoding
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
@ -99,7 +99,7 @@ func resourceDeleteHandler(fileCache FileCache) handleFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resourcePostHandler(fileCache FileCache) handleFunc {
|
func resourcePostHandler(fileCache FileCache) handleFunc {
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
return withUser(withQuotaCheck(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
@ -150,10 +150,10 @@ func resourcePostHandler(fileCache FileCache) handleFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
var resourcePutHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
var resourcePutHandler = withUser(withQuotaCheck(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
if !d.user.Perm.Modify || !d.Check(r.URL.Path) {
|
if !d.user.Perm.Modify || !d.Check(r.URL.Path) {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
@ -183,7 +183,7 @@ var resourcePutHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
|
|||||||
}, "save", r.URL.Path, "", d.user)
|
}, "save", r.URL.Path, "", d.user)
|
||||||
|
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
})
|
}))
|
||||||
|
|
||||||
func resourcePatchHandler(fileCache FileCache) handleFunc {
|
func resourcePatchHandler(fileCache FileCache) handleFunc {
|
||||||
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
|
|||||||
@ -76,7 +76,7 @@ func keepUploadActive(filePath string) func() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func tusPostHandler() handleFunc {
|
func tusPostHandler() handleFunc {
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
return withUser(withQuotaCheck(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
@ -155,7 +155,7 @@ func tusPostHandler() handleFunc {
|
|||||||
|
|
||||||
w.Header().Set("Location", path)
|
w.Header().Set("Location", path)
|
||||||
return http.StatusCreated, nil
|
return http.StatusCreated, nil
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func tusHeadHandler() handleFunc {
|
func tusHeadHandler() handleFunc {
|
||||||
@ -190,7 +190,7 @@ func tusHeadHandler() handleFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func tusPatchHandler() handleFunc {
|
func tusPatchHandler() handleFunc {
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
return withUser(withQuotaCheck(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
@ -250,13 +250,28 @@ func tusPatchHandler() handleFunc {
|
|||||||
return http.StatusInternalServerError, fmt.Errorf("could not seek file: %w", err)
|
return http.StatusInternalServerError, fmt.Errorf("could not seek file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate maximum bytes we should accept to prevent quota bypass
|
||||||
|
maxBytesToWrite := uploadLength - uploadOffset
|
||||||
|
if maxBytesToWrite <= 0 {
|
||||||
|
return http.StatusBadRequest, fmt.Errorf("upload already complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use LimitReader to enforce the declared upload length
|
||||||
|
// This prevents clients from bypassing quota by falsifying Upload-Length header
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
bytesWritten, err := io.Copy(openFile, r.Body)
|
limitedReader := io.LimitReader(r.Body, maxBytesToWrite)
|
||||||
|
bytesWritten, err := io.Copy(openFile, limitedReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, fmt.Errorf("could not write to file: %w", err)
|
return http.StatusInternalServerError, fmt.Errorf("could not write to file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newOffset := uploadOffset + bytesWritten
|
newOffset := uploadOffset + bytesWritten
|
||||||
|
|
||||||
|
// Verify we haven't exceeded the declared upload length (defense in depth)
|
||||||
|
if newOffset > uploadLength {
|
||||||
|
return http.StatusBadRequest, fmt.Errorf("upload exceeded declared length")
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Upload-Offset", strconv.FormatInt(newOffset, 10))
|
w.Header().Set("Upload-Offset", strconv.FormatInt(newOffset, 10))
|
||||||
|
|
||||||
if newOffset >= uploadLength {
|
if newOffset >= uploadLength {
|
||||||
@ -265,7 +280,7 @@ func tusPatchHandler() handleFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return http.StatusNoContent, nil
|
return http.StatusNoContent, nil
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func tusDeleteHandler() handleFunc {
|
func tusDeleteHandler() handleFunc {
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
|
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules", "QuotaLimit", "QuotaUnit", "EnforceQuota"}
|
||||||
)
|
)
|
||||||
|
|
||||||
type modifyUserRequest struct {
|
type modifyUserRequest struct {
|
||||||
@ -208,3 +208,21 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
return http.StatusOK, nil
|
return http.StatusOK, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var userQuotaHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
|
u, err := d.store.Users.Get(d.server.Root, d.raw.(uint))
|
||||||
|
if errors.Is(err, fberrors.ErrNotExist) {
|
||||||
|
return http.StatusNotFound, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
quotaInfo, err := users.GetQuotaInfo(u)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderJSON(w, r, quotaInfo)
|
||||||
|
})
|
||||||
|
|||||||
@ -18,6 +18,9 @@ type UserDefaults struct {
|
|||||||
HideDotfiles bool `json:"hideDotfiles"`
|
HideDotfiles bool `json:"hideDotfiles"`
|
||||||
DateFormat bool `json:"dateFormat"`
|
DateFormat bool `json:"dateFormat"`
|
||||||
AceEditorTheme string `json:"aceEditorTheme"`
|
AceEditorTheme string `json:"aceEditorTheme"`
|
||||||
|
QuotaLimit uint64 `json:"quotaLimit"`
|
||||||
|
QuotaUnit string `json:"quotaUnit"`
|
||||||
|
EnforceQuota bool `json:"enforceQuota"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply applies the default options to a user.
|
// Apply applies the default options to a user.
|
||||||
@ -32,4 +35,7 @@ func (d *UserDefaults) Apply(u *users.User) {
|
|||||||
u.HideDotfiles = d.HideDotfiles
|
u.HideDotfiles = d.HideDotfiles
|
||||||
u.DateFormat = d.DateFormat
|
u.DateFormat = d.DateFormat
|
||||||
u.AceEditorTheme = d.AceEditorTheme
|
u.AceEditorTheme = d.AceEditorTheme
|
||||||
|
u.QuotaLimit = d.QuotaLimit
|
||||||
|
u.QuotaUnit = d.QuotaUnit
|
||||||
|
u.EnforceQuota = d.EnforceQuota
|
||||||
}
|
}
|
||||||
|
|||||||
161
users/quota.go
Normal file
161
users/quota.go
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
|
||||||
|
fberrors "github.com/filebrowser/filebrowser/v2/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// KB represents kilobyte in bytes
|
||||||
|
KB = 1024
|
||||||
|
// MB represents megabyte in bytes
|
||||||
|
MB = 1024 * KB
|
||||||
|
// GB represents gigabyte in bytes
|
||||||
|
GB = 1024 * MB
|
||||||
|
// TB represents terabyte in bytes
|
||||||
|
TB = 1024 * GB
|
||||||
|
// QuotaCalculationTimeout is the maximum time allowed for quota calculation
|
||||||
|
QuotaCalculationTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalculateUserQuota calculates the total disk usage for a user's scope.
|
||||||
|
// It walks the entire directory tree and sums all file sizes.
|
||||||
|
func CalculateUserQuota(fs afero.Fs, scope string) (uint64, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), QuotaCalculationTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var totalSize uint64
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := afero.Walk(fs, "/", func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
// Skip files/directories that can't be accessed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories, only count files
|
||||||
|
if !info.IsDir() {
|
||||||
|
totalSize += uint64(info.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return 0, fmt.Errorf("quota calculation timed out after %v", QuotaCalculationTimeout)
|
||||||
|
case err := <-done:
|
||||||
|
return totalSize, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertToBytes converts a value with a unit (KB, MB, GB or TB) to bytes.
|
||||||
|
func ConvertToBytes(value float64, unit string) (uint64, error) {
|
||||||
|
switch unit {
|
||||||
|
case "KB":
|
||||||
|
return uint64(value * float64(KB)), nil
|
||||||
|
case "MB":
|
||||||
|
return uint64(value * float64(MB)), nil
|
||||||
|
case "GB":
|
||||||
|
return uint64(value * float64(GB)), nil
|
||||||
|
case "TB":
|
||||||
|
return uint64(value * float64(TB)), nil
|
||||||
|
default:
|
||||||
|
return 0, fberrors.ErrInvalidQuotaUnit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertFromBytes converts bytes to a value in the specified unit (KB, MB, GB or TB).
|
||||||
|
func ConvertFromBytes(bytes uint64, unit string) (float64, error) {
|
||||||
|
switch unit {
|
||||||
|
case "KB":
|
||||||
|
return float64(bytes) / float64(KB), nil
|
||||||
|
case "MB":
|
||||||
|
return float64(bytes) / float64(MB), nil
|
||||||
|
case "GB":
|
||||||
|
return float64(bytes) / float64(GB), nil
|
||||||
|
case "TB":
|
||||||
|
return float64(bytes) / float64(TB), nil
|
||||||
|
default:
|
||||||
|
return 0, fberrors.ErrInvalidQuotaUnit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatQuotaDisplay formats bytes for display, automatically selecting the appropriate unit.
|
||||||
|
func FormatQuotaDisplay(bytes uint64) string {
|
||||||
|
if bytes >= TB {
|
||||||
|
return fmt.Sprintf("%.2f TB", float64(bytes)/float64(TB))
|
||||||
|
}
|
||||||
|
if bytes >= GB {
|
||||||
|
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
|
||||||
|
}
|
||||||
|
if bytes >= 1024*1024 {
|
||||||
|
return fmt.Sprintf("%.2f MB", float64(bytes)/(1024*1024))
|
||||||
|
}
|
||||||
|
if bytes >= 1024 {
|
||||||
|
return fmt.Sprintf("%.2f KB", float64(bytes)/1024)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckQuotaExceeded checks if a file operation would exceed the user's quota.
|
||||||
|
// Returns true if the quota would be exceeded, false otherwise.
|
||||||
|
func CheckQuotaExceeded(user *User, additionalSize int64) (bool, uint64, error) {
|
||||||
|
// If quota is not set (0) or not enforced, never exceeded
|
||||||
|
if user.QuotaLimit == 0 {
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate current usage
|
||||||
|
currentUsage, err := CalculateUserQuota(user.Fs, user.Scope)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, fmt.Errorf("failed to calculate quota: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if adding the new file would exceed the limit
|
||||||
|
newTotal := currentUsage + uint64(additionalSize)
|
||||||
|
exceeded := newTotal > user.QuotaLimit
|
||||||
|
|
||||||
|
return exceeded, currentUsage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuotaInfo returns detailed quota information for a user.
|
||||||
|
type QuotaInfo struct {
|
||||||
|
Limit uint64 `json:"limit"` // Quota limit in bytes
|
||||||
|
Used uint64 `json:"used"` // Current usage in bytes
|
||||||
|
Unit string `json:"unit"` // Display unit (GB or TB)
|
||||||
|
Enforce bool `json:"enforce"` // Whether quota is enforced
|
||||||
|
Percentage float64 `json:"percentage"` // Usage percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuotaInfo retrieves quota information for a user.
|
||||||
|
func GetQuotaInfo(user *User) (*QuotaInfo, error) {
|
||||||
|
// Calculate current usage
|
||||||
|
used, err := CalculateUserQuota(user.Fs, user.Scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to calculate quota usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate percentage
|
||||||
|
var percentage float64
|
||||||
|
if user.QuotaLimit > 0 {
|
||||||
|
percentage = (float64(used) / float64(user.QuotaLimit)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return &QuotaInfo{
|
||||||
|
Limit: user.QuotaLimit,
|
||||||
|
Used: used,
|
||||||
|
Unit: user.QuotaUnit,
|
||||||
|
Enforce: user.EnforceQuota,
|
||||||
|
Percentage: percentage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@ -36,6 +36,10 @@ type User struct {
|
|||||||
HideDotfiles bool `json:"hideDotfiles"`
|
HideDotfiles bool `json:"hideDotfiles"`
|
||||||
DateFormat bool `json:"dateFormat"`
|
DateFormat bool `json:"dateFormat"`
|
||||||
AceEditorTheme string `json:"aceEditorTheme"`
|
AceEditorTheme string `json:"aceEditorTheme"`
|
||||||
|
QuotaLimit uint64 `json:"quotaLimit"` // Quota limit in bytes (0 = unlimited)
|
||||||
|
QuotaUnit string `json:"quotaUnit"` // "KB", "MB", "GB" or "TB" for UI display
|
||||||
|
EnforceQuota bool `json:"enforceQuota"` // Hard limit if true, soft limit if false
|
||||||
|
QuotaUsed uint64 `json:"quotaUsed"` // Current usage in bytes (calculated, not stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRules implements rules.Provider.
|
// GetRules implements rules.Provider.
|
||||||
@ -51,6 +55,9 @@ var checkableFields = []string{
|
|||||||
"Commands",
|
"Commands",
|
||||||
"Sorting",
|
"Sorting",
|
||||||
"Rules",
|
"Rules",
|
||||||
|
"QuotaLimit",
|
||||||
|
"QuotaUnit",
|
||||||
|
"EnforceQuota",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean cleans up a user and verifies if all its fields
|
// Clean cleans up a user and verifies if all its fields
|
||||||
@ -86,6 +93,18 @@ func (u *User) Clean(baseScope string, fields ...string) error {
|
|||||||
if u.Rules == nil {
|
if u.Rules == nil {
|
||||||
u.Rules = []rules.Rule{}
|
u.Rules = []rules.Rule{}
|
||||||
}
|
}
|
||||||
|
case "QuotaUnit":
|
||||||
|
if u.QuotaUnit == "" {
|
||||||
|
u.QuotaUnit = "GB"
|
||||||
|
}
|
||||||
|
if u.QuotaUnit != "KB" && u.QuotaUnit != "MB" && u.QuotaUnit != "GB" && u.QuotaUnit != "TB" {
|
||||||
|
return fberrors.ErrInvalidQuotaUnit
|
||||||
|
}
|
||||||
|
case "QuotaLimit":
|
||||||
|
// QuotaLimit of 0 means unlimited, which is valid
|
||||||
|
// No validation needed
|
||||||
|
case "EnforceQuota":
|
||||||
|
// Boolean field, no validation needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user