Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: build

on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:

env:
VERSION_MAJOR: 0
VERSION_MINOR: 1

jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: generate version
id: version
shell: bash
run: |
REVISION=${{ github.run_number }}
VERSION="${{ env.VERSION_MAJOR }}.${{ env.VERSION_MINOR }}.${REVISION}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Generated version: ${VERSION}"

build:
needs: version
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: checkout
uses: actions/checkout@v4

- name: setup golang
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
cache: true

# templ sources (*.templ) are not committed; regenerate the *_templ.go
# files before tests and build. Pinned to the version in go.mod.
- name: generate templ sources
run: |
go install github.com/a-h/templ/cmd/templ@$(go list -m -f '{{.Version}}' github.com/a-h/templ)
templ generate

- name: run tests
run: |
CGO_ENABLED=0 go test ./...

- name: build project
run: |
go mod download

mkdir -p dist

VERSION="${{ needs.version.outputs.version }}"
LD_FLAGS="-X 'main.Version=${VERSION}'"

echo "Building linux binary (version: ${VERSION})"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LD_FLAGS}" -o "dist/tdrive-linux-amd64" .
tar -czvf "dist/tdrive-linux-amd64.tar.gz" -C dist "tdrive-linux-amd64"

echo "Building windows binary (version: ${VERSION})"
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "${LD_FLAGS}" -o "dist/tdrive-windows-amd64.exe" .
(cd dist && zip "tdrive-windows-amd64.zip" "tdrive-windows-amd64.exe")

- name: create release
uses: softprops/action-gh-release@v1
if: github.ref == 'refs/heads/master'
with:
tag_name: v${{ needs.version.outputs.version }}
generate_release_notes: true
draft: false
prerelease: false
files: |
dist/tdrive-linux-amd64.tar.gz
dist/tdrive-windows-amd64.zip
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# ---> Go
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test binary, built with `go test -c`
*.test

# Output of the go coverage tool, specifically when used with LiteIDE
*.out

/.idea
/.vscode
/dist
/gen
/temp

# templ generated sources (regenerated by `templ generate`, never committed)
*_templ.go
32 changes: 32 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Build stage: compile a static binary.
FROM golang:1.25-alpine AS build

WORKDIR /src

# Cache module downloads.
COPY go.mod go.sum ./
RUN go mod download

# Build (templ output is committed, so no codegen step is needed here).
COPY . .
ARG VERSION=docker
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${VERSION}" -o /out/tdrive .

# Runtime stage: minimal image, non-root, persistent data volume.
FROM alpine:3.20

RUN apk add --no-cache ca-certificates && \
adduser -D -u 10001 tdrive && \
mkdir -p /data && chown tdrive:tdrive /data

COPY --from=build /out/tdrive /usr/local/bin/tdrive

USER tdrive
WORKDIR /data
VOLUME ["/data"]

EXPOSE 3000

# All configuration is via CLI flags; override CMD to change port, enable FTP, etc.
ENTRYPOINT ["tdrive"]
CMD ["/data"]
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,47 @@
# tdrive
A single-user, self-hosted web file disk over HTTP, WebDAV, and FTP

A single-user, self-hosted web file disk. Files live on the filesystem and are
served over **HTTP**, **WebDAV**, and **FTP** from one static binary — no
database, no config file, no frontend build.

