mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-08 10:25:32 -05:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2995e0c4d9 | ||
|
|
84b3b8ebd2 | ||
|
|
28f067f808 |
No files matched your search
@@ -80,6 +80,11 @@ var heifImageExts = map[string]bool{
|
||||
|
||||
const heicDecodeMaxEdge = 16384
|
||||
|
||||
// stillImageMaxSourcePixels caps the original image area (width×height) passed to
|
||||
// go-media for HEIC, JPEG XL, and camera RAW decode. JPEG XL honors it today
|
||||
// (~50 MiB of 8-bit RGB at 4096×4096); other still formats ignore the field.
|
||||
const stillImageMaxSourcePixels = 4096 * 4096
|
||||
|
||||
// cameraRawExts match go-media rawpreview formats (also iteminfo raw extensions).
|
||||
var cameraRawExts = map[string]bool{
|
||||
".cr2": true, ".cr3": true, ".nef": true, ".nrw": true, ".arw": true, ".srf": true, ".sr2": true,
|
||||
@@ -628,10 +633,11 @@ func stillImageJPEG(path string, maxW, maxH, quality int, op string, supported f
|
||||
quality = 85
|
||||
}
|
||||
res, err := media.Thumbnail(f, size, media.ThumbnailOptions{
|
||||
MaxWidth: maxW,
|
||||
MaxHeight: maxH,
|
||||
Format: "jpeg",
|
||||
Quality: quality,
|
||||
MaxWidth: maxW,
|
||||
MaxHeight: maxH,
|
||||
Format: "jpeg",
|
||||
Quality: quality,
|
||||
MaxSourcePixels: stillImageMaxSourcePixels,
|
||||
})
|
||||
if err != nil {
|
||||
logFail(err)
|
||||
|
||||
@@ -2,11 +2,13 @@ package gomedia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/go-media/pkg/media"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -66,3 +68,24 @@ func TestJXLJPEGMissingFileReturnsFalse(t *testing.T) {
|
||||
require.False(t, ok)
|
||||
require.Empty(t, data)
|
||||
}
|
||||
|
||||
// JPEG XL above stillImageMaxSourcePixels must fail with go-media's ErrUnsupported
|
||||
// and return ok=false (no ffmpeg fallback).
|
||||
func TestJXLJPEGRejectsOversizedSource(t *testing.T) {
|
||||
withGate(t, false)
|
||||
f, err := os.Open(fixtureJXL)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = media.Thumbnail(f, info.Size(), media.ThumbnailOptions{
|
||||
MaxWidth: 256,
|
||||
MaxHeight: 256,
|
||||
Format: "jpeg",
|
||||
Quality: 85,
|
||||
MaxSourcePixels: 1,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, media.ErrUnsupported), "oversized JXL must surface ErrUnsupported, got %v", err)
|
||||
}
|
||||
@@ -325,6 +325,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"playwrightTest": settings.Env.IsPlaywright,
|
||||
"officeProduct": officeProductForSPA(),
|
||||
"officeExtensions": wopiExtensionsForSPA(),
|
||||
"textPreviewMimeTypes": iteminfo.TextPreviewMimeTypes(),
|
||||
}
|
||||
|
||||
cspNonce, err := utils.CSPNonce()
|
||||
|
||||
@@ -271,57 +271,57 @@ var compressedFile = map[string]bool{
|
||||
|
||||
// Raw camera image: extension -> MIME type (single source for IsRawImage + type detection)
|
||||
var rawImageMimeType = map[string]string{
|
||||
".cr2": "image/x-canon-cr2",
|
||||
".cr3": "image/x-canon-cr3",
|
||||
".nef": "image/x-nikon-nef",
|
||||
".nrw": "image/x-nikon-nef",
|
||||
".arw": "image/x-sony-arw",
|
||||
".srf": "image/x-sony-arw",
|
||||
".sr2": "image/x-sony-arw",
|
||||
".orf": "image/x-olympus-orf",
|
||||
".rw2": "image/x-panasonic-rw2",
|
||||
".raw": "image/x-panasonic-raw",
|
||||
".dng": "image/x-adobe-dng",
|
||||
".raf": "image/x-fuji-raf",
|
||||
".pef": "image/x-pentax-pef",
|
||||
".ptx": "image/x-pentax-pef",
|
||||
".rwl": "image/x-leica-rwl",
|
||||
".3fr": "image/x-hasselblad-3fr",
|
||||
".fff": "image/x-hasselblad-fff",
|
||||
".erf": "image/x-epson-erf",
|
||||
".mrw": "image/x-minolta-mrw",
|
||||
".dcr": "image/x-kodak-dcr",
|
||||
".kdc": "image/x-kodak-dcr",
|
||||
".dc2": "image/x-kodak-dc2",
|
||||
".x3f": "image/x-sigma-x3f",
|
||||
".iiq": "image/x-phaseone-iiq",
|
||||
".nkc": "image/x-kodak-nkc",
|
||||
".r3d": "image/x-red-r3d",
|
||||
".cr2": "image/x-canon-cr2",
|
||||
".cr3": "image/x-canon-cr3",
|
||||
".nef": "image/x-nikon-nef",
|
||||
".nrw": "image/x-nikon-nef",
|
||||
".arw": "image/x-sony-arw",
|
||||
".srf": "image/x-sony-arw",
|
||||
".sr2": "image/x-sony-arw",
|
||||
".orf": "image/x-olympus-orf",
|
||||
".rw2": "image/x-panasonic-rw2",
|
||||
".raw": "image/x-panasonic-raw",
|
||||
".dng": "image/x-adobe-dng",
|
||||
".raf": "image/x-fuji-raf",
|
||||
".pef": "image/x-pentax-pef",
|
||||
".ptx": "image/x-pentax-pef",
|
||||
".rwl": "image/x-leica-rwl",
|
||||
".3fr": "image/x-hasselblad-3fr",
|
||||
".fff": "image/x-hasselblad-fff",
|
||||
".erf": "image/x-epson-erf",
|
||||
".mrw": "image/x-minolta-mrw",
|
||||
".dcr": "image/x-kodak-dcr",
|
||||
".kdc": "image/x-kodak-dcr",
|
||||
".dc2": "image/x-kodak-dc2",
|
||||
".x3f": "image/x-sigma-x3f",
|
||||
".iiq": "image/x-phaseone-iiq",
|
||||
".nkc": "image/x-kodak-nkc",
|
||||
".r3d": "image/x-red-r3d",
|
||||
}
|
||||
|
||||
// 3D model extension -> MIME type (single source for Is3DModel + type detection)
|
||||
var model3DMimeType = map[string]string{
|
||||
".gltf": "model/gltf+json",
|
||||
".glb": "model/gltf-binary",
|
||||
".obj": "model/obj",
|
||||
".fbx": "model/fbx",
|
||||
".stl": "model/stl",
|
||||
".ply": "model/ply",
|
||||
".dae": "model/vnd.collada+xml",
|
||||
".3mf": "model/3mf",
|
||||
".3ds": "model/3ds",
|
||||
".usdz": "model/vnd.usdz+zip",
|
||||
".usd": "model/vnd.usd+zip",
|
||||
".usda": "model/vnd.usd+zip",
|
||||
".usdc": "model/vnd.usd+zip",
|
||||
".amf": "model/x-amf",
|
||||
".vrml": "model/vrml",
|
||||
".wrl": "model/vrml",
|
||||
".vtk": "model/vtk",
|
||||
".vtp": "model/vtk",
|
||||
".pcd": "model/pcd",
|
||||
".xyz": "model/xyz",
|
||||
".vox": "model/vox",
|
||||
".gltf": "model/gltf+json",
|
||||
".glb": "model/gltf-binary",
|
||||
".obj": "model/obj",
|
||||
".fbx": "model/fbx",
|
||||
".stl": "model/stl",
|
||||
".ply": "model/ply",
|
||||
".dae": "model/vnd.collada+xml",
|
||||
".3mf": "model/3mf",
|
||||
".3ds": "model/3ds",
|
||||
".usdz": "model/vnd.usdz+zip",
|
||||
".usd": "model/vnd.usd+zip",
|
||||
".usda": "model/vnd.usd+zip",
|
||||
".usdc": "model/vnd.usd+zip",
|
||||
".amf": "model/x-amf",
|
||||
".vrml": "model/vrml",
|
||||
".wrl": "model/vrml",
|
||||
".vtk": "model/vtk",
|
||||
".vtp": "model/vtk",
|
||||
".pcd": "model/pcd",
|
||||
".xyz": "model/xyz",
|
||||
".vox": "model/vox",
|
||||
}
|
||||
|
||||
var audioMetadataTypes = map[string]bool{
|
||||
@@ -560,10 +560,41 @@ func TextPreviewKind(ext string) string {
|
||||
return textPreviewKinds[ext]
|
||||
}
|
||||
|
||||
// textPreviewMimeTypes are non-text/* mimetypes that textTypes extensions resolve to
|
||||
// (see the custom table in backend/internal/adapters/fs/files/mime.go). The frontend
|
||||
// receives this list through globalVars (backend/internal/web/static.go), so it is
|
||||
// the single source of truth.
|
||||
// Do not add mimetypes shared with binaries (e.g. application/x-msdownload for .exe).
|
||||
var textPreviewMimeTypes = map[string]bool{
|
||||
"application/json": true,
|
||||
"application/rtf": true,
|
||||
"application/yaml": true,
|
||||
"application/x-yaml": true,
|
||||
"application/sql": true,
|
||||
"application/x-sql": true,
|
||||
"application/x-sh": true,
|
||||
"application/x-php": true,
|
||||
"application/x-powershell": true,
|
||||
"application/x-tex": true,
|
||||
"application/toml": true,
|
||||
"application/x-ruby": true,
|
||||
}
|
||||
|
||||
// IsTextPreviewable reports whether an item uses the text preview endpoint. The decision
|
||||
// is the item's mimetype: every text/* type is previewable, and everything else is not.
|
||||
// is the item's mimetype: every text/* type and the textPreviewMimeTypes allowlist.
|
||||
func IsTextPreviewable(mimetype string) bool {
|
||||
return strings.HasPrefix(mimetype, "text/")
|
||||
return strings.HasPrefix(mimetype, "text/") || textPreviewMimeTypes[mimetype]
|
||||
}
|
||||
|
||||
// TextPreviewMimeTypes returns the non-text/* allowlist in sorted order, for the
|
||||
// frontend's globalVars. Sorting keeps the served value stable across restarts.
|
||||
func TextPreviewMimeTypes() []string {
|
||||
types := make([]string, 0, len(textPreviewMimeTypes))
|
||||
for mt := range textPreviewMimeTypes {
|
||||
types = append(types, mt)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// TextPreviewRenderKind returns the renderer kind for a text-previewable file: the
|
||||
@@ -573,4 +604,4 @@ func TextPreviewRenderKind(name string) string {
|
||||
return kind
|
||||
}
|
||||
return "plain"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package iteminfo
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -24,7 +25,11 @@ func TestIsTextPreviewable(t *testing.T) {
|
||||
for _, mt := range []string{"text/plain", "text/markdown", "text/lyrics", "text/x-go", "text/html"} {
|
||||
require.True(t, IsTextPreviewable(mt), mt)
|
||||
}
|
||||
for _, mt := range []string{"", "image/svg+xml", "application/pdf", "application/json", "application/x-sqlite3", "application/postscript", "application/backup", "application/wasm"} {
|
||||
for _, mt := range []string{"application/json", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex", "application/rtf", "text/rtf"} {
|
||||
require.True(t, IsTextPreviewable(mt), mt)
|
||||
}
|
||||
// Mimetypes shared with binaries (.exe, .bat via application/x-msdownload) must stay non-previewable.
|
||||
for _, mt := range []string{"", "image/svg+xml", "application/pdf", "application/x-sqlite3", "application/postscript", "application/backup", "application/wasm", "application/x-msdownload", "application/x-msdos-program", "application/octet-stream"} {
|
||||
require.False(t, IsTextPreviewable(mt), mt)
|
||||
}
|
||||
}
|
||||
@@ -34,3 +39,13 @@ func TestTextPreviewRenderKind(t *testing.T) {
|
||||
require.Equal(t, "plain", TextPreviewRenderKind("50. Holy Water.lrc"))
|
||||
require.Equal(t, "plain", TextPreviewRenderKind("cert.pem"))
|
||||
}
|
||||
|
||||
func TestTextPreviewMimeTypesIsSortedAndMatchesAllowlist(t *testing.T) {
|
||||
types := TextPreviewMimeTypes()
|
||||
require.NotEmpty(t, types)
|
||||
require.True(t, sort.StringsAreSorted(types), "list must be sorted for a stable globalVars value")
|
||||
for _, mt := range types {
|
||||
require.True(t, IsTextPreviewable(mt), mt)
|
||||
}
|
||||
require.Contains(t, types, "application/json")
|
||||
}
|
||||
@@ -295,6 +295,8 @@ const PROFILE_SNAPSHOT_FIELDS = [
|
||||
"debugOffice",
|
||||
"viewMode",
|
||||
"gallerySize",
|
||||
"promptRightCloseButton",
|
||||
"newFileTemplate",
|
||||
];
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The backend serves the text allowlist through globalVars (backend/internal/web/static.go).
|
||||
const { globalsMock } = vi.hoisted(() => ({
|
||||
globalsMock: {
|
||||
textPreviewMimeTypes: [
|
||||
"application/json",
|
||||
"application/rtf",
|
||||
"application/yaml",
|
||||
"application/x-yaml",
|
||||
"application/sql",
|
||||
"application/x-sql",
|
||||
"application/x-sh",
|
||||
"application/x-php",
|
||||
"application/x-powershell",
|
||||
"application/x-tex",
|
||||
"application/toml",
|
||||
"application/x-ruby",
|
||||
] as string[],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/constants.js", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
globalVars: globalsMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
clearTextPreviewCache,
|
||||
fetchTextPreview,
|
||||
@@ -40,8 +66,31 @@ describe("isTextPreviewableType", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("is true for text-extension mimetypes that are not text/*", () => {
|
||||
for (const mt of ["application/json", "application/rtf", "application/yaml", "application/sql", "application/x-sh", "application/x-php", "application/x-powershell", "application/x-tex"]) {
|
||||
expect(isTextPreviewableType(mt), mt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is true for RTF mimetypes", () => {
|
||||
for (const mt of ["application/rtf", "text/rtf", "application/rtf; charset=windows-1252"]) {
|
||||
expect(isTextPreviewableType(mt), mt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("follows the allowlist served in globalVars", () => {
|
||||
const original = globalsMock.textPreviewMimeTypes;
|
||||
try {
|
||||
globalsMock.textPreviewMimeTypes = ["application/x-custom"];
|
||||
expect(isTextPreviewableType("application/x-custom")).toBe(true);
|
||||
expect(isTextPreviewableType("application/json")).toBe(false);
|
||||
} finally {
|
||||
globalsMock.textPreviewMimeTypes = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("is false for non-text mimetypes and missing types", () => {
|
||||
for (const mt of ["application/x-sqlite3", "application/postscript", "application/wasm", "application/backup", "image/svg+xml", "image/png", "directory", "", null, undefined]) {
|
||||
for (const mt of ["application/x-sqlite3", "application/postscript", "application/wasm", "application/backup", "application/x-msdownload", "application/octet-stream", "image/svg+xml", "image/png", "directory", "", null, undefined]) {
|
||||
expect(isTextPreviewableType(mt), String(mt)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { globalVars } from "@/utils/constants.js";
|
||||
import { state } from "@/store/index.ts";
|
||||
import type { TextPreviewPayload } from "@/utils/textPreviewRender.ts";
|
||||
|
||||
@@ -26,13 +27,29 @@ export function languageFromPath(path: string | null | undefined): string | null
|
||||
return name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
// Non-text/* mimetypes (JSON, YAML, SQL, shell, ...) served by the backend in globalVars.
|
||||
// The backend is the single source of truth; the Set is rebuilt only when the served array changes.
|
||||
let allowlistSource: string[] | undefined;
|
||||
let allowlistSet = new Set<string>();
|
||||
|
||||
function textPreviewMimeTypes(): Set<string> {
|
||||
const source = globalVars?.textPreviewMimeTypes as string[] | undefined;
|
||||
if (source !== allowlistSource) {
|
||||
allowlistSource = source;
|
||||
allowlistSet = new Set(source ?? []);
|
||||
}
|
||||
return allowlistSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an item's mimetype is text/*. The backend uses the same rule, so an item the
|
||||
* listing marks as text is the one the text preview endpoint renders. Binary types such as
|
||||
* sqlite, postscript, wasm and backup are not text/* and never reach the endpoint.
|
||||
* True when an item's mimetype is text/* or on the backend's text allowlist. The backend uses
|
||||
* the same rule, so an item the listing marks as text is the one the text preview endpoint
|
||||
* renders. Binary types such as sqlite, postscript, wasm and backup never reach the endpoint.
|
||||
*/
|
||||
export function isTextPreviewableType(mimetype: string | null | undefined): boolean {
|
||||
return (mimetype ?? "").startsWith("text/");
|
||||
// split always yields at least one element; the ?? "" satisfies noUncheckedIndexedAccess.
|
||||
const base = (mimetype ?? "").split(";")[0] ?? "";
|
||||
return base.startsWith("text/") || textPreviewMimeTypes().has(base);
|
||||
}
|
||||
|
||||
// Text fetches are queued so a folder full of text files does not fire dozens of
|
||||
|
||||
Reference in new issue
Block a user