Compare commits

..

No commits in common. "master" and "v2.42.1" have entirely different histories.

21 changed files with 458 additions and 433 deletions

View File

@ -2,39 +2,6 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [2.42.5](https://github.com/filebrowser/filebrowser/compare/v2.42.4...v2.42.5) (2025-08-16)
### Bug Fixes
* "new folder" button not working in the move and copy popup ([#5368](https://github.com/filebrowser/filebrowser/issues/5368)) ([3107ae4](https://github.com/filebrowser/filebrowser/commit/3107ae41475ae9383c3af414d25a133e549f8087))
### [2.42.4](https://github.com/filebrowser/filebrowser/compare/v2.42.3...v2.42.4) (2025-08-16)
### Bug Fixes
* add libcap to Dockerfile.s6 ([342b239](https://github.com/filebrowser/filebrowser/commit/342b239ac6f4af2453d5f7aa27f7f0093024dd72))
### [2.42.3](https://github.com/filebrowser/filebrowser/compare/v2.42.2...v2.42.3) (2025-08-09)
### Bug Fixes
* add missing CLI flags for user management ([#5351](https://github.com/filebrowser/filebrowser/issues/5351)) ([cd51a59](https://github.com/filebrowser/filebrowser/commit/cd51a59e72c72560fce7bcc9b12aaf02646b699c))
### [2.42.2](https://github.com/filebrowser/filebrowser/compare/v2.42.1...v2.42.2) (2025-08-06)
### Bug Fixes
* show file upload errors ([06e8713](https://github.com/filebrowser/filebrowser/commit/06e8713fa55065d38f02499d3e8d39fc86926cab))
### Refactorings
* upload progress calculation ([#5350](https://github.com/filebrowser/filebrowser/issues/5350)) ([c14cf86](https://github.com/filebrowser/filebrowser/commit/c14cf86f8304e01d804e01a7eef5ea093627ef37))
### [2.42.1](https://github.com/filebrowser/filebrowser/compare/v2.42.0...v2.42.1) (2025-07-31)

View File

@ -1,7 +1,7 @@
FROM ghcr.io/linuxserver/baseimage-alpine:3.22
RUN apk update && \
apk --no-cache add ca-certificates mailcap jq libcap
apk --no-cache add ca-certificates mailcap jq
# Make user and create necessary directories
RUN mkdir -p /config /database /srv && \
@ -12,8 +12,7 @@ COPY filebrowser /bin/filebrowser
COPY docker/common/ /
COPY docker/s6/ /
RUN chown -R abc:abc /bin/filebrowser /defaults healthcheck.sh && \
setcap 'cap_net_bind_service=+ep' /bin/filebrowser
RUN chown -R abc:abc /bin/filebrowser /defaults healthcheck.sh
# Define healthcheck script
HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh

View File

@ -77,8 +77,6 @@ func addUserFlags(flags *pflag.FlagSet) {
flags.String("locale", "en", "locale for users")
flags.String("viewMode", string(users.ListViewMode), "view mode for users")
flags.Bool("singleClick", false, "use single clicks only")
flags.Bool("dateFormat", false, "use date format (true for absolute time, false for relative)")
flags.Bool("hideDotfiles", false, "hide dotfiles")
}
func getViewMode(flags *pflag.FlagSet) (users.ViewMode, error) {

View File

@ -36,22 +36,10 @@ var usersAddCmd = &cobra.Command{
return err
}
dateFormat, err := getBool(cmd.Flags(), "dateFormat")
if err != nil {
return err
}
hideDotfiles, err := getBool(cmd.Flags(), "hideDotfiles")
if err != nil {
return err
}
user := &users.User{
Username: args[0],
Password: password,
LockPassword: lockPassword,
DateFormat: dateFormat,
HideDotfiles: hideDotfiles,
}
s.Defaults.Apply(user)

View File

@ -76,14 +76,6 @@ options you want to change.`,
if err != nil {
return err
}
user.DateFormat, err = getBool(flags, "dateFormat")
if err != nil {
return err
}
user.HideDotfiles, err = getBool(flags, "hideDotfiles")
if err != nil {
return err
}
if newUsername != "" {
user.Username = newUsername

View File

@ -13,7 +13,7 @@ export default async function search(base: string, query: string) {
let data = await res.json();
data = data.map((item: ResourceItem & { dir: boolean }) => {
data = data.map((item: UploadItem) => {
item.url = `/files${base}` + url.encodePath(item.path);
if (item.dir) {

View File

@ -1,11 +1,17 @@
import * as tus from "tus-js-client";
import { baseURL, tusEndpoint, tusSettings, origin } from "@/utils/constants";
import { useAuthStore } from "@/stores/auth";
import { useUploadStore } from "@/stores/upload";
import { removePrefix } from "@/api/utils";
const RETRY_BASE_DELAY = 1000;
const RETRY_MAX_DELAY = 20000;
const CURRENT_UPLOAD_LIST: { [key: string]: tus.Upload } = {};
const SPEED_UPDATE_INTERVAL = 1000;
const ALPHA = 0.2;
const ONE_MINUS_ALPHA = 1 - ALPHA;
const RECENT_SPEEDS_LIMIT = 5;
const MB_DIVISOR = 1024 * 1024;
const CURRENT_UPLOAD_LIST: CurrentUploadList = {};
export async function upload(
filePath: string,
@ -49,35 +55,48 @@ export async function upload(
return true;
},
onError: function (error: Error | tus.DetailedError) {
delete CURRENT_UPLOAD_LIST[filePath];
if (error.message === "Upload aborted") {
return reject(error);
onError: function (error) {
if (CURRENT_UPLOAD_LIST[filePath].interval) {
clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
}
const message =
error instanceof tus.DetailedError
? error.originalResponse === null
? "000 No connection"
: error.originalResponse.getBody()
: "Upload failed";
console.error(error);
reject(new Error(message));
delete CURRENT_UPLOAD_LIST[filePath];
reject(new Error(`Upload failed: ${error.message}`));
},
onProgress: function (bytesUploaded) {
const fileData = CURRENT_UPLOAD_LIST[filePath];
fileData.currentBytesUploaded = bytesUploaded;
if (!fileData.hasStarted) {
fileData.hasStarted = true;
fileData.lastProgressTimestamp = Date.now();
fileData.interval = window.setInterval(() => {
calcProgress(filePath);
}, SPEED_UPDATE_INTERVAL);
}
if (typeof onupload === "function") {
onupload({ loaded: bytesUploaded });
}
},
onSuccess: function () {
if (CURRENT_UPLOAD_LIST[filePath].interval) {
clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
}
delete CURRENT_UPLOAD_LIST[filePath];
resolve();
},
});
CURRENT_UPLOAD_LIST[filePath] = upload;
CURRENT_UPLOAD_LIST[filePath] = {
upload: upload,
recentSpeeds: [],
initialBytesUploaded: 0,
currentBytesUploaded: 0,
currentAverageSpeed: 0,
lastProgressTimestamp: null,
sumOfRecentSpeeds: 0,
hasStarted: false,
interval: undefined,
};
upload.start();
});
}
@ -109,11 +128,76 @@ function isTusSupported() {
return tus.isSupported === true;
}
function computeETA(speed?: number) {
const state = useUploadStore();
if (state.speedMbyte === 0) {
return Infinity;
}
const totalSize = state.sizes.reduce(
(acc: number, size: number) => acc + size,
0
);
const uploadedSize = state.progress.reduce((a, b) => a + b, 0);
const remainingSize = totalSize - uploadedSize;
const speedBytesPerSecond = (speed ?? state.speedMbyte) * 1024 * 1024;
return remainingSize / speedBytesPerSecond;
}
function computeGlobalSpeedAndETA() {
let totalSpeed = 0;
let totalCount = 0;
for (const filePath in CURRENT_UPLOAD_LIST) {
totalSpeed += CURRENT_UPLOAD_LIST[filePath].currentAverageSpeed;
totalCount++;
}
if (totalCount === 0) return { speed: 0, eta: Infinity };
const averageSpeed = totalSpeed / totalCount;
const averageETA = computeETA(averageSpeed);
return { speed: averageSpeed, eta: averageETA };
}
function calcProgress(filePath: string) {
const uploadStore = useUploadStore();
const fileData = CURRENT_UPLOAD_LIST[filePath];
const elapsedTime =
(Date.now() - (fileData.lastProgressTimestamp ?? 0)) / 1000;
const bytesSinceLastUpdate =
fileData.currentBytesUploaded - fileData.initialBytesUploaded;
const currentSpeed = bytesSinceLastUpdate / MB_DIVISOR / elapsedTime;
if (fileData.recentSpeeds.length >= RECENT_SPEEDS_LIMIT) {
fileData.sumOfRecentSpeeds -= fileData.recentSpeeds.shift() ?? 0;
}
fileData.recentSpeeds.push(currentSpeed);
fileData.sumOfRecentSpeeds += currentSpeed;
const avgRecentSpeed =
fileData.sumOfRecentSpeeds / fileData.recentSpeeds.length;
fileData.currentAverageSpeed =
ALPHA * avgRecentSpeed + ONE_MINUS_ALPHA * fileData.currentAverageSpeed;
const { speed, eta } = computeGlobalSpeedAndETA();
uploadStore.setUploadSpeed(speed);
uploadStore.setETA(eta);
fileData.initialBytesUploaded = fileData.currentBytesUploaded;
fileData.lastProgressTimestamp = Date.now();
}
export function abortAllUploads() {
for (const filePath in CURRENT_UPLOAD_LIST) {
if (CURRENT_UPLOAD_LIST[filePath]) {
CURRENT_UPLOAD_LIST[filePath].abort(true);
CURRENT_UPLOAD_LIST[filePath].options!.onError!(
if (CURRENT_UPLOAD_LIST[filePath].interval) {
clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
}
if (CURRENT_UPLOAD_LIST[filePath].upload) {
CURRENT_UPLOAD_LIST[filePath].upload.abort(true);
CURRENT_UPLOAD_LIST[filePath].upload.options!.onError!(
new Error("Upload aborted")
);
}

View File

@ -132,6 +132,7 @@ import {
import { files as api } from "@/api";
import ProgressBar from "@/components/ProgressBar.vue";
import prettyBytes from "pretty-bytes";
import { StatusError } from "@/api/utils.js";
const USAGE_DEFAULT = { used: "0 B", total: "0 B", usedPercentage: 0 };
@ -180,9 +181,13 @@ export default {
total: prettyBytes(usage.total, { binary: true }),
usedPercentage: Math.round((usage.used / usage.total) * 100),
};
} finally {
return Object.assign(this.usage, usageStats);
} catch (error) {
if (error instanceof StatusError && error.is_canceled) {
return;
}
this.$showError(error);
}
return Object.assign(this.usage, usageStats);
},
toRoot() {
this.$router.push({ path: "/files" });

View File

@ -25,10 +25,9 @@
</template>
<script>
import { mapState, mapActions } from "pinia";
import { mapState } from "pinia";
import { useAuthStore } from "@/stores/auth";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import url from "@/utils/url";
import { files } from "@/api";
@ -69,7 +68,6 @@ export default {
this.abortOngoingNext();
},
methods: {
...mapActions(useLayoutStore, ["showHover"]),
abortOngoingNext() {
this.nextAbortController.abort();
},
@ -165,7 +163,7 @@ export default {
this.$emit("update:selected", this.selected);
},
createDir: async function () {
this.showHover({
this.$store.commit("showHover", {
prompt: "newDir",
action: null,
confirm: null,

View File

@ -1,25 +1,20 @@
<template>
<div
v-if="uploadStore.activeUploads.size > 0"
v-if="filesInUploadCount > 0"
class="upload-files"
v-bind:class="{ closed: !open }"
>
<div class="card floating">
<div class="card-title">
<h2>
{{
$t("prompts.uploadFiles", {
files: uploadStore.pendingUploadCount,
})
}}
</h2>
<h2>{{ $t("prompts.uploadFiles", { files: filesInUploadCount }) }}</h2>
<div class="upload-info">
<div class="upload-speed">{{ speedMbytes }}/s</div>
<div class="upload-speed">{{ uploadSpeed.toFixed(2) }} MB/s</div>
<div class="upload-eta">{{ formattedETA }} remaining</div>
<div class="upload-percentage">{{ sentPercent }}% Completed</div>
<div class="upload-percentage">
{{ getProgressDecimal }}% Completed
</div>
<div class="upload-fraction">
{{ sentMbytes }} /
{{ totalMbytes }}
{{ getTotalProgressBytes }} / {{ getTotalSize }}
</div>
</div>
<button
@ -45,21 +40,17 @@
<div class="card-content file-icons">
<div
class="file"
v-for="upload in uploadStore.activeUploads"
:key="upload.path"
:data-dir="upload.type === 'dir'"
:data-type="upload.type"
:aria-label="upload.name"
v-for="file in filesInUpload"
:key="file.id"
:data-dir="file.isDir"
:data-type="file.type"
:aria-label="file.name"
>
<div class="file-name">
<i class="material-icons"></i> {{ upload.name }}
<i class="material-icons"></i> {{ file.name }}
</div>
<div class="file-progress">
<div
v-bind:style="{
width: (upload.sentBytes / upload.totalBytes) * 100 + '%',
}"
></div>
<div v-bind:style="{ width: file.progress + '%' }"></div>
</div>
</div>
</div>
@ -67,126 +58,63 @@
</div>
</template>
<script setup lang="ts">
import { useFileStore } from "@/stores/file";
<script>
import { mapState, mapWritableState, mapActions } from "pinia";
import { useUploadStore } from "@/stores/upload";
import { storeToRefs } from "pinia";
import { computed, ref, watch } from "vue";
import { useFileStore } from "@/stores/file";
import { abortAllUploads } from "@/api/tus";
import buttons from "@/utils/buttons";
import { useI18n } from "vue-i18n";
import { partial } from "filesize";
const { t } = useI18n({});
export default {
name: "uploadFiles",
data: function () {
return {
open: false,
};
},
computed: {
...mapState(useUploadStore, [
"filesInUpload",
"filesInUploadCount",
"uploadSpeed",
"getETA",
"getProgress",
"getProgressDecimal",
"getTotalProgressBytes",
"getTotalSize",
]),
...mapWritableState(useFileStore, ["reload"]),
formattedETA() {
if (!this.getETA || this.getETA === Infinity) {
return "--:--:--";
}
const open = ref<boolean>(false);
const speed = ref<number>(0);
const eta = ref<number>(Infinity);
let totalSeconds = this.getETA;
const hours = Math.floor(totalSeconds / 3600);
totalSeconds %= 3600;
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.round(totalSeconds % 60);
const fileStore = useFileStore();
const uploadStore = useUploadStore();
const { sentBytes, totalBytes } = storeToRefs(uploadStore);
const byteToMbyte = partial({ exponent: 2 });
const sentPercent = computed(() =>
((uploadStore.sentBytes / uploadStore.totalBytes) * 100).toFixed(2)
);
const sentMbytes = computed(() => byteToMbyte(uploadStore.sentBytes));
const totalMbytes = computed(() => byteToMbyte(uploadStore.totalBytes));
const speedMbytes = computed(() => byteToMbyte(speed.value));
let lastSpeedUpdate: number = 0;
let recentSpeeds: number[] = [];
const calculateSpeed = (sentBytes: number, oldSentBytes: number) => {
// Reset the state when the uploads batch is complete
if (sentBytes === 0) {
lastSpeedUpdate = 0;
recentSpeeds = [];
eta.value = Infinity;
speed.value = 0;
return;
}
const elapsedTime = (Date.now() - (lastSpeedUpdate ?? 0)) / 1000;
const bytesSinceLastUpdate = sentBytes - oldSentBytes;
const currentSpeed = bytesSinceLastUpdate / elapsedTime;
recentSpeeds.push(currentSpeed);
if (recentSpeeds.length > 5) {
recentSpeeds.shift();
}
const recentSpeedsAverage =
recentSpeeds.reduce((acc, curr) => acc + curr) / recentSpeeds.length;
// Use the current speed for the first update to avoid smoothing lag
if (recentSpeeds.length === 1) {
speed.value = currentSpeed;
}
speed.value = recentSpeedsAverage * 0.2 + speed.value * 0.8;
lastSpeedUpdate = Date.now();
calculateEta();
};
const calculateEta = () => {
if (speed.value === 0) {
eta.value = Infinity;
return Infinity;
}
const remainingSize = uploadStore.totalBytes - uploadStore.sentBytes;
const speedBytesPerSecond = speed.value;
eta.value = remainingSize / speedBytesPerSecond;
};
watch(sentBytes, calculateSpeed);
watch(totalBytes, (totalBytes, oldTotalBytes) => {
if (oldTotalBytes !== 0) {
return;
}
// Mark the start time of a new upload batch
lastSpeedUpdate = Date.now();
});
const formattedETA = computed(() => {
if (!eta.value || eta.value === Infinity) {
return "--:--:--";
}
let totalSeconds = eta.value;
const hours = Math.floor(totalSeconds / 3600);
totalSeconds %= 3600;
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.round(totalSeconds % 60);
return `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
});
const toggle = () => {
open.value = !open.value;
};
const abortAll = () => {
if (confirm(t("upload.abortUpload"))) {
buttons.done("upload");
open.value = false;
uploadStore.abort();
fileStore.reload = true; // Trigger reload in the file store
}
return `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
},
},
methods: {
...mapActions(useUploadStore, ["reset"]), // Mapping reset action from upload store
toggle: function () {
this.open = !this.open;
},
abortAll() {
if (confirm(this.$t("upload.abortUpload"))) {
abortAllUploads();
buttons.done("upload");
this.open = false;
this.reset(); // Resetting the upload store state
this.reload = true; // Trigger reload in the file store
}
},
},
};
</script>

View File

@ -77,14 +77,14 @@
"noPreview": "L'aperçu n'est pas disponible pour ce fichier."
},
"help": {
"click": "Sélectionner un fichier ou dossier",
"click": "Sélectionner un élément",
"ctrl": {
"click": "Sélectionner plusieurs fichiers ou dossiers",
"click": "Sélectionner plusieurs éléments",
"f": "Ouvrir l'invité de recherche",
"s": "Enregistrer un fichier ou télécharger le dossier actuel"
"s": "Télécharger l'élément actuel"
},
"del": "Supprimer les éléments sélectionnés",
"doubleClick": "Ouvrir un fichier ou dossier",
"doubleClick": "Ouvrir un élément",
"esc": "Désélectionner et/ou fermer la boîte de dialogue",
"f1": "Ouvrir l'aide",
"f2": "Renommer le fichier",
@ -98,8 +98,8 @@
"passwordsDontMatch": "Les mots de passe ne concordent pas",
"signup": "S'inscrire",
"submit": "Se connecter",
"username": "Utilisateur·ice",
"usernameTaken": "Le nom d'utilisateur·ice est déjà pris",
"username": "Utilisateur",
"usernameTaken": "Le nom d'utilisateur est déjà pris",
"wrongCredentials": "Identifiants incorrects !"
},
"permanent": "Permanent",
@ -110,7 +110,7 @@
"deleteMessageMultiple": "Êtes-vous sûr de vouloir supprimer ces {count} élément(s) ?",
"deleteMessageSingle": "Êtes-vous sûr de vouloir supprimer cet élément ?",
"deleteMessageShare": "Êtes-vous sûr de vouloir supprimer ce partage ({path}) ?",
"deleteUser": "Êtes-vous sûr de vouloir supprimer cet·te utilisateur·ice ?",
"deleteUser": "Êtes-vous sûr de vouloir supprimer cet utilisateur ?",
"deleteTitle": "Supprimer",
"displayName": "Nom :",
"download": "Télécharger",
@ -120,7 +120,7 @@
"filesSelected": "{count} éléments sélectionnés",
"lastModified": "Dernière modification",
"move": "Déplacer",
"moveMessage": "Choisissez un nouveau dossier principal pour vos fichier(s)/dossier(s) :",
"moveMessage": "Choisissez l'emplacement où déplacer la sélection :",
"newArchetype": "Créer un nouveau post basé sur un archétype. Votre fichier sera créé dans le dossier de contenu.",
"newDir": "Nouveau dossier",
"newDirMessage": "Nom du nouveau dossier :",
@ -155,12 +155,12 @@
},
"settings": {
"admin": "Admin",
"administrator": "Administrateur·ice",
"administrator": "Administrateur",
"allowCommands": "Exécuter des commandes",
"allowEdit": "Éditer, renommer et supprimer des fichiers ou des dossiers",
"allowNew": "Créer de nouveaux fichiers et dossiers",
"allowPublish": "Publier de nouveaux posts et pages",
"allowSignup": "Autoriser les utilisateur·ices à s'inscrire",
"allowSignup": "Autoriser les utilisateurs à s'inscrire",
"avoidChanges": "(Laisser vide pour conserver l'actuel)",
"branding": "Image de marque",
"brandingDirectoryPath": "Chemin du dossier d'image de marque",
@ -169,17 +169,17 @@
"commandRunner": "Exécuteur de commandes",
"commandRunnerHelp": "Ici, vous pouvez définir les commandes qui seront exécutées lors des événements nommés précédemments. Vous devez en écrire une par ligne. Les variables d'environnement {0} et {1} seront disponibles, {0} étant relatif à {1}. Pour plus d'informations sur cette fonctionnalité et les variables d'environnement disponibles, veuillez lire la {2}.",
"commandsUpdated": "Commandes mises à jour !",
"createUserDir": "Créer automatiquement un dossier pour l'utilisateur·ice",
"minimumPasswordLength": "Taille minimale du mot de passe",
"createUserDir": "Créer automatiquement un dossier pour l'utilisateur",
"minimumPasswordLength": "Minimum password length",
"tusUploads": "Uploads segmentés",
"tusUploadsHelp": "File Browser prend en charge les uploads segmentés afin de permettre une gestion efficace, fiable et reprenable sur des réseaux instables.",
"tusUploadsChunkSize": "Taille maximale autorisée par segment (les uploads directs seront utilisés pour les fichiers plus petits). Vous pouvez entrer un entier en octets ou une chaîne telle que 10MB, 1GB, etc.",
"tusUploadsRetryCount": "Nombre de tentatives en cas d'échec d'un segment.",
"userHomeBasePath": "Chemin de base pour les dossiers personnels des utilisateur·ices",
"userHomeBasePath": "Chemin de base pour les répertoires personnels des utilisateurs",
"userScopeGenerationPlaceholder": "Le périmètre sera généré automatiquement",
"createUserHomeDirectory": "Créer le dossier personnel de l'utilisateur·ice",
"createUserHomeDirectory": "Créer le répertoire personnel de l'utilisateur",
"customStylesheet": "Feuille de style personnalisée",
"defaultUserDescription": "Paramètres par défaut pour les nouveaux utilisateur·ices.",
"defaultUserDescription": "Paramètres par défaut pour les nouveaux utilisateurs.",
"disableExternalLinks": "Désactiver les liens externes (sauf la documentation)",
"disableUsedDiskPercentage": "Désactiver le graphique de pourcentage d'utilisation du disque",
"documentation": "documentation",
@ -188,12 +188,12 @@
"executeOnShellDescription": "Par défaut, File Browser exécute les commandes en appelant directement leurs binaires. Si vous voulez les exécuter sur un shell à la place (comme Bash ou PowerShell), vous pouvez le définir ici avec les arguments et les drapeaux requis. S'il est défini, la commande que vous exécutez sera ajoutée en tant qu'argument. Cela s'applique à la fois aux commandes utilisateur et aux crochets d'événements.",
"globalRules": "Il s'agit d'un ensemble global de règles d'autorisation et d'interdiction. Elles s'appliquent à tous les utilisateurs. Vous pouvez définir des règles spécifiques sur les paramètres de chaque utilisateur pour remplacer celles-ci.",
"globalSettings": "Paramètres globaux",
"hideDotfiles": "Cacher les fichiers de configuration commançant par un point",
"hideDotfiles": "Cacher les fichiers de configuration utilisateur (dotfiles)",
"insertPath": "Insérer le chemin",
"insertRegex": "Insérer une expression régulière",
"instanceName": "Nom de l'instance",
"language": "Langue",
"lockPassword": "Empêcher l'utilisateur·ice de changer son mot de passe",
"lockPassword": "Empêcher l'utilisateur de changer son mot de passe",
"newPassword": "Votre nouveau mot de passe",
"newPasswordConfirm": "Confirmation du nouveau mot de passe",
"newUser": "Nouvel utilisateur",
@ -210,13 +210,13 @@
"share": "Partager des fichiers"
},
"permissions": "Permissions",
"permissionsHelp": "Vous pouvez définir l'utilisateur·ice comme étant administrateur·ice ou encore choisir les permissions individuellement. Si vous sélectionnez \"Administrateur·ice\", toutes les autres options seront automatiquement activées. La gestion des utilisateur·ices est un privilège que seul l'administrateur·ice possède.\n",
"permissionsHelp": "Vous pouvez définir l'utilisateur comme étant un administrateur ou encore choisir les permissions individuellement. Si vous sélectionnez \"Administrateur\", toutes les autres options seront automatiquement activées. La gestion des utilisateurs est un privilège que seul l'administrateur possède.\n",
"profileSettings": "Paramètres du profil",
"ruleExample1": "Bloque l'accès à tous les fichiers commençant par un point (comme par exemple .git, .gitignore) dans tous les dossiers.\n",
"ruleExample2": "Bloque l'accès au fichier nommé \"Caddyfile\" à la racine du dossier utilisateur·ice.",
"ruleExample1": "Bloque l'accès à tous les fichiers commençant par un point (comme par exemple .git, .gitignore) dans tous les dossiers",
"ruleExample2": "Bloque l'accès au fichier nommé \"Caddyfile\" à la racine du dossier utilisateur",
"rules": "Règles",
"rulesHelp": "Vous pouvez définir ici un ensemble de règles pour cet utilisateur·ice. Les fichiers bloqués ne seront pas affichés et ne seront pas accessibles par l'utilisateur·ice. Les expressions régulières sont supportées et les chemins d'accès sont relatifs par rapport au dossier de l'utilisateur·ice.\n",
"scope": "Portée du dossier utilisateur·ice",
"rulesHelp": "Vous pouvez définir ici un ensemble de règles pour cet utilisateur. Les fichiers bloqués ne seront pas affichés et ne seront pas accessibles par l'utilisateur. Les expressions régulières sont supportées et les chemins d'accès sont relatifs par rapport au dossier de l'utilisateur.\n",
"scope": "Portée du dossier utilisateur",
"setDateFormat": "Définir le format de la date",
"settingsUpdated": "Les paramètres ont été mis à jour !",
"shareDuration": "Durée du partage",
@ -224,21 +224,21 @@
"shareDeleted": "Partage supprimé !",
"singleClick": "Utiliser un simple clic pour ouvrir les fichiers et les dossiers",
"themes": {
"default": "Par défaut du système",
"default": "System default",
"dark": "Sombre",
"light": "Clair",
"title": "Thème"
},
"user": "Utilisateur·ice",
"user": "Utilisateur",
"userCommands": "Commandes",
"userCommandsHelp": "Une liste séparée par des espaces des commandes permises pour l'utilisateur·ice. Exemple :\n",
"userCreated": "Utilisateur·ice créé !",
"userDefaults": "Paramètres par défaut de l'utilisateur.ice",
"userDeleted": "Utilisateur·ice supprimé !",
"userManagement": "Gestion des utilisateur·ices",
"userUpdated": "Utilisateur·ice mis à jour !",
"username": "Nom d'utilisateur·ice",
"users": "Utilisateur·ices"
"userCommandsHelp": "Une liste séparée par des espaces des commandes permises pour l'utilisateur. Exemple :\n",
"userCreated": "Utilisateur créé !",
"userDefaults": "Paramètres par défaut de l'utilisateur",
"userDeleted": "Utilisateur supprimé !",
"userManagement": "Gestion des utilisateurs",
"userUpdated": "Utilisateur mis à jour !",
"username": "Nom d'utilisateur",
"users": "Utilisateurs"
},
"sidebar": {
"help": "Aide",

View File

@ -1,9 +1,8 @@
import { defineStore } from "pinia";
import { useFileStore } from "./file";
import { files as api } from "@/api";
import { throttle } from "lodash-es";
import buttons from "@/utils/buttons";
import { computed, inject, markRaw, ref } from "vue";
import * as tus from "@/api/tus";
// TODO: make this into a user setting
const UPLOADS_LIMIT = 5;
@ -14,167 +13,208 @@ const beforeUnload = (event: Event) => {
// event.returnValue = "";
};
export const useUploadStore = defineStore("upload", () => {
const $showError = inject<IToastError>("$showError")!;
// Utility function to format bytes into a readable string
function formatSize(bytes: number): string {
if (bytes === 0) return "0.00 Bytes";
let progressInterval: number | null = null;
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
//
// STATE
//
// Return the rounded size with two decimal places
return (bytes / k ** i).toFixed(2) + " " + sizes[i];
}
const allUploads = ref<Upload[]>([]);
const activeUploads = ref<Set<Upload>>(new Set());
const lastUpload = ref<number>(-1);
const totalBytes = ref<number>(0);
const sentBytes = ref<number>(0);
//
// ACTIONS
//
const upload = (
path: string,
name: string,
file: File | null,
overwrite: boolean,
type: ResourceType
) => {
if (!hasActiveUploads() && !hasPendingUploads()) {
window.addEventListener("beforeunload", beforeUnload);
buttons.loading("upload");
}
const upload: Upload = {
path,
name,
file,
overwrite,
type,
totalBytes: file?.size || 1,
sentBytes: 0,
// Stores rapidly changing sent bytes value without causing component re-renders
rawProgress: markRaw({
sentBytes: 0,
}),
};
totalBytes.value += upload.totalBytes;
allUploads.value.push(upload);
processUploads();
};
const abort = () => {
// Resets the state by preventing the processing of the remaning uploads
lastUpload.value = Infinity;
tus.abortAllUploads();
};
//
// GETTERS
//
const pendingUploadCount = computed(
() =>
allUploads.value.length -
(lastUpload.value + 1) +
activeUploads.value.size
);
//
// PRIVATE FUNCTIONS
//
const hasActiveUploads = () => activeUploads.value.size > 0;
const hasPendingUploads = () =>
allUploads.value.length > lastUpload.value + 1;
const isActiveUploadsOnLimit = () => activeUploads.value.size < UPLOADS_LIMIT;
const processUploads = async () => {
if (!hasActiveUploads() && !hasPendingUploads()) {
const fileStore = useFileStore();
window.removeEventListener("beforeunload", beforeUnload);
buttons.success("upload");
reset();
fileStore.reload = true;
}
if (isActiveUploadsOnLimit() && hasPendingUploads()) {
if (!hasActiveUploads()) {
// Update the state in a fixed time interval
progressInterval = window.setInterval(syncState, 1000);
export const useUploadStore = defineStore("upload", {
// convert to a function
state: (): {
id: number;
sizes: number[];
progress: number[];
queue: UploadItem[];
uploads: Uploads;
speedMbyte: number;
eta: number;
error: Error | null;
} => ({
id: 0,
sizes: [],
progress: [],
queue: [],
uploads: {},
speedMbyte: 0,
eta: 0,
error: null,
}),
getters: {
// user and jwt getter removed, no longer needed
getProgress: (state) => {
if (state.progress.length === 0) {
return 0;
}
const upload = nextUpload();
if (upload.type === "dir") {
await api.post(upload.path).catch($showError);
} else {
const onUpload = (event: ProgressEvent) => {
upload.rawProgress.sentBytes = event.loaded;
};
await api
.post(upload.path, upload.file!, upload.overwrite, onUpload)
.catch((err) => err.message !== "Upload aborted" && $showError(err));
const totalSize = state.sizes.reduce((a, b) => a + b, 0);
const sum = state.progress.reduce((a, b) => a + b, 0);
return Math.ceil((sum / totalSize) * 100);
},
getProgressDecimal: (state) => {
if (state.progress.length === 0) {
return 0;
}
finishUpload(upload);
}
};
const totalSize = state.sizes.reduce((a, b) => a + b, 0);
const sum = state.progress.reduce((a, b) => a + b, 0);
return ((sum / totalSize) * 100).toFixed(2);
},
getTotalProgressBytes: (state) => {
if (state.progress.length === 0 || state.sizes.length === 0) {
return "0 Bytes";
}
const sum = state.progress.reduce((a, b) => a + b, 0);
return formatSize(sum);
},
getTotalSize: (state) => {
if (state.sizes.length === 0) {
return "0 Bytes";
}
const totalSize = state.sizes.reduce((a, b) => a + b, 0);
return formatSize(totalSize);
},
filesInUploadCount: (state) => {
return Object.keys(state.uploads).length + state.queue.length;
},
filesInUpload: (state) => {
const files = [];
const nextUpload = (): Upload => {
lastUpload.value++;
for (const index in state.uploads) {
const upload = state.uploads[index];
const id = upload.id;
const type = upload.type;
const name = upload.file.name;
const size = state.sizes[id];
const isDir = upload.file.isDir;
const progress = isDir
? 100
: Math.ceil((state.progress[id] / size) * 100);
const upload = allUploads.value[lastUpload.value];
activeUploads.value.add(upload);
files.push({
id,
name,
progress,
type,
isDir,
});
}
return upload;
};
return files.sort((a, b) => a.progress - b.progress);
},
uploadSpeed: (state) => {
return state.speedMbyte;
},
getETA: (state) => state.eta,
},
actions: {
// no context as first argument, use `this` instead
setProgress({ id, loaded }: { id: number; loaded: number }) {
this.progress[id] = loaded;
},
setError(error: Error) {
this.error = error;
},
reset() {
this.id = 0;
this.sizes = [];
this.progress = [];
this.queue = [];
this.uploads = {};
this.speedMbyte = 0;
this.eta = 0;
this.error = null;
},
addJob(item: UploadItem) {
this.queue.push(item);
this.sizes[this.id] = item.file.size;
this.id++;
},
moveJob() {
const item = this.queue[0];
this.queue.shift();
this.uploads[item.id] = item;
},
removeJob(id: number) {
delete this.uploads[id];
},
upload(item: UploadItem) {
const uploadsCount = Object.keys(this.uploads).length;
const finishUpload = (upload: Upload) => {
sentBytes.value += upload.totalBytes - upload.sentBytes;
upload.sentBytes = upload.totalBytes;
upload.file = null;
const isQueueEmpty = this.queue.length == 0;
const isUploadsEmpty = uploadsCount == 0;
activeUploads.value.delete(upload);
processUploads();
};
if (isQueueEmpty && isUploadsEmpty) {
window.addEventListener("beforeunload", beforeUnload);
buttons.loading("upload");
}
const syncState = () => {
for (const upload of activeUploads.value) {
sentBytes.value += upload.rawProgress.sentBytes - upload.sentBytes;
upload.sentBytes = upload.rawProgress.sentBytes;
}
};
this.addJob(item);
this.processUploads();
},
finishUpload(item: UploadItem) {
this.setProgress({ id: item.id, loaded: item.file.size });
this.removeJob(item.id);
this.processUploads();
},
async processUploads() {
const uploadsCount = Object.keys(this.uploads).length;
const reset = () => {
if (progressInterval !== null) {
clearInterval(progressInterval);
progressInterval = null;
}
const isBelowLimit = uploadsCount < UPLOADS_LIMIT;
const isQueueEmpty = this.queue.length == 0;
const isUploadsEmpty = uploadsCount == 0;
allUploads.value = [];
activeUploads.value = new Set();
lastUpload.value = -1;
totalBytes.value = 0;
sentBytes.value = 0;
};
const isFinished = isQueueEmpty && isUploadsEmpty;
const canProcess = isBelowLimit && !isQueueEmpty;
return {
// STATE
activeUploads,
totalBytes,
sentBytes,
if (isFinished) {
const fileStore = useFileStore();
window.removeEventListener("beforeunload", beforeUnload);
buttons.success("upload");
this.reset();
fileStore.reload = true;
}
// ACTIONS
upload,
abort,
if (canProcess) {
const item = this.queue[0];
this.moveJob();
// GETTERS
pendingUploadCount,
};
if (item.file.isDir) {
await api.post(item.path).catch(this.setError);
} else {
const onUpload = throttle(
(event: ProgressEvent) =>
this.setProgress({
id: item.id,
loaded: event.loaded,
}),
100,
{ leading: true, trailing: false }
);
await api
.post(item.path, item.file.file as File, item.overwrite, onUpload)
.catch(this.setError);
}
this.finishUpload(item);
}
},
setUploadSpeed(value: number) {
this.speedMbyte = value;
},
setETA(value: number) {
this.eta = value;
},
// easily reset state using `$reset`
clearUpload() {
this.$reset();
},
},
});

View File

@ -29,7 +29,6 @@ interface ResourceItem extends ResourceBase {
}
type ResourceType =
| "dir"
| "video"
| "audio"
| "image"

View File

@ -1,15 +1,22 @@
type Upload = {
interface Uploads {
[key: number]: Upload;
}
interface Upload {
id: number;
file: UploadEntry;
type?: ResourceType;
}
interface UploadItem {
id: number;
url?: string;
path: string;
name: string;
file: File | null;
type: ResourceType;
overwrite: boolean;
totalBytes: number;
sentBytes: number;
rawProgress: {
sentBytes: number;
};
};
file: UploadEntry;
dir?: boolean;
overwrite?: boolean;
type?: ResourceType;
}
interface UploadEntry {
name: string;
@ -20,3 +27,17 @@ interface UploadEntry {
}
type UploadList = UploadEntry[];
type CurrentUploadList = {
[key: string]: {
upload: import("tus-js-client").Upload;
recentSpeeds: number[];
initialBytesUploaded: number;
currentBytesUploaded: number;
currentAverageSpeed: number;
lastProgressTimestamp: number | null;
sumOfRecentSpeeds: number;
hasStarted: boolean;
interval: number | undefined;
};
};

View File

@ -132,6 +132,7 @@ export function handleFiles(
layoutStore.closeHovers();
for (const file of files) {
const id = uploadStore.id;
let path = base;
if (file.fullPath !== undefined) {
@ -144,8 +145,14 @@ export function handleFiles(
path += "/";
}
const type = file.isDir ? "dir" : detectType((file.file as File).type);
const item: UploadItem = {
id,
path,
file,
overwrite,
...(!file.isDir && { type: detectType((file.file as File).type) }),
};
uploadStore.upload(path, file.name, file.file ?? null, overwrite, type);
uploadStore.upload(item);
}
}

View File

@ -36,6 +36,7 @@ import { files as api } from "@/api";
import { storeToRefs } from "pinia";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { useUploadStore } from "@/stores/upload";
import HeaderBar from "@/components/header/HeaderBar.vue";
import Breadcrumbs from "@/components/Breadcrumbs.vue";
@ -51,8 +52,10 @@ const Preview = defineAsyncComponent(() => import("@/views/files/Preview.vue"));
const layoutStore = useLayoutStore();
const fileStore = useFileStore();
const uploadStore = useUploadStore();
const { reload } = storeToRefs(fileStore);
const { error: uploadError } = storeToRefs(uploadStore);
const route = useRoute();
@ -105,6 +108,9 @@ watch(route, () => {
watch(reload, (newValue) => {
newValue && fetchData();
});
watch(uploadError, (newValue) => {
newValue && layoutStore.showError();
});
// Define functions

View File

@ -1,11 +1,7 @@
<template>
<div>
<div v-if="uploadStore.totalBytes" class="progress">
<div
v-bind:style="{
width: sentPercent + '%',
}"
></div>
<div v-if="uploadStore.getProgress" class="progress">
<div v-bind:style="{ width: uploadStore.getProgress + '%' }"></div>
</div>
<sidebar></sidebar>
<main>
@ -31,7 +27,7 @@ import Prompts from "@/components/prompts/Prompts.vue";
import Shell from "@/components/Shell.vue";
import UploadFiles from "@/components/prompts/UploadFiles.vue";
import { enableExec } from "@/utils/constants";
import { computed, watch } from "vue";
import { watch } from "vue";
import { useRoute } from "vue-router";
const layoutStore = useLayoutStore();
@ -40,10 +36,6 @@ const fileStore = useFileStore();
const uploadStore = useUploadStore();
const route = useRoute();
const sentPercent = computed(() =>
((uploadStore.sentBytes / uploadStore.totalBytes) * 100).toFixed(2)
);
watch(route, () => {
fileStore.selected = [];
fileStore.multiple = false;

View File

@ -9,6 +9,7 @@
"src/components/prompts/Delete.vue",
"src/components/prompts/FileList.vue",
"src/components/prompts/Rename.vue",
"src/components/prompts/Share.vue"
"src/components/prompts/Share.vue",
"src/components/prompts/UploadFiles.vue"
]
}

View File

@ -69,7 +69,7 @@ require (
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect

View File

@ -207,8 +207,8 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=

View File

@ -1,6 +1,6 @@
# Configuration
Most of the configuration can be understood through the command line interface documentation. To access it, you need to install File Browser and run `filebrowser --help`. In this page, we cover some specific, more complex, topics.
Most of the configuration can be understood through our Command Line Interface documentation. Although there are some specific topics that we want to cover on this section.
## Custom Branding