diff --git a/.dockerignore b/.dockerignore
index 44cd24c..c6d499b 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,8 +2,11 @@
.github
docs
node_modules
-backend/filetree
-backend/filetree.exe
+app/web/node_modules
+app/web/dist
+app/uiembed/dist
+app/filetree
+app/filetree.exe
*.md
.env
.env.*
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 90a75ed..82d0cb0 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,7 +1,7 @@
version: 2
updates:
- package-ecosystem: "gomod"
- directory: "/backend"
+ directory: "/app"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
@@ -13,7 +13,7 @@ updates:
open-pull-requests-limit: 5
- package-ecosystem: "npm"
- directory: "/frontend"
+ directory: "/app/web"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 9ea96cf..eefa055 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -4,9 +4,21 @@ Documentation:
Backend:
- changed-files:
- - any-glob-to-any-file: backend/**
+ - any-glob-to-any-file: app/config/**
+ - any-glob-to-any-file: app/handlers/**
+ - any-glob-to-any-file: app/middleware/**
+ - any-glob-to-any-file: app/main.go
+ - any-glob-to-any-file: app/frontend_mount.go
+ - any-glob-to-any-file: app/go.mod
+ - any-glob-to-any-file: app/go.sum
+ - any-glob-to-any-file: app/uiembed/**
Frontend:
- changed-files:
- - any-glob-to-any-file: frontend/**
-
+ - any-glob-to-any-file: app/web/src/**
+ - any-glob-to-any-file: app/web/public/**
+ - any-glob-to-any-file: app/web/index.html
+ - any-glob-to-any-file: app/web/package.json
+ - any-glob-to-any-file: app/web/package-lock.json
+ - any-glob-to-any-file: app/web/vite.config.*
+ - any-glob-to-any-file: app/web/tsconfig*.json
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c4b89bd..e3ac341 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,8 +44,8 @@ jobs:
if: github.event_name == 'pull_request'
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
- backend:
- name: Backend
+ app:
+ name: App (Go)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -54,22 +54,22 @@ jobs:
uses: actions/setup-go@v6
with:
go-version: '1.25'
- cache-dependency-path: backend/go.sum
+ cache-dependency-path: app/go.sum
- name: Build
- run: cd backend && go build -o filetree .
+ run: cd app && go build -o filetree .
- name: Vet
- run: cd backend && go vet ./...
+ run: cd app && go vet ./...
- name: Test
- run: cd backend && go test ./...
+ run: cd app && go test ./...
- name: Lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.10
- working-directory: backend
+ working-directory: app
gosec:
name: Go security scan
@@ -81,12 +81,12 @@ jobs:
uses: actions/setup-go@v6
with:
go-version: '1.25'
- cache-dependency-path: backend/go.sum
+ cache-dependency-path: app/go.sum
- name: Run gosec
run: |
go install github.com/securego/gosec/v2/cmd/gosec@latest
- cd backend && gosec ./...
+ cd app && gosec ./...
frontend:
name: Frontend
@@ -99,24 +99,54 @@ jobs:
with:
node-version: '20'
cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
+ cache-dependency-path: app/web/package-lock.json
- name: Install dependencies
- run: cd frontend && npm ci
+ run: cd app/web && npm ci
- name: Build
- run: cd frontend && npm run build
+ run: cd app/web && npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
- path: frontend/dist
+ path: app/web/dist
+
+ app-embed:
+ name: App (embedded UI)
+ runs-on: ubuntu-latest
+ needs: [frontend]
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: '1.25'
+ cache-dependency-path: app/go.sum
+
+ # Upload stores paths relative to app/web/dist (index.html, assets/...), not app/web/dist/...
+ # Extract directly into app/web/dist so cp below finds the built UI.
+ - name: Download frontend dist
+ uses: actions/download-artifact@v4
+ with:
+ name: frontend-dist
+ path: app/web/dist
+
+ - name: Stage UI for embed
+ run: |
+ rm -rf app/uiembed/dist
+ mkdir -p app/uiembed/dist
+ cp -R app/web/dist/. app/uiembed/dist/
+
+ - name: Build with embedded frontend
+ run: cd app && go build -tags embed -o filetree .
docker-build:
name: Build
runs-on: ubuntu-latest
- needs: [commitlint, backend, gosec, frontend]
+ needs: [commitlint, app, gosec, frontend, app-embed]
steps:
- uses: actions/checkout@v6
@@ -145,12 +175,6 @@ jobs:
steps:
- uses: actions/checkout@v6
- - name: Download frontend artifact
- uses: actions/download-artifact@v4
- with:
- name: frontend-dist
- path: frontend/dist
-
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -197,7 +221,7 @@ jobs:
ci-success:
name: CI success
runs-on: ubuntu-latest
- needs: [commitlint, backend, gosec, frontend, docker-build]
+ needs: [commitlint, app, gosec, frontend, app-embed, docker-build]
steps:
- name: All checks passed
run: echo "All checks passed"
diff --git a/.gitignore b/.gitignore
index b3c74cc..79cedf8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,12 @@
# Go
-backend/filetree
-backend/filetree.exe
-backend/data/
+app/filetree
+app/filetree.exe
+app/data/
-# Node / frontend
+# Node / UI (also legacy paths)
node_modules/
+app/web/dist/
+app/uiembed/dist/
frontend/dist/
# MkDocs
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1849789..23de5aa 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,8 +4,8 @@ Thank you for your interest in contributing.
## Development setup
-1. **Backend:** `cd backend && go mod tidy && go run .`
-2. **Frontend:** `cd frontend && npm install && npm run dev`
+1. **Go (API):** `cd app && go mod tidy && go run .`
+2. **UI:** `cd app/web && npm install && npm run dev`
3. Set `CONFIG_FILE=./config.yaml` and configure auth (see `config.example.yaml`).
## Commit messages
@@ -21,9 +21,10 @@ Validate locally: `make commitlint` or `npx commitlint --last --verbose`
## Pull requests
1. Create a branch from `main`.
-2. Ensure `go build ./... && go vet ./... && go test ./...` passes in `backend`.
-3. Ensure `npm run build` passes in `frontend`.
-4. Open a PR with a clear description of the change.
+2. Ensure `go build ./... && go vet ./... && go test ./...` passes in `app`.
+3. Ensure `npm run build` passes in `app/web`.
+4. Optional: `make build` verifies the full embedded binary (frontend + `go build -tags embed`).
+5. Open a PR with a clear description of the change.
## Code style
@@ -32,7 +33,7 @@ Validate locally: `make commitlint` or `npx commitlint --last --verbose`
## Go score & quality
-CI runs these checks on the backend:
+CI runs these checks on the Go module under `app/`:
| Tool | Purpose |
|------|---------|
@@ -45,10 +46,10 @@ CI runs these checks on the backend:
Run locally:
```bash
-cd backend
+cd app
go build ./... && go vet ./... && go test ./...
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest && golangci-lint run
go install github.com/securego/gosec/v2/cmd/gosec@latest && gosec ./...
```
-If you fork the repo, update the Go Report Card badge in the README to point to your fork (e.g. `github.com/YOUR_ORG/filetree`) to see the score at [goreportcard.com](https://goreportcard.com).
+If you fork the repo, update the Go Report Card badge in the README to point to your fork module path (e.g. `github.com/YOUR_ORG/filetree/app`) to see the score at [goreportcard.com](https://goreportcard.com).
diff --git a/Containerfile b/Containerfile
index 5f89fd5..3fdb12d 100644
--- a/Containerfile
+++ b/Containerfile
@@ -1,22 +1,24 @@
FROM node:20-alpine AS frontend
-WORKDIR /app/frontend
-COPY frontend/ ./
+WORKDIR /app/web
+COPY app/web/package.json app/web/package-lock.json ./
ENV NODE_OPTIONS="--max-old-space-size=4096"
-RUN if [ ! -f dist/index.html ]; then npm ci && npm run build; fi
+RUN npm ci
+COPY app/web/ ./
+RUN npm run build
FROM golang:1.25-alpine AS backend
WORKDIR /app
-COPY backend/go.mod backend/go.sum ./
+COPY app/go.mod app/go.sum ./
RUN go mod download
-COPY backend/ ./
-RUN CGO_ENABLED=0 go build -o filetree .
+COPY app/ ./
+COPY --from=frontend /app/web/dist ./uiembed/dist
+RUN CGO_ENABLED=0 go build -tags embed -o filetree .
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=backend /app/filetree .
-COPY --from=frontend /app/frontend/dist ./frontend/dist
EXPOSE 8080
diff --git a/Makefile b/Makefile
index 183dfd3..777bd5a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,37 +1,41 @@
-.PHONY: build build-backend build-frontend test commitlint clean run-backend run-frontend
+.PHONY: build all build-app build-frontend embed-ui build-app-embed test commitlint clean run run-frontend
-# Build Go binary (output: backend/filetree or backend/filetree.exe)
-build-backend:
- cd backend && go build -o filetree .
+# One binary with embedded UI: Vite → app/web/dist → app/uiembed/dist → go build -tags embed
+build: embed-ui
+ cd app && go build -tags embed -o filetree .
-# Build frontend (output: frontend/dist/)
-build-frontend:
- cd frontend && npm run build
+all: build
+
+# Same as build (kept for scripts and docs that name it explicitly)
+build-app-embed: build
-# Build both backend binary and frontend assets
-all: build-backend build-frontend
+# Go only, no embed (dev: skip npm when UI unchanged; serve from app/web/dist on disk if present)
+build-app:
+ cd app && go build -o filetree .
+
+# Frontend (output: app/web/dist/)
+build-frontend:
+ cd app/web && npm run build
-# Alias
-build: all
+# Stage static files for go:embed (used by build). Prereqs build-frontend so `make -j` cannot copy before Vite finishes.
+embed-ui: build-frontend
+ rm -rf app/uiembed/dist
+ mkdir -p app/uiembed/dist
+ cp -R app/web/dist/. app/uiembed/dist/
-# Validate last commit message (Conventional Commits)
commitlint:
npx commitlint --last --verbose
-# Run tests
test:
- cd backend && go vet ./... && go test ./...
- cd frontend && npm run build
+ cd app && go vet ./... && go test ./...
+ cd app/web && npm run build
-# Run Go backend (dev)
run:
- ./backend/filetree
+ ./app/filetree
-# Run frontend dev server (proxies /api to backend)
run-frontend:
- cd frontend && npm run dev
+ cd app/web && npm run dev
-# Remove build artifacts
clean:
- rm -f backend/filetree backend/filetree.exe
- rm -rf frontend/dist
+ rm -f app/filetree app/filetree.exe
+ rm -rf app/web/dist app/uiembed/dist frontend/dist
diff --git a/README.md b/README.md
index e4c2698..2052442 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,8 @@
-
-
+
+
@@ -70,21 +70,25 @@ If the package is private, run `docker login ghcr.io` before `docker pull`.
### Production binary
+**Production binary** — `make build` runs the Vite production build, copies output into `app/uiembed/dist` for `go:embed`, and compiles **one** `app/filetree` binary with the UI embedded (install UI deps once: `cd app/web && npm ci`):
+
```bash
git clone https://github.com/heapoftrash/filetree.git
make build
-ROOT_PATH=/path/to/files CONFIG_FILE=./config.yaml ./backend/filetree
+ROOT_PATH=/path/to/files CONFIG_FILE=./config.yaml ./app/filetree
```
-Ship `backend/filetree` and `frontend/dist`. The binary serves the built UI when `frontend/dist` is present.
+The **prebuilt container image** already uses an embedded UI (no separate static bundle directory in the image).
+
+**UI on disk (optional)** — `make build-app` (Go only) plus `make build-frontend` if you want `app/web/dist` on disk; run `./app/filetree` from the repo root and the server will pick up `app/web/dist`, `app/uiembed/dist`, or cwd-relative paths (see [documentation](https://heapoftrash.github.io/filetree/)).
See [docs/API.md](docs/API.md) for the API. Build docs locally: `pip install mkdocs-material && mkdocs serve`.
### Development
-**API** — `cd backend && go mod tidy && go run .` → **http://localhost:8080**
+**API** — `cd app && go mod tidy && go run .` → **http://localhost:8080**
-**UI** — `cd frontend && npm install && npm run dev` → **http://localhost:5173** (proxies `/api` to the backend)
+**UI** — `cd app/web && npm install && npm run dev` → **http://localhost:5173** (proxies `/api` to the API)
Set `frontend.url` in config to `http://localhost:5173` for CORS and OAuth.
diff --git a/backend/config/config.go b/app/config/config.go
similarity index 94%
rename from backend/config/config.go
rename to app/config/config.go
index f847841..092e7bf 100644
--- a/backend/config/config.go
+++ b/app/config/config.go
@@ -41,19 +41,19 @@ type Config struct {
Auth AuthConfig `yaml:"auth" json:"auth"`
Frontend FrontendConfig `yaml:"frontend" json:"frontend"`
Users UsersConfig `yaml:"users" json:"users"`
- ConfigPath string `yaml:"-" json:"-"` // path we loaded from, for write-back
+ ConfigPath string `yaml:"-" json:"-"` // path we loaded from, for write-back
}
type ServerConfig struct {
RootPath string `yaml:"root_path" json:"root_path"`
- Debug bool `yaml:"debug" json:"debug"` // when false, Gin runs in release mode (no debug logs)
- MaxUploadBytes int64 `yaml:"max_upload_bytes" json:"max_upload_bytes"` // max multipart upload size; 0 = default 100MB
+ Debug bool `yaml:"debug" json:"debug"` // when false, Gin runs in release mode (no debug logs)
+ MaxUploadBytes int64 `yaml:"max_upload_bytes" json:"max_upload_bytes"` // max multipart upload size; 0 = default 100MB
}
type AuthConfig struct {
- JWTSecret string `yaml:"jwt_secret" json:"jwt_secret"`
- OAuthRedirectURL string `yaml:"oauth_redirect_url" json:"oauth_redirect_url"`
- LocalAuthEnabled bool `yaml:"local_auth_enabled" json:"local_auth_enabled"` // username/password auth
+ JWTSecret string `yaml:"jwt_secret" json:"jwt_secret"`
+ OAuthRedirectURL string `yaml:"oauth_redirect_url" json:"oauth_redirect_url"`
+ LocalAuthEnabled bool `yaml:"local_auth_enabled" json:"local_auth_enabled"` // username/password auth
Providers map[string]ProviderConfig `yaml:"providers" json:"providers"`
}
@@ -70,8 +70,8 @@ type FrontendConfig struct {
}
type UsersConfig struct {
- AdminEmails []string `yaml:"admin_emails" json:"admin_emails"`
- LocalUsers []LocalUser `yaml:"local_users" json:"local_users"`
+ AdminEmails []string `yaml:"admin_emails" json:"admin_emails"`
+ LocalUsers []LocalUser `yaml:"local_users" json:"local_users"`
DefaultAdmin *DefaultAdminUser `yaml:"default_admin" json:"default_admin"`
}
diff --git a/backend/config/registry.go b/app/config/registry.go
similarity index 100%
rename from backend/config/registry.go
rename to app/config/registry.go
diff --git a/app/frontend_mount.go b/app/frontend_mount.go
new file mode 100644
index 0000000..cffcead
--- /dev/null
+++ b/app/frontend_mount.go
@@ -0,0 +1,101 @@
+package main
+
+import (
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "github.com/heapoftrash/filetree/app/uiembed"
+)
+
+func mountFrontend(r *gin.Engine) {
+ if mountEmbeddedFrontend(r) {
+ return
+ }
+ mountDiskFrontend(r)
+}
+
+func mountEmbeddedFrontend(r *gin.Engine) bool {
+ distFS, ok := uiembed.DistRoot()
+ if !ok {
+ return false
+ }
+ attachDistRoutes(r, distFS)
+ log.Println("Serving embedded frontend")
+ return true
+}
+
+// attachDistRoutes serves a Vite-style dist tree (index.html, assets/, optional root SVGs) for tests and embed.
+func attachDistRoutes(r *gin.Engine, distFS fs.FS) {
+ httpDist := http.FS(distFS)
+ if assetsFS, err := fs.Sub(distFS, "assets"); err == nil {
+ r.StaticFS("/assets", http.FS(assetsFS))
+ }
+ for _, name := range []string{"favicon.svg", "icon-light.svg"} {
+ n := name
+ if _, err := fs.Stat(distFS, n); err != nil {
+ continue
+ }
+ r.GET("/"+n, func(c *gin.Context) {
+ c.FileFromFS(n, httpDist)
+ })
+ }
+ // Do not use FileFromFS/StaticFile*/FileServer for index.html: Gin sets r.URL.Path to end with
+ // "/index.html", and net/http.FileServer then returns 301 Location: "./" (see net/http fs.go).
+ serveIndex := func(c *gin.Context) {
+ serveIndexHTML(c, distFS)
+ }
+ r.GET("/", serveIndex)
+ r.HEAD("/", serveIndex)
+ r.NoRoute(func(c *gin.Context) {
+ serveIndexHTML(c, distFS)
+ })
+}
+
+func mountDiskFrontend(r *gin.Engine) {
+ var frontendDir string
+ for _, p := range []string{"./app/web/dist", "./app/uiembed/dist", "./web/dist", "./uiembed/dist"} {
+ if _, err := os.Stat(p + "/index.html"); err == nil {
+ frontendDir = p
+ break
+ }
+ }
+ if frontendDir == "" {
+ return
+ }
+ r.Static("/assets", frontendDir+"/assets")
+ if _, err := os.Stat(frontendDir + "/favicon.svg"); err == nil {
+ r.StaticFile("/favicon.svg", frontendDir+"/favicon.svg")
+ }
+ if _, err := os.Stat(frontendDir + "/icon-light.svg"); err == nil {
+ r.StaticFile("/icon-light.svg", frontendDir+"/icon-light.svg")
+ }
+ indexPath := frontendDir + "/index.html"
+ serveIndex := func(c *gin.Context) {
+ c.File(indexPath)
+ }
+ r.GET("/", serveIndex)
+ r.HEAD("/", serveIndex)
+ r.NoRoute(func(c *gin.Context) {
+ c.File(indexPath)
+ })
+ log.Printf("Serving frontend from disk (%s)\n", frontendDir)
+}
+
+func serveIndexHTML(c *gin.Context, distFS fs.FS) {
+ b, err := fs.ReadFile(distFS, "index.html")
+ if err != nil {
+ c.Status(http.StatusNotFound)
+ return
+ }
+ c.Header("Content-Type", "text/html; charset=utf-8")
+ if c.Request.Method == http.MethodHead {
+ c.Header("Content-Length", strconv.Itoa(len(b)))
+ c.Status(http.StatusOK)
+ return
+ }
+ c.Data(http.StatusOK, "text/html; charset=utf-8", b)
+}
diff --git a/app/frontend_mount_test.go b/app/frontend_mount_test.go
new file mode 100644
index 0000000..c3456b8
--- /dev/null
+++ b/app/frontend_mount_test.go
@@ -0,0 +1,122 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestServeIndexHTMLNoRedirect(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ dist := fstest.MapFS{
+ "index.html": &fstest.MapFile{Data: []byte("x")},
+ }
+ for _, method := range []string{http.MethodGet, http.MethodHead} {
+ t.Run(method, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(method, "http://example.com/", nil)
+ serveIndexHTML(c, dist)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: %d", w.Code)
+ }
+ if loc := w.Header().Get("Location"); loc != "" {
+ t.Fatalf("unexpected redirect Location %q (FileServer index.html bug)", loc)
+ }
+ if method == http.MethodGet && w.Body.Len() == 0 {
+ t.Fatal("expected body for GET")
+ }
+ })
+ }
+}
+
+func TestAttachDistRoutes_AssetsAvailable(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ dist := fstest.MapFS{
+ "index.html": &fstest.MapFile{Data: []byte(``)},
+ "assets/main-abc.js": &fstest.MapFile{Data: []byte("export default function () {}")},
+ "favicon.svg": &fstest.MapFile{Data: []byte("")},
+ "icon-light.svg": &fstest.MapFile{Data: []byte("")},
+ }
+ r := gin.New()
+ attachDistRoutes(r, dist)
+
+ t.Run("GET asset", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/assets/main-abc.js", nil)
+ r.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: %d body=%q", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "export default") {
+ t.Fatalf("unexpected body: %q", w.Body.String())
+ }
+ })
+
+ t.Run("GET favicon", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/favicon.svg", nil))
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: %d", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "