Compare commits

...
16 Commits
Author SHA1 Message Date
Graham Steffaniak 010de838b1 add release without minor version (#1780) 2025-12-31 12:43:34 -06:00
Graham Steffaniak 07acd2f7bf fixed sidebar issue for shares (#1779) 2025-12-31 11:26:50 -06:00
Graham Steffaniak d049470d29 updated readme 2025-12-30 09:05:00 -06:00
Graham Steffaniak 180cc40567 Merge branch 'main' into dev/v1.1.5 2025-12-30 09:02:54 -06:00
Graham Steffaniak 40a4b51130 updated password protected shares (#1769) 2025-12-29 16:08:26 -06:00
Graham Steffaniak 68fb573059 improved cacheDir checks (#1723) 2025-12-13 08:50:05 -06:00
Graham Steffaniak 5747c40dca updated translation 2025-12-12 18:00:04 -06:00
Graham Steffaniak 598bd366db added search image show toggle (#1721) 2025-12-12 17:58:20 -06:00
Graham Steffaniak e3be3300dc File list loading spinner (#1720) 2025-12-12 15:44:27 -06:00
Graham Steffaniak 416aa75446 updated with new logger (#1719) 2025-12-12 15:24:27 -06:00
Graham Steffaniak cfb3b9e869 updated with fix for special characters (#1718) 2025-12-12 14:23:30 -06:00
Graham Steffaniak 166f1bf232 invalidate isDir cache (#1716) 2025-12-12 14:06:41 -06:00
Graham Steffaniak 451f9ac69f update with access rules for shares (#1714) 2025-12-12 12:35:25 -06:00
Graham Steffaniak b2fa1780ed updated with access rule changes (#1713) 2025-12-12 11:27:49 -06:00
Kevin B. 9255bc174b Fix/Improve some behaviors in nextPrevious (#1707) 2025-12-10 16:14:55 -06:00
Kevin B. a3a074d7b6 Fix navigation for files with special characters (#1711) 2025-12-10 14:12:21 -06:00
9 changed files with 219 additions and 63 deletions

No files matched your search

+41 -5
View File
@@ -59,7 +59,7 @@ jobs:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
- name: Modify tags (strip 'v' and add version tags)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
@@ -68,13 +68,31 @@ jobs:
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not)
) |
if any(.tags[]; test("beta")) then
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor and major tags
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
] |
# Add suffix-only tags for beta (only if tag ends with -beta)
if any(.tags[]; split(":")[-1] | endswith("-beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta"
]
else . end |
if any(.tags[]; test("stable")) then
# Add suffix-only tags for stable (and latest) (only if tag ends with -stable)
if any(.tags[]; split(":")[-1] | endswith("-stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
@@ -136,13 +154,31 @@ jobs:
| select(test(":latest") | not) # Remove the "latest" tag
| . + "-slim" # Append "-slim" to the versioned tag
) |
if any(.tags[]; test("beta")) then
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor and major tags
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
] |
# Add suffix-only tags for beta-slim (only if tag ends with -beta-slim)
if any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
.tags += [
"gtstef/filebrowser:beta-slim",
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
]
else . end |
if any(.tags[]; test("stable")) then
# Add suffix-only tags for stable-slim (only if tag ends with -stable-slim)
if any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
.tags += [
"gtstef/filebrowser:stable-slim",
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
+13
View File
@@ -2,6 +2,19 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.1.5-beta
**Notes**:
- Major git container tag request #1756
**BugFixes**:
- Sharing broken if source `disableIndexing: true` (#1742)
- Password protected share permission issues (#1729) (#1606) (#1593)
- Sidebar Sliding left (#1737)
- file permissions not working (#1762)
- handle Close() errors in archive creation (#1745)
- Move custom CSS and user-selected theme styles to the end of the body (#1744)
## v1.1.4-beta
**New Features**:
+31 -5
View File
@@ -1,6 +1,9 @@
package http
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
libError "errors"
"fmt"
@@ -333,8 +336,34 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
return 200, nil
}
if r.URL.Query().Get("token") == l.Token {
return 200, nil
tokenParam := r.URL.Query().Get("token")
if tokenParam != "" {
// Verify the token signature if it's in the new signed format
if strings.Contains(tokenParam, ".") {
parts := strings.Split(tokenParam, ".")
if len(parts) == 2 {
payload := parts[0]
signature := parts[1]
// Verify HMAC signature
mac := hmac.New(sha256.New, []byte(config.Auth.Key))
mac.Write([]byte(payload))
expectedSignature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
// Use constant-time comparison to prevent timing attacks
if hmac.Equal([]byte(signature), []byte(expectedSignature)) {
// Token signature is valid, now check if it matches stored token
if tokenParam == l.Token {
return 200, nil
}
}
}
} else {
// Legacy token format (plain base64) - direct comparison
if tokenParam == l.Token {
return 200, nil
}
}
}
password := r.Header.Get("X-SHARE-PASSWORD")
@@ -342,9 +371,6 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
if err != nil {
return http.StatusUnauthorized, err
}
if password == "" {
return http.StatusUnauthorized, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
+40 -26
View File
@@ -1,7 +1,9 @@
package http
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
@@ -62,8 +64,8 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
pathExists := utils.CheckPathExists(filepath.Join(sourceInfo.Path, s.Path))
s.CommonShare.HasPassword = s.HasPassword()
s.DownloadURL = getShareURL(r, s.Hash, true)
s.ShareURL = getShareURL(r, s.Hash, false)
s.DownloadURL = getShareURL(r, s.Hash, true, s.Token)
s.ShareURL = getShareURL(r, s.Hash, false, s.Token)
// Create response with source name (overrides the embedded Link's source field)
responses = append(responses, &ShareResponse{
@@ -283,11 +285,21 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
stringHash := ""
var token string
if len(hash) > 0 {
tokenBuffer := make([]byte, 24)
if _, err = rand.Read(tokenBuffer); err != nil {
// Generate a cryptographically secure token similar to JWT
// Create a random payload
payloadBuffer := make([]byte, 24)
if _, err = rand.Read(payloadBuffer); err != nil {
return http.StatusInternalServerError, err
}
token = base64.URLEncoding.EncodeToString(tokenBuffer)
payload := base64.URLEncoding.EncodeToString(payloadBuffer)
// Sign the payload with HMAC-SHA256 using the same secret key as JWT tokens
mac := hmac.New(sha256.New, []byte(config.Auth.Key))
mac.Write([]byte(payload))
signature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
// Combine payload and signature: payload.signature (similar to JWT format)
token = payload + "." + signature
stringHash = string(hash)
}
if s != nil {
@@ -352,17 +364,13 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err != nil {
return http.StatusForbidden, err
}
scopePath := utils.JoinPathAsUnix(userscope, body.Path)
scopePath = utils.AddTrailingSlashIfNotExists(scopePath)
body.Path = scopePath
providedPath := body.Path
body.Path = utils.JoinPathAsUnix(userscope, providedPath)
body.Path = utils.AddTrailingSlashIfNotExists(body.Path)
// validate path exists as file or folder
_, exists := idx.GetReducedMetadata(body.Path, true) // true to check if it exists
if !exists {
// could be a file instead
_, exists := idx.GetReducedMetadata(utils.GetParentDirectoryPath(body.Path), true)
if !exists {
return http.StatusForbidden, fmt.Errorf("path not found: %s", body.Path)
}
_, _, err = idx.GetRealPath(body.Path)
if err != nil {
return http.StatusForbidden, fmt.Errorf("path not found: %s", providedPath)
}
if body.ShareType == "upload" && !body.AllowCreate {
body.AllowCreate = true
@@ -510,8 +518,8 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
response := DirectDownloadResponse{
Status: "201",
Hash: existing.Hash,
DownloadURL: getShareURL(r, existing.Hash, true),
ShareURL: getShareURL(r, existing.Hash, false),
DownloadURL: getShareURL(r, existing.Hash, true, existing.Token),
ShareURL: getShareURL(r, existing.Hash, false, existing.Token),
}
return renderJSON(w, r, response)
}
@@ -542,18 +550,23 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
response := DirectDownloadResponse{
Status: "200",
Hash: secureHash,
DownloadURL: getShareURL(r, secureHash, true),
ShareURL: getShareURL(r, secureHash, false),
DownloadURL: getShareURL(r, secureHash, true, shareLink.Token),
ShareURL: getShareURL(r, secureHash, false, shareLink.Token),
}
return renderJSON(w, r, response)
}
func getShareURL(r *http.Request, hash string, isDirectDownload bool) string {
func getShareURL(r *http.Request, hash string, isDirectDownload bool, token string) string {
var shareURL string
tokenParam := ""
if token != "" && isDirectDownload {
tokenParam = fmt.Sprintf("&token=%s", url.QueryEscape(token))
}
if config.Server.ExternalUrl != "" {
if isDirectDownload {
shareURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
shareURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s%s", config.Server.ExternalUrl, config.Server.BaseURL, hash, tokenParam)
} else {
shareURL = fmt.Sprintf("%s%spublic/share/%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
}
@@ -576,7 +589,7 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool) string {
scheme = getScheme(r)
}
if isDirectDownload {
shareURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s", scheme, host, config.Server.BaseURL, hash)
shareURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s%s", scheme, host, config.Server.BaseURL, hash, tokenParam)
} else {
shareURL = fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, config.Server.BaseURL, hash)
}
@@ -596,13 +609,14 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool) string {
// @Router /public/api/shareinfo [get]
func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
// Get the file link by hash
commonShare, err := store.Share.GetCommonShareByHash(hash)
// Get the file link by hash (need full Link to get Token)
shareLink, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
commonShare.DownloadURL = getShareURL(r, hash, true)
commonShare.ShareURL = getShareURL(r, hash, false)
commonShare := shareLink.CommonShare
commonShare.DownloadURL = getShareURL(r, hash, true, shareLink.Token)
commonShare.ShareURL = getShareURL(r, hash, false, shareLink.Token)
return renderJSON(w, r, commonShare)
}
+5
View File
@@ -73,6 +73,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
staticURL := config.Server.BaseURL + "public/static"
description := config.Frontend.Description
title := config.Frontend.Name
disableSidebar := false
// Use custom favicon if configured and validated, otherwise fall back to default
favicon := staticURL + "/favicon"
@@ -104,6 +105,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
userSelectedTheme = theme.CssRaw
}
}
if d.share.DisableSidebar {
disableSidebar = true
}
}
// Set login icon URL
loginIcon := staticURL + "/loginIcon"
@@ -148,6 +152,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"enableHeicConversion": config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] && settings.MediaEnabled(),
"eventBasedThemes": !config.Frontend.Styling.DisableEventBasedThemes,
"loginIcon": loginIcon,
"disableSidebar": disableSidebar,
}
// Marshal each variable to JSON strings for direct template usage
-1
View File
@@ -91,7 +91,6 @@ export function getPreviewURL(path,size="small") {
}
}
export function post(
hash,
path,
+1 -1
View File
@@ -188,7 +188,7 @@ export const getters = {
return { dirs, files }
},
isSidebarVisible: () => {
if (state.shareInfo?.disableSidebar) {
if (globalVars.disableSidebar) {
return false
}
const cv = getters.currentView()
+86 -24
View File
@@ -204,6 +204,10 @@ export default {
this.attemptedPasswordLogin = this.sharePassword !== "";
// Reset password validation state on wrong password
mutations.setShareData({ passwordValid: false });
// Clear error for upload shares so upload interface can be shown once password is correct
if (state.shareInfo?.shareType === "upload") {
this.error = null;
}
this.showPasswordPrompt();
} else {
router.push({ name: "error" });
@@ -234,46 +238,104 @@ export default {
}
shareInfo.hash = hash;
mutations.setShareInfo(shareInfo);
if (shareInfo.shareType == "upload") {
return;
}
if (shareInfo.themeColor) {
document.documentElement.style.setProperty("--primaryColor", shareInfo.themeColor);
}
if (shareInfo.hasPassword && this.sharePassword === "") {
this.sharePassword = localStorage.getItem("sharepass:" + this.shareHash);
if (this.sharePassword === null || this.sharePassword === "") {
this.showPasswordPrompt();
return;
}
}
// Parse share route
// Parse share route to get shareHash and shareSubPath
let urlPath = getters.routePath('public/share')
let parts = urlPath.split("/");
this.shareHash = parts[1]
this.shareSubPath = "/" + parts.slice(2).join("/");
// Handle password
if (this.sharePassword === "") {
this.sharePassword = localStorage.getItem("sharepass:" + this.shareHash);
} else {
// Check for password requirement (applies to both regular and upload shares)
if (shareInfo.hasPassword) {
if (this.sharePassword === "") {
this.sharePassword = localStorage.getItem("sharepass:" + this.shareHash);
if (this.sharePassword === null || this.sharePassword === "") {
this.showPasswordPrompt();
return;
}
}
// Store password in localStorage
localStorage.setItem("sharepass:" + this.shareHash, this.sharePassword);
}
if (shareInfo.themeColor) {
document.documentElement.style.setProperty("--primaryColor", shareInfo.themeColor);
}
// Handle password (same for both regular and upload shares)
if (this.sharePassword === null) {
this.sharePassword = "";
}
// For upload shares, validate password on startup and return early
// Password validation happens via fetchPub call, which will throw 401 if incorrect
// A 501 error means browsing is disabled (expected for upload shares) and indicates auth succeeded
if (shareInfo.shareType == "upload") {
// Initialize password validation state
if (shareInfo.hasPassword) {
mutations.setShareData({ passwordValid: false });
try {
await publicApi.fetchPub(this.shareSubPath, this.shareHash, this.sharePassword, false);
// If we get here, password is valid (unlikely for upload shares, but handle it)
mutations.setShareData({ passwordValid: true });
this.error = null; // Clear any previous errors
} catch (e) {
// 501 means browsing is disabled for upload shares - this is expected and means auth succeeded
if (e.status === 501) {
// Password is valid, mark as validated
mutations.setShareData({ passwordValid: true });
this.error = null; // Clear any previous errors
} else if (e.status === 401) {
// Password is invalid, show prompt
this.attemptedPasswordLogin = true;
mutations.setShareData({ passwordValid: false });
this.showPasswordPrompt();
return;
} else {
// For other errors, re-throw to be handled by fetchData()
throw e;
}
}
} else {
// No password required, mark as validated
mutations.setShareData({ passwordValid: true });
this.error = null; // Clear any previous errors
}
return;
}
// For regular shares, validate password on startup (similar to upload shares)
if (shareInfo.hasPassword) {
mutations.setShareData({ passwordValid: false });
try {
await publicApi.fetchPub(this.shareSubPath, this.shareHash, this.sharePassword, false);
// Password is valid
mutations.setShareData({ passwordValid: true });
this.error = null; // Clear any previous errors
} catch (e) {
if (e.status === 401) {
// Password is invalid, show prompt
this.attemptedPasswordLogin = true;
mutations.setShareData({ passwordValid: false });
this.showPasswordPrompt();
return;
} else {
// For other errors, re-throw to be handled by fetchData()
throw e;
}
}
} else {
// No password required, mark as validated
mutations.setShareData({ passwordValid: true });
this.error = null; // Clear any previous errors
}
mutations.resetSelected();
mutations.setMultiple(false);
if (state.shareInfo?.singleFileShare) {
mutations.setSidebarVisible(true);
}
// Initialize password validation state for password-protected shares
if (state.shareInfo?.isPasswordProtected) {
mutations.setShareData({ passwordValid: false });
}
// Fetch share data
let file = await publicApi.fetchPub(this.shareSubPath, this.shareHash, this.sharePassword);
file.hash = this.shareHash;
+2 -1
View File
@@ -181,7 +181,8 @@
</div>
<!-- Upload Share Target -->
<div v-else class="upload-share-embed">
<!-- Only show upload interface if password is validated (or no password required) -->
<div v-else-if="!shareInfo.hasPassword || state.share?.passwordValid" class="upload-share-embed">
<Upload :initialItems="null" />
</div>
</template>