Add user quota management system
Introduces comprehensive quota management with real-time usage tracking, frontend components, backend API endpoints, and TUS upload integration for storage limit enforcement.
This commit is contained in:
parent
943e5340d0
commit
0a3d1642f7
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("hideDotfiles", false, "hide dotfiles")
|
||||
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) {
|
||||
@ -136,6 +139,20 @@ func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all
|
||||
defaults.Sorting.Asc, err = flags.GetBool(flag.Name)
|
||||
case "hideDotfiles":
|
||||
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 {
|
||||
|
||||
@ -52,13 +52,16 @@ options you want to change.`,
|
||||
}
|
||||
|
||||
defaults := settings.UserDefaults{
|
||||
Scope: user.Scope,
|
||||
Locale: user.Locale,
|
||||
ViewMode: user.ViewMode,
|
||||
SingleClick: user.SingleClick,
|
||||
Perm: user.Perm,
|
||||
Sorting: user.Sorting,
|
||||
Commands: user.Commands,
|
||||
Scope: user.Scope,
|
||||
Locale: user.Locale,
|
||||
ViewMode: user.ViewMode,
|
||||
SingleClick: user.SingleClick,
|
||||
Perm: user.Perm,
|
||||
Sorting: user.Sorting,
|
||||
Commands: user.Commands,
|
||||
QuotaLimit: user.QuotaLimit,
|
||||
QuotaUnit: user.QuotaUnit,
|
||||
EnforceQuota: user.EnforceQuota,
|
||||
}
|
||||
|
||||
err = getUserDefaults(flags, &defaults, false)
|
||||
@ -73,6 +76,9 @@ options you want to change.`,
|
||||
user.Perm = defaults.Perm
|
||||
user.Commands = defaults.Commands
|
||||
user.Sorting = defaults.Sorting
|
||||
user.QuotaLimit = defaults.QuotaLimit
|
||||
user.QuotaUnit = defaults.QuotaUnit
|
||||
user.EnforceQuota = defaults.EnforceQuota
|
||||
user.LockPassword, err = flags.GetBool("lockPassword")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@ -22,6 +22,8 @@ var (
|
||||
ErrInvalidRequestParams = errors.New("invalid request params")
|
||||
ErrSourceIsParent = errors.New("source is parent")
|
||||
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 {
|
||||
|
||||
@ -47,6 +47,11 @@ export async function upload(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do not retry for quota exceeded (413 Request Entity Too Large)
|
||||
if (status === 413) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
onError: function (error: Error | tus.DetailedError) {
|
||||
@ -56,12 +61,29 @@ export async function upload(
|
||||
return reject(error);
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof tus.DetailedError
|
||||
? error.originalResponse === null
|
||||
? "000 No connection"
|
||||
: error.originalResponse.getBody()
|
||||
: "Upload failed";
|
||||
let message = "Upload failed";
|
||||
|
||||
if (error instanceof tus.DetailedError) {
|
||||
if (error.originalResponse === null) {
|
||||
message = "000 No connection";
|
||||
} 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);
|
||||
|
||||
|
||||
@ -41,3 +41,7 @@ export async function remove(id: number) {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getQuota(id: number) {
|
||||
return fetchJSON<IQuotaInfo>(`/api/users/${id}/quota`, {});
|
||||
}
|
||||
|
||||
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" />
|
||||
<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">
|
||||
<h3>{{ t("settings.rules") }}</h3>
|
||||
<p class="small">{{ t("settings.rulesHelp") }}</p>
|
||||
@ -71,11 +78,15 @@ import Languages from "./Languages.vue";
|
||||
import Rules from "./Rules.vue";
|
||||
import Permissions from "./Permissions.vue";
|
||||
import Commands from "./Commands.vue";
|
||||
import QuotaInput from "./QuotaInput.vue";
|
||||
import { enableExec } from "@/utils/constants";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
const isLoggedInUserAdmin = computed(() => authStore.user?.perm.admin);
|
||||
|
||||
const createUserDirData = ref<boolean | null>(null);
|
||||
const originalUserScope = ref<string | null>(null);
|
||||
|
||||
@ -61,7 +61,8 @@
|
||||
"forbidden": "You don't have permissions to access this.",
|
||||
"internal": "Something really went wrong.",
|
||||
"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": {
|
||||
"body": "Body",
|
||||
@ -258,7 +259,14 @@
|
||||
"userManagement": "User Management",
|
||||
"userUpdated": "User updated!",
|
||||
"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": {
|
||||
"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;
|
||||
sorting?: Sorting;
|
||||
aceEditorTheme: string;
|
||||
quotaLimit: number;
|
||||
quotaUnit: string;
|
||||
enforceQuota: boolean;
|
||||
quotaUsed: number;
|
||||
}
|
||||
|
||||
type ViewModeType = "list" | "mosaic" | "mosaic gallery";
|
||||
@ -31,6 +35,9 @@ interface IUserForm {
|
||||
hideDotfiles?: boolean;
|
||||
singleClick?: boolean;
|
||||
dateFormat?: boolean;
|
||||
quotaLimit?: number;
|
||||
quotaUnit?: string;
|
||||
enforceQuota?: boolean;
|
||||
}
|
||||
|
||||
interface Permissions {
|
||||
@ -64,4 +71,12 @@ interface IRegexp {
|
||||
raw: string;
|
||||
}
|
||||
|
||||
interface IQuotaInfo {
|
||||
limit: number;
|
||||
used: number;
|
||||
unit: string;
|
||||
enforce: boolean;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
type UserTheme = "light" | "dark" | "";
|
||||
|
||||
@ -99,6 +99,10 @@ const fetchData = async () => {
|
||||
rules: [],
|
||||
lockPassword: false,
|
||||
id: 0,
|
||||
quotaLimit: 0,
|
||||
quotaUnit: "GB",
|
||||
enforceQuota: false,
|
||||
quotaUsed: 0,
|
||||
};
|
||||
} else {
|
||||
const id = Array.isArray(route.params.id)
|
||||
|
||||
@ -59,6 +59,7 @@ func NewHandler(
|
||||
users.Handle("/{id:[0-9]+}", monkey(userPutHandler, "")).Methods("PUT")
|
||||
users.Handle("/{id:[0-9]+}", monkey(userGetHandler, "")).Methods("GET")
|
||||
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(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 {
|
||||
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) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
@ -150,10 +150,10 @@ func resourcePostHandler(fileCache FileCache) handleFunc {
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
|
||||
return errToStatus(err), err
|
||||
})
|
||||
}))
|
||||
|
||||
func resourcePatchHandler(fileCache FileCache) handleFunc {
|
||||
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
|
||||
@ -76,7 +76,7 @@ func keepUploadActive(filePath string) func() {
|
||||
}
|
||||
|
||||
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) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
@ -155,7 +155,7 @@ func tusPostHandler() handleFunc {
|
||||
|
||||
w.Header().Set("Location", path)
|
||||
return http.StatusCreated, nil
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func tusHeadHandler() handleFunc {
|
||||
@ -190,7 +190,7 @@ func tusHeadHandler() 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) {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
@ -265,7 +265,7 @@ func tusPatchHandler() handleFunc {
|
||||
}
|
||||
|
||||
return http.StatusNoContent, nil
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func tusDeleteHandler() handleFunc {
|
||||
|
||||
@ -17,7 +17,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
|
||||
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules", "QuotaLimit", "QuotaUnit", "EnforceQuota"}
|
||||
)
|
||||
|
||||
type modifyUserRequest struct {
|
||||
@ -208,3 +208,21 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
|
||||
|
||||
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"`
|
||||
DateFormat bool `json:"dateFormat"`
|
||||
AceEditorTheme string `json:"aceEditorTheme"`
|
||||
QuotaLimit uint64 `json:"quotaLimit"`
|
||||
QuotaUnit string `json:"quotaUnit"`
|
||||
EnforceQuota bool `json:"enforceQuota"`
|
||||
}
|
||||
|
||||
// Apply applies the default options to a user.
|
||||
@ -32,4 +35,7 @@ func (d *UserDefaults) Apply(u *users.User) {
|
||||
u.HideDotfiles = d.HideDotfiles
|
||||
u.DateFormat = d.DateFormat
|
||||
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"`
|
||||
DateFormat bool `json:"dateFormat"`
|
||||
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.
|
||||
@ -51,6 +55,9 @@ var checkableFields = []string{
|
||||
"Commands",
|
||||
"Sorting",
|
||||
"Rules",
|
||||
"QuotaLimit",
|
||||
"QuotaUnit",
|
||||
"EnforceQuota",
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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