This commit is contained in:
Jonathan D Kelley 2026-01-03 02:27:30 -05:00 committed by GitHub
commit 79f130c0f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 839 additions and 27 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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 {

View File

@ -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);

View File

@ -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`, {});
}

View File

@ -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 + "/";

View 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>

View 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>

View File

@ -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);

View File

@ -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",

View File

@ -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" | "";

View File

@ -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)

View File

@ -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()),

View File

@ -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
View 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
}

View File

@ -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) {

View File

@ -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 {

View File

@ -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)
})

View File

@ -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
View 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
}

View File

@ -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
} }
} }