![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white)
![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS-555)
![Access](https://img.shields.io/badge/access-HTTP%20%C2%B7%20FTP%20%C2%B7%20WebDAV-success)
![Database](https://img.shields.io/badge/database-none-brightgreen)

![tdrive file browser](docs/screenshot.png)

## Features

- Web file browser (templ + htmx, no SPA): instant filter, click-to-sort, list/grid toggle, image thumbnails
- Upload via toolbar or drag-and-drop (chunked for large files); streaming downloads with HTTP range support
- View **and edit** text files; images, Markdown (GFM, Mermaid, KaTeX, code highlight), and HTML rendered inline
- Desktop-style selection, right-click menu (open / download / copy raw URL / rename / delete), keyboard shortcuts
- Multi-select batch delete and drag-and-drop move; create folders and text files inline, no modals
- Static URL for every file at `/raw/<path>`; nginx-style autoindex for folders
- Same files over HTTP, FTP (`--ftp-port`), and WebDAV (`/webdav`); REST API at `/api/v1`
- Path-safe by construction via Go `os.Root` — symlinks cannot escape the data directory
- Optional password protection, read-only mode, 中文 / English UI
- Native Windows service; clean SIGINT/SIGTERM shutdown for systemd / Docker

## Usage

```sh
tdrive # serve the current directory on http://localhost:3000
tdrive /srv/files # serve a specific directory
tdrive --port 8080 # change the HTTP port
tdrive --host 127.0.0.1 # bind to one interface only
tdrive --ftp-port 2121 # also enable FTP
tdrive --password secret # require a password
tdrive --readonly # browse & download only
```

| Flag | Meaning | Default |
|---|---|---|
| `[root]` (positional) | directory to serve | `.` (current directory) |
| `--host` | bind address for HTTP and FTP | all interfaces |
| `--port` | HTTP port | `3000` |
| `--ftp-port` | enable the FTP server on this port | off |
| `--password` | access password (empty = no auth) | empty |
| `--readonly` | block all uploads, edits, and deletes | off |
| `--verbose` | debug logging | off |
44 changes: 44 additions & 0 deletions cmd/base_command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cmd

import (
"log/slog"
"os"

"github.com/spf13/cobra"
)

type BaseCommand struct {
cobra.Command
versionInfo *VersionInfo
}

func (command *BaseCommand) setupLogger(verbose bool) {

var level slog.Level

var logger *slog.Logger

if verbose {

level = slog.LevelDebug
} else {

level = slog.LevelInfo
}

logger = slog.New(
slog.NewTextHandler(
os.Stdout,
&slog.HandlerOptions{
AddSource: verbose,
Level: level,
}),
)

slog.SetDefault(logger)

slog.Info("logger init ok",
slog.Bool("verbose", verbose),
slog.String("version", command.versionInfo.Version),
)
}
90 changes: 90 additions & 0 deletions cmd/root_command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

"tdrive/internal/app"
"tdrive/internal/config"
"tdrive/internal/meta"
)

type VersionInfo struct {
Version string `json:"version"`
}

// RootCommand is the program entry point. Running it directly starts the file
// disk server; version is the only subcommand.
type RootCommand struct {
BaseCommand

flags serverFlags
verbose bool
}

func NewRootCommand(versionInfo *VersionInfo) *RootCommand {

var program RootCommand

program.versionInfo = versionInfo

program.Use = meta.Name + " [root]"

program.Short = "Personal web file disk (HTTP + FTP + WebDAV)"

program.Long = "A single-user web file disk that stores files directly on the filesystem and serves them over HTTP, FTP, and WebDAV. Run with no arguments to serve the current directory over HTTP (and WebDAV) on :3000 with no authentication; pass a directory to serve it instead."

program.Args = cobra.MaximumNArgs(1)

program.flags.register(&program.Command)
program.Flags().BoolVar(&program.verbose, "verbose", false, "enable debug logging")

program.RunE = func(cobraCommand *cobra.Command, args []string) error {

return program.onExecute(cobraCommand, args)
}

program.AddCommand(NewVersionCommand(versionInfo))

return &program
}

// onExecute resolves configuration and runs the server until termination. The
// run path adapts to its environment: under the Windows Service Control Manager
// it speaks the SCM protocol; otherwise it shuts down gracefully on SIGINT/SIGTERM
// (which is what systemd and Docker send).
func (command *RootCommand) onExecute(cobraCommand *cobra.Command, args []string) error {

// 1. Initialize logging.
command.setupLogger(command.verbose)

// 2. Resolve configuration: defaults, then CLI flags on top.
var cfg *config.Config = config.New()

var err error = command.flags.apply(cobraCommand, args, cfg)
if nil != err {
return err
}

err = cfg.Validate()
if nil != err {
return fmt.Errorf("invalid configuration: %w", err)
}

// 3. Build the application.
var application *app.App

application, err = app.New(cfg)
if nil != err {
return fmt.Errorf("init application: %w", err)
}

// 4. Run until terminated (Windows SCM or POSIX signals).
return app.Run(application)
}

func Execute(versionInfo *VersionInfo) error {

return NewRootCommand(versionInfo).Execute()
}
Loading
Loading