mirror of
https://github.com/filebrowser/filebrowser.git
synced 2025-07-18 14:00:25 +00:00
Compare commits
No commits in common. "e6ffb653740e37118d1882edb8d71ebe6663fece" and "7c716862c1bd3cdedd3c02d3a37207293db197ca" have entirely different histories.
e6ffb65374
...
7c716862c1
2
.github/workflows/main.yaml
vendored
2
.github/workflows/main.yaml
vendored
@ -85,6 +85,8 @@ jobs:
|
|||||||
node-version: "22.x"
|
node-version: "22.x"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
cache-dependency-path: "frontend/pnpm-lock.yaml"
|
cache-dependency-path: "frontend/pnpm-lock.yaml"
|
||||||
|
- name: Install upx
|
||||||
|
run: sudo apt-get install -y upx
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v1
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
@ -40,6 +40,17 @@ archives:
|
|||||||
- goos: windows
|
- goos: windows
|
||||||
formats: ["zip"]
|
formats: ["zip"]
|
||||||
|
|
||||||
|
upx:
|
||||||
|
- enabled: true
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
compress: "best"
|
||||||
|
lzma: true
|
||||||
|
|
||||||
dockers:
|
dockers:
|
||||||
# Alpine docker images
|
# Alpine docker images
|
||||||
- dockerfile: Dockerfile
|
- dockerfile: Dockerfile
|
||||||
|
21
CHANGELOG.md
21
CHANGELOG.md
@ -2,27 +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.
|
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.39.0](https://github.com/filebrowser/filebrowser/compare/v2.38.0...v2.39.0) (2025-07-13)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* Improve Docker entrypoint and config handling ([01c814c](https://github.com/filebrowser/filebrowser/commit/01c814cf98f81f2bcd622aea75e5b1efe3484940))
|
|
||||||
* rewrite the archiver and added support for zstd and brotli ([#5283](https://github.com/filebrowser/filebrowser/issues/5283)) ([7c71686](https://github.com/filebrowser/filebrowser/commit/7c716862c1bd3cdedd3c02d3a37207293db197ca))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* drop modify permission for uploading new file ([#5270](https://github.com/filebrowser/filebrowser/issues/5270)) ([0f27c91](https://github.com/filebrowser/filebrowser/commit/0f27c91eca581482ce4f82f6429f5dac12f8b64e))
|
|
||||||
* Settings button in the sidebar ([5a8e717](https://github.com/filebrowser/filebrowser/commit/5a8e7171b1b41eff771fe27133c91d2c250896a8))
|
|
||||||
|
|
||||||
|
|
||||||
### Build
|
|
||||||
|
|
||||||
* improve docker image and binary sizes ([35ca24a](https://github.com/filebrowser/filebrowser/commit/35ca24adb886721fc9d5e1a68cfc577e2c5f0230))
|
|
||||||
* lightweight busybox-based container build ([#5285](https://github.com/filebrowser/filebrowser/issues/5285)) ([5c5942d](https://github.com/filebrowser/filebrowser/commit/5c5942d99514b433e09d90624bbe58992eab6be2))
|
|
||||||
* remove upx ([1a5c83b](https://github.com/filebrowser/filebrowser/commit/1a5c83bcfe847f1e41a44cef23fd795b19b6b434))
|
|
||||||
|
|
||||||
## [2.38.0](https://github.com/filebrowser/filebrowser/compare/v2.37.0...v2.38.0) (2025-07-12)
|
## [2.38.0](https://github.com/filebrowser/filebrowser/compare/v2.37.0...v2.38.0) (2025-07-12)
|
||||||
|
|
||||||
|
|
||||||
|
36
Dockerfile
36
Dockerfile
@ -1,37 +1,23 @@
|
|||||||
## Multistage build: First stage fetches dependencies
|
FROM alpine:3.22
|
||||||
FROM alpine:3.22 AS fetcher
|
|
||||||
|
|
||||||
# install and copy ca-certificates, mailcap, and tini-static; download JSON.sh
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk --no-cache add ca-certificates mailcap tini-static && \
|
apk --no-cache add ca-certificates mailcap jq tini
|
||||||
wget -O /JSON.sh https://raw.githubusercontent.com/dominictarr/JSON.sh/0d5e5c77365f63809bf6e77ef44a1f34b0e05840/JSON.sh
|
|
||||||
|
|
||||||
## Second stage: Use lightweight BusyBox image for final runtime environment
|
# Make user and create necessary directories
|
||||||
FROM busybox:1.37.0-musl
|
|
||||||
|
|
||||||
# Define non-root user UID and GID
|
|
||||||
ENV UID=1000
|
ENV UID=1000
|
||||||
ENV GID=1000
|
ENV GID=1000
|
||||||
|
|
||||||
# Create user group and user
|
|
||||||
RUN addgroup -g $GID user && \
|
RUN addgroup -g $GID user && \
|
||||||
adduser -D -u $UID -G user user
|
adduser -D -u $UID -G user user && \
|
||||||
|
mkdir -p /config /database /srv && \
|
||||||
|
chown -R user:user /config /database /srv
|
||||||
|
|
||||||
# Copy binary, scripts, and configurations into image with proper ownership
|
# Copy files and set permissions
|
||||||
COPY --chown=user:user filebrowser /bin/filebrowser
|
COPY filebrowser /bin/filebrowser
|
||||||
COPY --chown=user:user docker/common/ /
|
COPY docker/common/ /
|
||||||
COPY --chown=user:user docker/alpine/ /
|
COPY docker/alpine/ /
|
||||||
COPY --chown=user:user --from=fetcher /sbin/tini-static /bin/tini
|
|
||||||
COPY --from=fetcher /JSON.sh /JSON.sh
|
|
||||||
COPY --from=fetcher /etc/ca-certificates.conf /etc/ca-certificates.conf
|
|
||||||
COPY --from=fetcher /etc/ca-certificates /etc/ca-certificates
|
|
||||||
COPY --from=fetcher /etc/mime.types /etc/mime.types
|
|
||||||
COPY --from=fetcher /etc/ssl /etc/ssl
|
|
||||||
|
|
||||||
# Create data directories, set ownership, and ensure healthcheck script is executable
|
RUN chown -R user:user /bin/filebrowser /defaults healthcheck.sh init.sh
|
||||||
RUN mkdir -p /config /database /srv && \
|
|
||||||
chown -R user:user /config /database /srv \
|
|
||||||
&& chmod +x /healthcheck.sh
|
|
||||||
|
|
||||||
# Define healthcheck script
|
# Define healthcheck script
|
||||||
HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh
|
HEALTHCHECK --start-period=2s --interval=5s --timeout=3s CMD /healthcheck.sh
|
||||||
|
@ -1,9 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
PORT=${FB_PORT:-$(cat /tmp/FB_CONFIG | sh /JSON.sh | grep '\["port"\]' | awk '{print $2}')}
|
|
||||||
ADDRESS=${FB_ADDRESS:-$(cat /tmp/FB_CONFIG | sh /JSON.sh | grep '\["address"\]' | awk '{print $2}' | sed 's/"//g')}
|
|
||||||
ADDRESS=${ADDRESS:-localhost}
|
|
||||||
|
|
||||||
wget -q --spider http://$ADDRESS:$PORT/health || exit 1
|
|
@ -7,32 +7,19 @@ if [ ! -f "/config/settings.json" ]; then
|
|||||||
cp -a /defaults/settings.json /config/settings.json
|
cp -a /defaults/settings.json /config/settings.json
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Extract config file path from arguments
|
# Deal with the case where user does not provide a config argument
|
||||||
config_file=""
|
has_config_arg=0
|
||||||
next_is_config=0
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
if [ "$next_is_config" -eq 1 ]; then
|
|
||||||
config_file="$arg"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
-c|--config)
|
--config|--config=*|-c|-c=*)
|
||||||
next_is_config=1
|
has_config_arg=1
|
||||||
;;
|
break
|
||||||
-c=*|--config=*)
|
;;
|
||||||
config_file="${arg#*=}"
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# If no config argument is provided, set the default and add it to the args
|
if [ "$has_config_arg" -eq 0 ]; then
|
||||||
if [ -z "$config_file" ]; then
|
set -- --config=/config/settings.json "$@"
|
||||||
config_file="/config/settings.json"
|
fi
|
||||||
set -- --config=/config/settings.json "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a symlink to the config file for compatibility with the healthcheck script
|
|
||||||
ln -s "$config_file" /tmp/FB_CONFIG
|
|
||||||
|
|
||||||
exec filebrowser "$@"
|
exec filebrowser "$@"
|
@ -3,6 +3,7 @@ import { baseURL, tusEndpoint, tusSettings } from "@/utils/constants";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useUploadStore } from "@/stores/upload";
|
import { useUploadStore } from "@/stores/upload";
|
||||||
import { removePrefix } from "@/api/utils";
|
import { removePrefix } from "@/api/utils";
|
||||||
|
import { fetchURL } from "./utils";
|
||||||
|
|
||||||
const RETRY_BASE_DELAY = 1000;
|
const RETRY_BASE_DELAY = 1000;
|
||||||
const RETRY_MAX_DELAY = 20000;
|
const RETRY_MAX_DELAY = 20000;
|
||||||
@ -27,6 +28,8 @@ export async function upload(
|
|||||||
filePath = removePrefix(filePath);
|
filePath = removePrefix(filePath);
|
||||||
const resourcePath = `${tusEndpoint}${filePath}?override=${overwrite}`;
|
const resourcePath = `${tusEndpoint}${filePath}?override=${overwrite}`;
|
||||||
|
|
||||||
|
await createUpload(resourcePath);
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
// Exit early because of typescript, tus content can't be a string
|
// Exit early because of typescript, tus content can't be a string
|
||||||
@ -35,7 +38,7 @@ export async function upload(
|
|||||||
}
|
}
|
||||||
return new Promise<void | string>((resolve, reject) => {
|
return new Promise<void | string>((resolve, reject) => {
|
||||||
const upload = new tus.Upload(content, {
|
const upload = new tus.Upload(content, {
|
||||||
endpoint: `${baseURL}${resourcePath}`,
|
uploadUrl: `${baseURL}${resourcePath}`,
|
||||||
chunkSize: tusSettings.chunkSize,
|
chunkSize: tusSettings.chunkSize,
|
||||||
retryDelays: computeRetryDelays(tusSettings),
|
retryDelays: computeRetryDelays(tusSettings),
|
||||||
parallelUploads: 1,
|
parallelUploads: 1,
|
||||||
@ -43,18 +46,6 @@ export async function upload(
|
|||||||
headers: {
|
headers: {
|
||||||
"X-Auth": authStore.jwt,
|
"X-Auth": authStore.jwt,
|
||||||
},
|
},
|
||||||
onShouldRetry: function (err) {
|
|
||||||
const status = err.originalResponse
|
|
||||||
? err.originalResponse.getStatus()
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Do not retry for file conflict.
|
|
||||||
if (status === 409) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onError: function (error) {
|
onError: function (error) {
|
||||||
if (CURRENT_UPLOAD_LIST[filePath].interval) {
|
if (CURRENT_UPLOAD_LIST[filePath].interval) {
|
||||||
clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
|
clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
|
||||||
@ -101,6 +92,17 @@ export async function upload(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createUpload(resourcePath: string) {
|
||||||
|
const headResp = await fetchURL(resourcePath, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (headResp.status !== 201) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create an upload: ${headResp.status} ${headResp.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function computeRetryDelays(tusSettings: TusSettings): number[] | undefined {
|
function computeRetryDelays(tusSettings: TusSettings): number[] | undefined {
|
||||||
if (!tusSettings.retryCount || tusSettings.retryCount < 1) {
|
if (!tusSettings.retryCount || tusSettings.retryCount < 1) {
|
||||||
// Disable retries altogether
|
// Disable retries altogether
|
||||||
@ -128,8 +130,7 @@ function isTusSupported() {
|
|||||||
return tus.isSupported === true;
|
return tus.isSupported === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeETA(speed?: number) {
|
function computeETA(state: ETAState, speed?: number) {
|
||||||
const state = useUploadStore();
|
|
||||||
if (state.speedMbyte === 0) {
|
if (state.speedMbyte === 0) {
|
||||||
return Infinity;
|
return Infinity;
|
||||||
}
|
}
|
||||||
@ -137,13 +138,22 @@ function computeETA(speed?: number) {
|
|||||||
(acc: number, size: number) => acc + size,
|
(acc: number, size: number) => acc + size,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
const uploadedSize = state.progress.reduce((a, b) => a + b, 0);
|
const uploadedSize = state.progress.reduce(
|
||||||
|
(acc: number, progress: Progress) => {
|
||||||
|
if (typeof progress === "number") {
|
||||||
|
return acc + progress;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
const remainingSize = totalSize - uploadedSize;
|
const remainingSize = totalSize - uploadedSize;
|
||||||
const speedBytesPerSecond = (speed ?? state.speedMbyte) * 1024 * 1024;
|
const speedBytesPerSecond = (speed ?? state.speedMbyte) * 1024 * 1024;
|
||||||
return remainingSize / speedBytesPerSecond;
|
return remainingSize / speedBytesPerSecond;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeGlobalSpeedAndETA() {
|
function computeGlobalSpeedAndETA() {
|
||||||
|
const uploadStore = useUploadStore();
|
||||||
let totalSpeed = 0;
|
let totalSpeed = 0;
|
||||||
let totalCount = 0;
|
let totalCount = 0;
|
||||||
|
|
||||||
@ -155,7 +165,7 @@ function computeGlobalSpeedAndETA() {
|
|||||||
if (totalCount === 0) return { speed: 0, eta: Infinity };
|
if (totalCount === 0) return { speed: 0, eta: Infinity };
|
||||||
|
|
||||||
const averageSpeed = totalSpeed / totalCount;
|
const averageSpeed = totalSpeed / totalCount;
|
||||||
const averageETA = computeETA(averageSpeed);
|
const averageETA = computeETA(uploadStore, averageSpeed);
|
||||||
|
|
||||||
return { speed: averageSpeed, eta: averageETA };
|
return { speed: averageSpeed, eta: averageETA };
|
||||||
}
|
}
|
||||||
@ -197,9 +207,6 @@ export function abortAllUploads() {
|
|||||||
}
|
}
|
||||||
if (CURRENT_UPLOAD_LIST[filePath].upload) {
|
if (CURRENT_UPLOAD_LIST[filePath].upload) {
|
||||||
CURRENT_UPLOAD_LIST[filePath].upload.abort(true);
|
CURRENT_UPLOAD_LIST[filePath].upload.abort(true);
|
||||||
CURRENT_UPLOAD_LIST[filePath].upload.options!.onError!(
|
|
||||||
new Error("Upload aborted")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
delete CURRENT_UPLOAD_LIST[filePath];
|
delete CURRENT_UPLOAD_LIST[filePath];
|
||||||
}
|
}
|
||||||
|
@ -41,7 +41,7 @@
|
|||||||
<div v-if="user.perm.admin">
|
<div v-if="user.perm.admin">
|
||||||
<button
|
<button
|
||||||
class="action"
|
class="action"
|
||||||
@click="toGlobalSettings"
|
@click="toSettings"
|
||||||
:aria-label="$t('sidebar.settings')"
|
:aria-label="$t('sidebar.settings')"
|
||||||
:title="$t('sidebar.settings')"
|
:title="$t('sidebar.settings')"
|
||||||
>
|
>
|
||||||
|
@ -30,7 +30,7 @@ export const useUploadStore = defineStore("upload", {
|
|||||||
state: (): {
|
state: (): {
|
||||||
id: number;
|
id: number;
|
||||||
sizes: number[];
|
sizes: number[];
|
||||||
progress: number[];
|
progress: Progress[];
|
||||||
queue: UploadItem[];
|
queue: UploadItem[];
|
||||||
uploads: Uploads;
|
uploads: Uploads;
|
||||||
speedMbyte: number;
|
speedMbyte: number;
|
||||||
@ -54,7 +54,9 @@ export const useUploadStore = defineStore("upload", {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalSize = state.sizes.reduce((a, b) => a + b, 0);
|
const totalSize = state.sizes.reduce((a, b) => a + b, 0);
|
||||||
const sum = state.progress.reduce((a, b) => a + b, 0);
|
|
||||||
|
// TODO: this looks ugly but it works with ts now
|
||||||
|
const sum = state.progress.reduce((acc, val) => +acc + +val) as number;
|
||||||
return Math.ceil((sum / totalSize) * 100);
|
return Math.ceil((sum / totalSize) * 100);
|
||||||
},
|
},
|
||||||
getProgressDecimal: (state) => {
|
getProgressDecimal: (state) => {
|
||||||
@ -63,14 +65,21 @@ export const useUploadStore = defineStore("upload", {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalSize = state.sizes.reduce((a, b) => a + b, 0);
|
const totalSize = state.sizes.reduce((a, b) => a + b, 0);
|
||||||
const sum = state.progress.reduce((a, b) => a + b, 0);
|
|
||||||
|
// TODO: this looks ugly but it works with ts now
|
||||||
|
const sum = state.progress.reduce((acc, val) => +acc + +val) as number;
|
||||||
return ((sum / totalSize) * 100).toFixed(2);
|
return ((sum / totalSize) * 100).toFixed(2);
|
||||||
},
|
},
|
||||||
getTotalProgressBytes: (state) => {
|
getTotalProgressBytes: (state) => {
|
||||||
if (state.progress.length === 0 || state.sizes.length === 0) {
|
if (state.progress.length === 0 || state.sizes.length === 0) {
|
||||||
return "0 Bytes";
|
return "0 Bytes";
|
||||||
}
|
}
|
||||||
const sum = state.progress.reduce((a, b) => a + b, 0);
|
const sum = state.progress.reduce(
|
||||||
|
(sum, p, i) =>
|
||||||
|
(sum as number) +
|
||||||
|
(typeof p === "number" ? p : p ? state.sizes[i] : 0),
|
||||||
|
0
|
||||||
|
) as number;
|
||||||
return formatSize(sum);
|
return formatSize(sum);
|
||||||
},
|
},
|
||||||
getTotalSize: (state) => {
|
getTotalSize: (state) => {
|
||||||
@ -95,7 +104,7 @@ export const useUploadStore = defineStore("upload", {
|
|||||||
const isDir = upload.file.isDir;
|
const isDir = upload.file.isDir;
|
||||||
const progress = isDir
|
const progress = isDir
|
||||||
? 100
|
? 100
|
||||||
: Math.ceil((state.progress[id] / size) * 100);
|
: Math.ceil(((state.progress[id] as number) / size) * 100);
|
||||||
|
|
||||||
files.push({
|
files.push({
|
||||||
id,
|
id,
|
||||||
@ -115,7 +124,7 @@ export const useUploadStore = defineStore("upload", {
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
// no context as first argument, use `this` instead
|
// no context as first argument, use `this` instead
|
||||||
setProgress({ id, loaded }: { id: number; loaded: number }) {
|
setProgress({ id, loaded }: { id: number; loaded: Progress }) {
|
||||||
this.progress[id] = loaded;
|
this.progress[id] = loaded;
|
||||||
},
|
},
|
||||||
setError(error: Error) {
|
setError(error: Error) {
|
||||||
@ -159,7 +168,7 @@ export const useUploadStore = defineStore("upload", {
|
|||||||
this.processUploads();
|
this.processUploads();
|
||||||
},
|
},
|
||||||
finishUpload(item: UploadItem) {
|
finishUpload(item: UploadItem) {
|
||||||
this.setProgress({ id: item.id, loaded: item.file.size });
|
this.setProgress({ id: item.id, loaded: item.file.size > 0 });
|
||||||
this.removeJob(item.id);
|
this.removeJob(item.id);
|
||||||
this.processUploads();
|
this.processUploads();
|
||||||
},
|
},
|
||||||
|
8
frontend/src/types/upload.d.ts
vendored
8
frontend/src/types/upload.d.ts
vendored
@ -28,6 +28,8 @@ interface UploadEntry {
|
|||||||
|
|
||||||
type UploadList = UploadEntry[];
|
type UploadList = UploadEntry[];
|
||||||
|
|
||||||
|
type Progress = number | boolean;
|
||||||
|
|
||||||
type CurrentUploadList = {
|
type CurrentUploadList = {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
upload: import("tus-js-client").Upload;
|
upload: import("tus-js-client").Upload;
|
||||||
@ -41,3 +43,9 @@ type CurrentUploadList = {
|
|||||||
interval: number | undefined;
|
interval: number | undefined;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ETAState {
|
||||||
|
sizes: number[];
|
||||||
|
progress: Progress[];
|
||||||
|
speedMbyte: number;
|
||||||
|
}
|
||||||
|
2
go.mod
2
go.mod
@ -53,7 +53,6 @@ require (
|
|||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jellydator/ttlcache/v3 v3.4.0 // indirect
|
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||||
@ -72,7 +71,6 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||||
golang.org/x/net v0.41.0 // indirect
|
golang.org/x/net v0.41.0 // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
2
go.sum
2
go.sum
@ -150,8 +150,6 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
|
|||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
|
||||||
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
|
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
|
@ -69,7 +69,7 @@ func NewHandler(
|
|||||||
api.PathPrefix("/tus").Handler(monkey(tusPostHandler(), "/api/tus")).Methods("POST")
|
api.PathPrefix("/tus").Handler(monkey(tusPostHandler(), "/api/tus")).Methods("POST")
|
||||||
api.PathPrefix("/tus").Handler(monkey(tusHeadHandler(), "/api/tus")).Methods("HEAD", "GET")
|
api.PathPrefix("/tus").Handler(monkey(tusHeadHandler(), "/api/tus")).Methods("HEAD", "GET")
|
||||||
api.PathPrefix("/tus").Handler(monkey(tusPatchHandler(), "/api/tus")).Methods("PATCH")
|
api.PathPrefix("/tus").Handler(monkey(tusPatchHandler(), "/api/tus")).Methods("PATCH")
|
||||||
api.PathPrefix("/tus").Handler(monkey(tusDeleteHandler(), "/api/tus")).Methods("DELETE")
|
api.PathPrefix("/tus").Handler(monkey(resourceDeleteHandler(fileCache), "/api/tus")).Methods("DELETE")
|
||||||
|
|
||||||
api.PathPrefix("/usage").Handler(monkey(diskUsage, "/api/usage")).Methods("GET")
|
api.PathPrefix("/usage").Handler(monkey(diskUsage, "/api/usage")).Methods("GET")
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -9,76 +8,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jellydator/ttlcache/v3"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
|
||||||
"github.com/filebrowser/filebrowser/v2/files"
|
"github.com/filebrowser/filebrowser/v2/files"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxUploadWait = 3 * time.Minute
|
|
||||||
|
|
||||||
// Tracks active uploads along with their respective upload lengths
|
|
||||||
var activeUploads = initActiveUploads()
|
|
||||||
|
|
||||||
func initActiveUploads() *ttlcache.Cache[string, int64] {
|
|
||||||
cache := ttlcache.New[string, int64]()
|
|
||||||
cache.OnEviction(func(_ context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[string, int64]) {
|
|
||||||
if reason == ttlcache.EvictionReasonExpired {
|
|
||||||
fmt.Printf("deleting incomplete upload file: \"%s\"", item.Key())
|
|
||||||
os.Remove(item.Key())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
go cache.Start()
|
|
||||||
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerUpload(filePath string, fileSize int64) {
|
|
||||||
activeUploads.Set(filePath, fileSize, maxUploadWait)
|
|
||||||
}
|
|
||||||
|
|
||||||
func completeUpload(filePath string) {
|
|
||||||
activeUploads.Delete(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getActiveUploadLength(filePath string) (int64, error) {
|
|
||||||
item := activeUploads.Get(filePath)
|
|
||||||
if item == nil {
|
|
||||||
return 0, fmt.Errorf("no active upload found for the given path")
|
|
||||||
}
|
|
||||||
|
|
||||||
return item.Value(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func keepUploadActive(filePath string) func() {
|
|
||||||
stop := make(chan bool)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(2 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
activeUploads.Touch(filePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
close(stop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func tusPostHandler() handleFunc {
|
func tusPostHandler() handleFunc {
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
|
||||||
return http.StatusForbidden, nil
|
|
||||||
}
|
|
||||||
file, err := files.NewFileInfo(&files.FileOptions{
|
file, err := files.NewFileInfo(&files.FileOptions{
|
||||||
Fs: d.user.Fs,
|
Fs: d.user.Fs,
|
||||||
Path: r.URL.Path,
|
Path: r.URL.Path,
|
||||||
@ -89,6 +26,10 @@ func tusPostHandler() handleFunc {
|
|||||||
})
|
})
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, afero.ErrFileNotFound):
|
case errors.Is(err, afero.ErrFileNotFound):
|
||||||
|
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
|
||||||
dirPath := filepath.Dir(r.URL.Path)
|
dirPath := filepath.Dir(r.URL.Path)
|
||||||
if _, statErr := d.user.Fs.Stat(dirPath); os.IsNotExist(statErr) {
|
if _, statErr := d.user.Fs.Stat(dirPath); os.IsNotExist(statErr) {
|
||||||
if mkdirErr := d.user.Fs.MkdirAll(dirPath, files.PermDir); mkdirErr != nil {
|
if mkdirErr := d.user.Fs.MkdirAll(dirPath, files.PermDir); mkdirErr != nil {
|
||||||
@ -100,55 +41,25 @@ func tusPostHandler() handleFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileFlags := os.O_CREATE | os.O_WRONLY
|
fileFlags := os.O_CREATE | os.O_WRONLY
|
||||||
|
if r.URL.Query().Get("override") == "true" {
|
||||||
|
fileFlags |= os.O_TRUNC
|
||||||
|
}
|
||||||
|
|
||||||
// if file exists
|
// if file exists
|
||||||
if file != nil {
|
if file != nil {
|
||||||
if file.IsDir {
|
if file.IsDir {
|
||||||
return http.StatusBadRequest, fmt.Errorf("cannot upload to a directory %s", file.RealPath())
|
return http.StatusBadRequest, fmt.Errorf("cannot upload to a directory %s", file.RealPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing files will remain untouched unless explicitly instructed to override
|
|
||||||
if r.URL.Query().Get("override") != "true" {
|
|
||||||
return http.StatusConflict, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permission for overwriting the file
|
|
||||||
if !d.user.Perm.Modify {
|
|
||||||
return http.StatusForbidden, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fileFlags |= os.O_TRUNC
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openFile, err := d.user.Fs.OpenFile(r.URL.Path, fileFlags, files.PermFile)
|
openFile, err := d.user.Fs.OpenFile(r.URL.Path, fileFlags, files.PermFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
defer openFile.Close()
|
if err := openFile.Close(); err != nil {
|
||||||
|
|
||||||
file, err = files.NewFileInfo(&files.FileOptions{
|
|
||||||
Fs: d.user.Fs,
|
|
||||||
Path: r.URL.Path,
|
|
||||||
Modify: d.user.Perm.Modify,
|
|
||||||
Expand: false,
|
|
||||||
ReadHeader: false,
|
|
||||||
Checker: d,
|
|
||||||
Content: false,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadLength, err := getUploadLength(r)
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusBadRequest, fmt.Errorf("invalid upload length: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enables the user to utilize the PATCH endpoint for uploading file data
|
|
||||||
registerUpload(file.RealPath(), uploadLength)
|
|
||||||
|
|
||||||
w.Header().Set("Location", "/api/tus/"+r.URL.Path)
|
|
||||||
|
|
||||||
return http.StatusCreated, nil
|
return http.StatusCreated, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -156,7 +67,7 @@ func tusPostHandler() handleFunc {
|
|||||||
func tusHeadHandler() handleFunc {
|
func tusHeadHandler() handleFunc {
|
||||||
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
if !d.Check(r.URL.Path) {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,13 +83,8 @@ func tusHeadHandler() handleFunc {
|
|||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadLength, err := getActiveUploadLength(file.RealPath())
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusNotFound, err
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Upload-Offset", strconv.FormatInt(file.Size, 10))
|
w.Header().Set("Upload-Offset", strconv.FormatInt(file.Size, 10))
|
||||||
w.Header().Set("Upload-Length", strconv.FormatInt(uploadLength, 10))
|
w.Header().Set("Upload-Length", "-1")
|
||||||
|
|
||||||
return http.StatusOK, nil
|
return http.StatusOK, nil
|
||||||
})
|
})
|
||||||
@ -186,7 +92,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(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.Modify || !d.Check(r.URL.Path) {
|
||||||
return http.StatusForbidden, nil
|
return http.StatusForbidden, nil
|
||||||
}
|
}
|
||||||
if r.Header.Get("Content-Type") != "application/offset+octet-stream" {
|
if r.Header.Get("Content-Type") != "application/offset+octet-stream" {
|
||||||
@ -195,7 +101,7 @@ func tusPatchHandler() handleFunc {
|
|||||||
|
|
||||||
uploadOffset, err := getUploadOffset(r)
|
uploadOffset, err := getUploadOffset(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusBadRequest, fmt.Errorf("invalid upload offset")
|
return http.StatusBadRequest, fmt.Errorf("invalid upload offset: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := files.NewFileInfo(&files.FileOptions{
|
file, err := files.NewFileInfo(&files.FileOptions{
|
||||||
@ -214,15 +120,6 @@ func tusPatchHandler() handleFunc {
|
|||||||
return errToStatus(err), err
|
return errToStatus(err), err
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadLength, err := getActiveUploadLength(file.RealPath())
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusNotFound, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent the upload from being evicted during the transfer
|
|
||||||
stop := keepUploadActive(file.RealPath())
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case file.IsDir:
|
case file.IsDir:
|
||||||
return http.StatusBadRequest, fmt.Errorf("cannot upload to a directory %s", file.RealPath())
|
return http.StatusBadRequest, fmt.Errorf("cannot upload to a directory %s", file.RealPath())
|
||||||
@ -251,60 +148,12 @@ func tusPatchHandler() handleFunc {
|
|||||||
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
|
w.Header().Set("Upload-Offset", strconv.FormatInt(uploadOffset+bytesWritten, 10))
|
||||||
w.Header().Set("Upload-Offset", strconv.FormatInt(newOffset, 10))
|
|
||||||
|
|
||||||
if newOffset >= uploadLength {
|
|
||||||
completeUpload(file.RealPath())
|
|
||||||
_ = d.RunHook(func() error { return nil }, "upload", r.URL.Path, "", d.user)
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.StatusNoContent, nil
|
return http.StatusNoContent, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func tusDeleteHandler() handleFunc {
|
|
||||||
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
||||||
if r.URL.Path == "/" || !d.user.Perm.Create {
|
|
||||||
return http.StatusForbidden, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := files.NewFileInfo(&files.FileOptions{
|
|
||||||
Fs: d.user.Fs,
|
|
||||||
Path: r.URL.Path,
|
|
||||||
Modify: d.user.Perm.Modify,
|
|
||||||
Expand: false,
|
|
||||||
ReadHeader: d.server.TypeDetectionByHeader,
|
|
||||||
Checker: d,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return errToStatus(err), err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = getActiveUploadLength(file.RealPath())
|
|
||||||
if err != nil {
|
|
||||||
return http.StatusNotFound, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = d.user.Fs.RemoveAll(r.URL.Path)
|
|
||||||
if err != nil {
|
|
||||||
return errToStatus(err), err
|
|
||||||
}
|
|
||||||
|
|
||||||
completeUpload(file.RealPath())
|
|
||||||
|
|
||||||
return http.StatusNoContent, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUploadLength(r *http.Request) (int64, error) {
|
|
||||||
uploadOffset, err := strconv.ParseInt(r.Header.Get("Upload-Length"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("invalid upload length: %w", err)
|
|
||||||
}
|
|
||||||
return uploadOffset, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUploadOffset(r *http.Request) (int64, error) {
|
func getUploadOffset(r *http.Request) (int64, error) {
|
||||||
uploadOffset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
|
uploadOffset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user