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("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 {

View File

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

View File

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

View File

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

View File

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

View File

@ -90,9 +90,9 @@
v-if="isFiles && !disableUsedPercentage"
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 />
{{ usage.used }} of {{ usage.total }} used
{{ usedFormatted }} of {{ totalFormatted }} used
</div>
<p class="credits">
@ -161,6 +161,37 @@ export default {
disableExternal: () => disableExternal,
disableUsedPercentage: () => disableUsedPercentage,
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: {
...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
@ -168,6 +199,11 @@ export default {
this.usageAbortController.abort();
},
async fetchUsage() {
// If user has quota, don't fetch disk usage
if (this.hasQuotaData) {
return;
}
const path = this.$route.path.endsWith("/")
? 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" />
<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);

View File

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

View File

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

View File

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

View File

@ -34,6 +34,10 @@ type userInfo struct {
DateFormat bool `json:"dateFormat"`
Username string `json:"username"`
AceEditorTheme string `json:"aceEditorTheme"`
QuotaLimit uint64 `json:"quotaLimit"`
QuotaUnit string `json:"quotaUnit"`
EnforceQuota bool `json:"enforceQuota"`
QuotaUsed uint64 `json:"quotaUsed"`
}
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) {
// 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{
User: userInfo{
ID: user.ID,
@ -215,6 +232,10 @@ func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.Use
DateFormat: user.DateFormat,
Username: user.Username,
AceEditorTheme: user.AceEditorTheme,
QuotaLimit: user.QuotaLimit,
QuotaUnit: user.QuotaUnit,
EnforceQuota: user.EnforceQuota,
QuotaUsed: quotaUsed,
},
RegisteredClaims: jwt.RegisteredClaims{
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(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
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 {
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) {

View File

@ -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
}
@ -250,13 +250,28 @@ func tusPatchHandler() handleFunc {
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()
bytesWritten, err := io.Copy(openFile, r.Body)
limitedReader := io.LimitReader(r.Body, maxBytesToWrite)
bytesWritten, err := io.Copy(openFile, limitedReader)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not write to file: %w", err)
}
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))
if newOffset >= uploadLength {
@ -265,7 +280,7 @@ func tusPatchHandler() handleFunc {
}
return http.StatusNoContent, nil
})
}))
}
func tusDeleteHandler() handleFunc {

View File

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

View File

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