diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..248db1f --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,48 @@ +name: release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + cache: true + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67aedbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +storage/* +# Added by goreleaser init: +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..198ec59 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,77 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + flags: + - -trimpath + +archives: + - name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + formats: ["tar.gz"] + files: + - web/**/* + - README.md + - CHANGELOG.md + +dockers: + - image_templates: + - "ghcr.io/skidoodle/safebin:{{ .Version }}-amd64" + - "ghcr.io/skidoodle/safebin:latest-amd64" + use: buildx + goos: linux + goarch: amd64 + dockerfile: Dockerfile.release + extra_files: + - web + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + + - image_templates: + - "ghcr.io/skidoodle/safebin:{{ .Version }}-arm64" + - "ghcr.io/skidoodle/safebin:latest-arm64" + use: buildx + goos: linux + goarch: arm64 + dockerfile: Dockerfile.release + extra_files: + - web + build_flag_templates: + - "--platform=linux/arm64" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + +docker_manifests: + - name_template: "ghcr.io/skidoodle/safebin:{{ .Version }}" + image_templates: + - "ghcr.io/skidoodle/safebin:{{ .Version }}-amd64" + - "ghcr.io/skidoodle/safebin:{{ .Version }}-arm64" + - name_template: "ghcr.io/skidoodle/safebin:latest" + image_templates: + - "ghcr.io/skidoodle/safebin:latest-amd64" + - "ghcr.io/skidoodle/safebin:latest-arm64" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f6dceb1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +## [3.0.0](https://github.com/skidoodle/safebin/compare/v2.0.0...v3.0.0) (2026-01-16) + + +### ⚠ BREAKING CHANGES + +* Docker volume paths and environment variables have been updated. The internal storage path in the container has changed from `/home/appuser/storage` to `/app/storage`. Existing deployments must update their volume mappings and environment variable names to maintain persistence. + +### Code Refactoring + +* relocate core logic to internal package and modernize project structure ([43be383](https://github.com/skidoodle/safebin/commit/43be383fdbfb0263036284b8beb0ce3c646db87c)) + +## [2.0.0](https://github.com/skidoodle/safebin/compare/v1.1.0...v2.0.0) (2026-01-16) + + +### ⚠ BREAKING CHANGES + +* The encryption scheme and URL structure have been completely redesigned. Links generated with previous versions of safebin are no longer compatible and cannot be decrypted by this version. + +### Features + +* overhaul encryption to zero-knowledge at rest and modernize UI ([599347e](https://github.com/skidoodle/safebin/commit/599347e867444288fa58f8e358269121c5d32e36)) + +## [1.1.0](https://github.com/skidoodle/safebin/compare/v1.0.1...v1.1.0) (2026-01-14) + + +### Features + +* implement chunked uploads and environment-based configuration ([1ccc80a](https://github.com/skidoodle/safebin/commit/1ccc80ad4e5b949a8f1d1f3a8b3b4e8c4d2e1353)) + +## [1.0.1](https://github.com/skidoodle/safebin/compare/v1.0.0...v1.0.1) (2026-01-14) + + +### Bug Fixes + +* better dockerfile ([c1ecbe5](https://github.com/skidoodle/safebin/commit/c1ecbe567a24eb4e755f19fee68422025f3b15b2)) + +## 1.0.0 (2026-01-13) + + +### Features + +* add automated release and docker workflow ([e40e6d0](https://github.com/skidoodle/safebin/commit/e40e6d01afd0067bba5d0cf4a9b1ff3d7122259f)) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f7cc7e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM --platform=$BUILDPLATFORM golang:1.25.5 AS builder + +WORKDIR /app + +COPY . . + +ARG TARGETOS +ARG TARGETARCH + +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \ + -ldflags="-s -w" \ + -trimpath \ + -o /app/safebin . + +FROM debian:trixie-slim + +LABEL org.opencontainers.image.source="https://github.com/skidoodle/safebin" +LABEL org.opencontainers.image.description="Minimalist, self-hosted file storage with Zero-Knowledge at Rest encryption." +LABEL org.opencontainers.image.licenses="GPL-2.0-only" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -u 10001 -s /bin/bash appuser +WORKDIR /app + +COPY --from=builder /app/safebin . +COPY --from=builder /app/web ./web + +RUN mkdir -p /app/storage && chown 10001:10001 /app/storage +VOLUME ["/app/storage"] + +USER 10001 +EXPOSE 8080 + +ENTRYPOINT ["/app/safebin"] diff --git a/Dockerfile.release b/Dockerfile.release new file mode 100644 index 0000000..6f42f75 --- /dev/null +++ b/Dockerfile.release @@ -0,0 +1,19 @@ +FROM debian:trixie-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -u 10001 -s /bin/bash appuser +WORKDIR /app + +COPY safebin . +COPY web ./web + +RUN mkdir -p /app/storage && chown 10001:10001 /app/storage +VOLUME ["/app/storage"] + +USER 10001 +EXPOSE 8080 + +ENTRYPOINT ["/app/safebin"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..be776d8 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# safebin + +`safebin` is a minimalist, self-hosted file storage service with **Zero-Knowledge at Rest** encryption. + +## Features + +- **Server-Side Encryption**: Files are encrypted using AES-256-GCM before touching the disk. +- **Log-Safe Keys**: The decryption key is stored in the URL fragment (`#`). Since fragments are never sent to the server, the key never appears in your HTTP access logs. +- **Integrity**: Uses GCM (Galois/Counter Mode) to ensure files cannot be tampered with while stored. +- **Deterministic**: Identical files result in the same ID, allowing for storage deduplication. + +## Usage + +You can interact with the service via the web interface or through the command line. + +### Uploading a file + +```bash +curl -F 'file=@archive.zip' https://bin.example.com +``` + +The server will return a URL containing the file ID and the decryption key: +`https://bin.example.com/vS6_1_8pS-Y_8-8_...` + +### Downloading a file + +Simply open the link in a browser or use `curl`: + +```bash +curl https://bin.example.com/vS6_1_8pS-Y_8-8_... > archive.zip +``` + +## Configuration + +`safebin` is configured via command-line flags: + +| Flag | Description | Default | +| :--- | :--- | :--- | +| `-h` | Bind address for the server. | `0.0.0.0` | +| `-p` | Port to listen on. | `8080` | +| `-s` | Directory where encrypted files are stored. | `./storage` | +| `-m` | Maximum file size in mb. | `512` | + +## Running Locally + +### With Docker + +```bash +git clone https://github.com/skidoodle/safebin +cd safebin +docker compose -f compose.dev.yaml up --build +``` + +### Without Docker + +Requires Go 1.25 or higher. + +```bash +git clone https://github.com/skidoodle/safebin +cd safebin +go build -o safebin . +./safebin -p 8080 -s ./data +``` + +## Deploying + +### Docker Compose + +The easiest way to deploy is using the provided `compose.yaml`. + +```yaml +services: + safebin: + image: ghcr.io/skidoodle/safebin:main + container_name: safebin + restart: unless-stopped + ports: + - 8080:8080 + environment: + - SAFEBIN_HOST=0.0.0.0 + - SAFEBIN_PORT=8080 + - SAFEBIN_STORAGE=/app/storage + - SAFEBIN_MAX_MB=512 + volumes: + - data:/app/storage + +volumes: + data: +``` + +## Retention Policy + +The server runs a cleanup task every hour. Retention is calculated using a cubic scaling formula to balance disk usage: +- **Small files (< 1MB)**: Up to 365 days. +- **Large files (512MB)**: 24 hours. + +This ensures that the server doesn't run out of disk space due to large binary blobs while allowing small text files or images to persist for longer periods. diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..0571bc7 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,8 @@ +services: + safebin: + build: + context: . + dockerfile: Dockerfile + container_name: safebin-dev + ports: + - 8080:8080 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..9291b04 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,17 @@ +services: + safebin: + image: ghcr.io/skidoodle/safebin:main + container_name: safebin + restart: unless-stopped + ports: + - 8080:8080 + environment: + - SAFEBIN_HOST=0.0.0.0 + - SAFEBIN_PORT=8080 + - SAFEBIN_STORAGE=/app/storage + - SAFEBIN_MAX_MB=512 + volumes: + - data:/app/storage + +volumes: + data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..31ed70f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/skidoodle/safebin + +go 1.25.5 diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..82d7ff7 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,58 @@ +package app + +import ( + "flag" + "fmt" + "html/template" + "log/slog" + "os" + "strconv" +) + +type Config struct { + Addr string + StorageDir string + MaxMB int64 +} + +type App struct { + Conf Config + Tmpl *template.Template + Logger *slog.Logger +} + +func LoadConfig() Config { + h := getEnv("SAFEBIN_HOST", "0.0.0.0") + p := getEnvInt("SAFEBIN_PORT", 8080) + s := getEnv("SAFEBIN_STORAGE", "./storage") + mDefault := int64(getEnvInt("SAFEBIN_MAX_MB", 512)) + + var m int64 + flag.StringVar(&h, "h", h, "Bind address") + flag.IntVar(&p, "p", p, "Port") + flag.StringVar(&s, "s", s, "Storage directory") + flag.Int64Var(&m, "m", mDefault, "Max file size in MB") + flag.Parse() + + return Config{Addr: fmt.Sprintf("%s:%d", h, p), StorageDir: s, MaxMB: m} +} + +func getEnv(k, f string) string { + if v, ok := os.LookupEnv(k); ok { + return v + } + return f +} + +func getEnvInt(k string, f int) int { + if v, ok := os.LookupEnv(k); ok { + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + return f +} + +func ParseTemplates() *template.Template { + return template.Must(template.ParseGlob("./web/templates/*.html")) +} diff --git a/internal/app/handlers.go b/internal/app/handlers.go new file mode 100644 index 0000000..ecc2a92 --- /dev/null +++ b/internal/app/handlers.go @@ -0,0 +1,174 @@ +package app + +import ( + "encoding/base64" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + + "github.com/skidoodle/safebin/internal/crypto" +) + +var reUploadID = regexp.MustCompile(`^[a-zA-Z0-9]{10,50}$`) + +func (app *App) HandleHome(w http.ResponseWriter, r *http.Request) { + err := app.Tmpl.ExecuteTemplate(w, "base", map[string]any{ + "MaxMB": app.Conf.MaxMB, + "Host": r.Host, + }) + if err != nil { + app.Logger.Error("Template error", "err", err) + } +} + +func (app *App) HandleUpload(w http.ResponseWriter, r *http.Request) { + limit := (app.Conf.MaxMB << 20) + (1 << 20) + r.Body = http.MaxBytesReader(w, r.Body, limit) + + file, header, err := r.FormFile("file") + if err != nil { + app.SendError(w, r, http.StatusBadRequest) + return + } + defer file.Close() + + tmpPath := filepath.Join(app.Conf.StorageDir, "tmp", fmt.Sprintf("up_%d", os.Getpid())) + tmp, _ := os.Create(tmpPath) + defer os.Remove(tmpPath) + defer tmp.Close() + + if _, err := io.Copy(tmp, file); err != nil { + app.SendError(w, r, http.StatusRequestEntityTooLarge) + return + } + + app.FinalizeFile(w, r, tmp, header.Filename) +} + +func (app *App) HandleChunk(w http.ResponseWriter, r *http.Request) { + uid := r.FormValue("upload_id") + idx, _ := strconv.Atoi(r.FormValue("index")) + + if !reUploadID.MatchString(uid) || idx > 1000 { + app.SendError(w, r, http.StatusBadRequest) + return + } + + file, _, err := r.FormFile("chunk") + if err != nil { + return + } + defer file.Close() + + dir := filepath.Join(app.Conf.StorageDir, "tmp", uid) + os.MkdirAll(dir, 0700) + + dest, _ := os.Create(filepath.Join(dir, strconv.Itoa(idx))) + defer dest.Close() + io.Copy(dest, file) +} + +func (app *App) HandleFinish(w http.ResponseWriter, r *http.Request) { + uid := r.FormValue("upload_id") + total, _ := strconv.Atoi(r.FormValue("total")) + + if !reUploadID.MatchString(uid) || total > 1000 { + app.SendError(w, r, http.StatusBadRequest) + return + } + + tmpPath := filepath.Join(app.Conf.StorageDir, "tmp", "m_"+uid) + merged, _ := os.Create(tmpPath) + defer os.Remove(tmpPath) + defer merged.Close() + + for i := range total { + partPath := filepath.Join(app.Conf.StorageDir, "tmp", uid, strconv.Itoa(i)) + part, err := os.Open(partPath) + if err != nil { + continue + } + io.Copy(merged, part) + part.Close() + } + + app.FinalizeFile(w, r, merged, r.FormValue("filename")) + os.RemoveAll(filepath.Join(app.Conf.StorageDir, "tmp", uid)) +} + +func (app *App) HandleGetFile(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if len(slug) < 22 { + app.SendError(w, r, http.StatusBadRequest) + return + } + + keyBase64 := slug[:22] + ext := slug[22:] + + key, err := base64.RawURLEncoding.DecodeString(keyBase64) + if err != nil || len(key) != 16 { + app.SendError(w, r, http.StatusUnauthorized) + return + } + + id := crypto.GetID(key, ext) + path := filepath.Join(app.Conf.StorageDir, id) + + info, err := os.Stat(path) + if err != nil { + app.SendError(w, r, http.StatusNotFound) + return + } + + f, _ := os.Open(path) + defer f.Close() + + streamer, _ := crypto.NewGCMStreamer(key) + decryptor := crypto.NewDecryptor(f, streamer.AEAD, info.Size()) + + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = "application/octet-stream" + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self' data:; style-src 'unsafe-inline'; sandbox allow-forms allow-scripts allow-downloads allow-same-origin") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", slug)) + + http.ServeContent(w, r, slug, info.ModTime(), decryptor) +} + +func (app *App) FinalizeFile(w http.ResponseWriter, r *http.Request, src *os.File, filename string) { + src.Seek(0, 0) + key, _ := crypto.DeriveKey(src) + + ext := filepath.Ext(filename) + id := crypto.GetID(key, ext) + + src.Seek(0, 0) + finalPath := filepath.Join(app.Conf.StorageDir, id) + + if _, err := os.Stat(finalPath); err == nil { + app.RespondWithLink(w, r, key, filename) + return + } + + out, _ := os.Create(finalPath + ".tmp") + streamer, _ := crypto.NewGCMStreamer(key) + if err := streamer.EncryptStream(out, src); err != nil { + out.Close() + os.Remove(finalPath + ".tmp") + app.SendError(w, r, http.StatusInternalServerError) + return + } + out.Close() + os.Rename(finalPath+".tmp", finalPath) + app.RespondWithLink(w, r, key, filename) +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..5970989 --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,58 @@ +package app + +import ( + "encoding/base64" + "fmt" + "net/http" + "path/filepath" +) + +func (app *App) Routes() *http.ServeMux { + mux := http.NewServeMux() + + fs := http.FileServer(http.Dir("./web/static")) + mux.Handle("GET /static/", http.StripPrefix("/static/", fs)) + + mux.HandleFunc("GET /{$}", app.HandleHome) + mux.HandleFunc("POST /{$}", app.HandleUpload) + mux.HandleFunc("POST /upload/chunk", app.HandleChunk) + mux.HandleFunc("POST /upload/finish", app.HandleFinish) + mux.HandleFunc("GET /{slug}", app.HandleGetFile) + + return mux +} + +func (app *App) RespondWithLink(w http.ResponseWriter, r *http.Request, key []byte, originalName string) { + keySlug := base64.RawURLEncoding.EncodeToString(key) + ext := filepath.Ext(originalName) + + link := fmt.Sprintf("%s/%s%s", r.Host, keySlug, ext) + + if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { + fmt.Fprintf(w, ` +
+curl -F file=@yourfile {{.Host}}
+