From 2af23db0ad3f4a5578b6921cbca32987e4e481b5 Mon Sep 17 00:00:00 2001 From: skidoodle Date: Fri, 16 Jan 2026 02:39:40 +0100 Subject: [PATCH] feat: initial release --- .github/workflows/release.yaml | 48 +++++++++ .gitignore | 3 + .goreleaser.yaml | 77 +++++++++++++++ CHANGELOG.md | 44 +++++++++ Dockerfile | 38 +++++++ Dockerfile.release | 19 ++++ README.md | 97 ++++++++++++++++++ compose.dev.yaml | 8 ++ compose.yaml | 17 ++++ go.mod | 3 + internal/app/config.go | 58 +++++++++++ internal/app/handlers.go | 174 +++++++++++++++++++++++++++++++++ internal/app/server.go | 58 +++++++++++ internal/app/storage.go | 50 ++++++++++ internal/crypto/crypto.go | 70 +++++++++++++ internal/crypto/reader.go | 92 +++++++++++++++++ main.go | 65 ++++++++++++ web/static/css/style.css | 110 +++++++++++++++++++++ web/static/js/app.js | 83 ++++++++++++++++ web/templates/base.html | 31 ++++++ web/templates/index.html | 18 ++++ 21 files changed, 1163 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 Dockerfile.release create mode 100644 README.md create mode 100644 compose.dev.yaml create mode 100644 compose.yaml create mode 100644 go.mod create mode 100644 internal/app/config.go create mode 100644 internal/app/handlers.go create mode 100644 internal/app/server.go create mode 100644 internal/app/storage.go create mode 100644 internal/crypto/crypto.go create mode 100644 internal/crypto/reader.go create mode 100644 main.go create mode 100644 web/static/css/style.css create mode 100644 web/static/js/app.js create mode 100644 web/templates/base.html create mode 100644 web/templates/index.html 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, ` +
+
Upload Complete:
+
+ + +
+ +
`, link) + return + } + + scheme := "https" + if r.TLS == nil { + scheme = "http" + } + fmt.Fprintf(w, "%s://%s\n", scheme, link) +} + +func (app *App) SendError(w http.ResponseWriter, r *http.Request, code int) { + if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { + w.WriteHeader(code) + fmt.Fprintf(w, `
Error %d
`, code) + return + } + http.Error(w, http.StatusText(code), code) +} diff --git a/internal/app/storage.go b/internal/app/storage.go new file mode 100644 index 0000000..6f30153 --- /dev/null +++ b/internal/app/storage.go @@ -0,0 +1,50 @@ +package app + +import ( + "context" + "math" + "os" + "path/filepath" + "time" +) + +func (app *App) StartCleanupTask(ctx context.Context) { + ticker := time.NewTicker(1 * time.Hour) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + app.CleanDir(app.Conf.StorageDir, false) + app.CleanDir(filepath.Join(app.Conf.StorageDir, "tmp"), true) + } + } +} + +func (app *App) CleanDir(path string, isTmp bool) { + entries, _ := os.ReadDir(path) + for _, entry := range entries { + info, _ := entry.Info() + expiry := 4 * time.Hour + if !isTmp { + expiry = CalculateRetention(info.Size(), app.Conf.MaxMB) + } + + if time.Since(info.ModTime()) > expiry { + os.RemoveAll(filepath.Join(path, entry.Name())) + } + } +} + +func CalculateRetention(fileSize int64, maxMB int64) time.Duration { + const ( + minAge = 24 * time.Hour + maxAge = 365 * 24 * time.Hour + ) + ratio := math.Max(0, math.Min(1, float64(fileSize)/float64(maxMB<<20))) + retention := float64(maxAge) * math.Pow(1.0-ratio, 3) + if retention < float64(minAge) { + return minAge + } + return time.Duration(retention) +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..1c59bf4 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,70 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "io" +) + +const ( + GCMChunkSize = 64 * 1024 + NonceSize = 12 +) + +func DeriveKey(r io.Reader) ([]byte, error) { + h := sha256.New() + if _, err := io.Copy(h, r); err != nil { + return nil, err + } + return h.Sum(nil)[:16], nil +} + +func GetID(key []byte, ext string) string { + h := sha256.New() + h.Write(key) + h.Write([]byte(ext)) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)[:9]) +} + +type GCMStreamer struct { + AEAD cipher.AEAD +} + +func NewGCMStreamer(key []byte) (*GCMStreamer, error) { + b, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + g, err := cipher.NewGCM(b) + if err != nil { + return nil, err + } + return &GCMStreamer{AEAD: g}, nil +} + +func (g *GCMStreamer) EncryptStream(dst io.Writer, src io.Reader) error { + buf := make([]byte, GCMChunkSize) + var chunkIdx uint64 = 0 + for { + n, err := io.ReadFull(src, buf) + if n > 0 { + nonce := make([]byte, NonceSize) + binary.BigEndian.PutUint64(nonce[4:], chunkIdx) + ciphertext := g.AEAD.Seal(nil, nonce, buf[:n], nil) + if _, werr := dst.Write(ciphertext); werr != nil { + return werr + } + chunkIdx++ + } + if err == io.EOF || err == io.ErrUnexpectedEOF { + break + } + if err != nil { + return err + } + } + return nil +} diff --git a/internal/crypto/reader.go b/internal/crypto/reader.go new file mode 100644 index 0000000..7aad6f5 --- /dev/null +++ b/internal/crypto/reader.go @@ -0,0 +1,92 @@ +package crypto + +import ( + "crypto/cipher" + "encoding/binary" + "errors" + "io" +) + +type Decryptor struct { + rs io.ReadSeeker + aead cipher.AEAD + size int64 + offset int64 +} + +func NewDecryptor(rs io.ReadSeeker, aead cipher.AEAD, encryptedSize int64) *Decryptor { + overhead := int64(aead.Overhead()) + fullBlocks := encryptedSize / (GCMChunkSize + overhead) + remainder := encryptedSize % (GCMChunkSize + overhead) + + plainSize := (fullBlocks * GCMChunkSize) + if remainder > overhead { + plainSize += (remainder - overhead) + } + + return &Decryptor{ + rs: rs, + aead: aead, + size: plainSize, + } +} + +func (d *Decryptor) Read(p []byte) (int, error) { + if d.offset >= d.size { + return 0, io.EOF + } + + chunkIdx := d.offset / GCMChunkSize + overhang := d.offset % GCMChunkSize + + overhead := int64(d.aead.Overhead()) + actualChunkSize := int64(GCMChunkSize + overhead) + + _, err := d.rs.Seek(chunkIdx*actualChunkSize, io.SeekStart) + if err != nil { + return 0, err + } + + encrypted := make([]byte, actualChunkSize) + n, err := io.ReadFull(d.rs, encrypted) + if err != nil && err != io.ErrUnexpectedEOF { + return 0, err + } + + nonce := make([]byte, NonceSize) + binary.BigEndian.PutUint64(nonce[4:], uint64(chunkIdx)) + + plaintext, err := d.aead.Open(nil, nonce, encrypted[:n], nil) + if err != nil { + return 0, err + } + + if overhang >= int64(len(plaintext)) { + return 0, io.EOF + } + + available := plaintext[overhang:] + nCopied := copy(p, available) + d.offset += int64(nCopied) + + return nCopied, nil +} + +func (d *Decryptor) Seek(offset int64, whence int) (int64, error) { + var abs int64 + switch whence { + case io.SeekStart: + abs = offset + case io.SeekCurrent: + abs = d.offset + offset + case io.SeekEnd: + abs = d.size + offset + default: + return 0, errors.New("invalid whence") + } + if abs < 0 { + return 0, errors.New("negative bias") + } + d.offset = abs + return abs, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3f592d6 --- /dev/null +++ b/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/skidoodle/safebin/internal/app" +) + +func main() { + cfg := app.LoadConfig() + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) + + logger.Info("Initializing Safebin Server", + "storage_dir", cfg.StorageDir, + "max_file_size", fmt.Sprintf("%dMB", cfg.MaxMB), + ) + + if err := os.MkdirAll(fmt.Sprintf("%s/tmp", cfg.StorageDir), 0700); err != nil { + logger.Error("Failed to initialize storage directory", "err", err) + os.Exit(1) + } + + application := &app.App{ + Conf: cfg, + Logger: logger, + Tmpl: app.ParseTemplates(), + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go application.StartCleanupTask(ctx) + + srv := &http.Server{ + Addr: cfg.Addr, + Handler: application.Routes(), + ReadTimeout: 10 * time.Minute, + WriteTimeout: 10 * time.Minute, + } + + go func() { + application.Logger.Info("Server is ready and listening", "addr", cfg.Addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + application.Logger.Error("Server failed to start", "err", err) + os.Exit(1) + } + }() + + <-ctx.Done() + application.Logger.Info("Shutting down gracefully...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + application.Logger.Error("Forced shutdown", "err", err) + } + application.Logger.Info("Server stopped") +} diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..3ccb2d4 --- /dev/null +++ b/web/static/css/style.css @@ -0,0 +1,110 @@ +:root { + --bg: #0d1117; + --fg: #adbac7; + --accent: #4493f8; + --border: #30363d; + --success: #3fb950; + --header-white: #f0f6fc; +} + +body { + background: var(--bg); + color: var(--fg); + font-family: -apple-system, system-ui, sans-serif; + margin: 0; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; +} + +.container { + width: 100%; + max-width: 600px; + padding: 20px; +} + +.header { + margin-bottom: 30px; + border-left: 3px solid var(--accent); + padding-left: 16px; +} + +.upload-area { + border: 2px dashed var(--border); + border-radius: 12px; + padding: 60px 20px; + text-align: center; + cursor: pointer; + background: #161b22; + transition: 0.2s; +} + +.upload-area:hover, +.upload-area.dragover { + border-color: var(--accent); + background: #1c2128; +} + +.progress-bar { + height: 6px; + background: var(--border); + border-radius: 10px; + margin: 25px 0; + overflow: hidden; + display: none; +} + +.progress-fill { + height: 100%; + background: var(--accent); + width: 0%; + transition: width 0.3s; +} + +.copy-box { + display: flex; + margin-top: 20px; + gap: 8px; +} + +input[type="text"] { + flex: 1; + background: #0d1117; + border: 1px solid var(--border); + color: var(--success); + padding: 12px; + border-radius: 6px; + font-family: monospace; + outline: none; +} + +button { + background: var(--accent); + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + cursor: pointer; + font-weight: 600; +} + +.reset-btn { + background: transparent; + color: var(--fg); + text-decoration: underline; + margin-top: 20px; + border: none; + cursor: pointer; + opacity: 0.7; +} + +.dim { + color: #768390; + font-size: 13px; +} + +.error-text { + color: #f85149; + margin-bottom: 10px; +} diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..75c329d --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,83 @@ +const $ = (id) => document.getElementById(id); +const dropZone = $("drop-zone"); +const fileInput = $("file-input"); + +if (dropZone) { + dropZone.onclick = () => { + if ($("idle-state").style.display !== "none") fileInput.click(); + }; + + fileInput.onchange = () => { + if (fileInput.files[0]) handleUpload(fileInput.files[0]); + }; + + ["dragenter", "dragover"].forEach((n) => + dropZone.addEventListener(n, (e) => { + e.preventDefault(); + dropZone.classList.add("dragover"); + }), + ); + + ["dragleave", "drop"].forEach((n) => + dropZone.addEventListener(n, (e) => { + e.preventDefault(); + dropZone.classList.remove("dragover"); + }), + ); + + dropZone.addEventListener("drop", (e) => { + e.preventDefault(); + if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files[0]); + }); +} + +async function handleUpload(file) { + $("idle-state").style.display = "none"; + $("busy-state").style.display = "block"; + + const uploadID = Math.random().toString(36).substring(2, 15); + const chunkSize = 1024 * 1024 * 8; + const total = Math.ceil(file.size / chunkSize); + + try { + for (let i = 0; i < total; i++) { + const fd = new FormData(); + fd.append("upload_id", uploadID); + fd.append("index", i); + fd.append("chunk", file.slice(i * chunkSize, (i + 1) * chunkSize)); + const res = await fetch("/upload/chunk", { method: "POST", body: fd }); + if (!res.ok) throw new Error(); + $("p-fill").style.width = ((i + 1) / total) * 100 + "%"; + } + + const finalFd = new FormData(); + finalFd.append("upload_id", uploadID); + finalFd.append("filename", file.name); + finalFd.append("total", total); + + const res = await fetch("/upload/finish", { + method: "POST", + body: finalFd, + headers: { "X-Requested-With": "XMLHttpRequest" }, + }); + + $("busy-state").style.display = "none"; + $("result-state").innerHTML = await res.text(); + } catch (e) { + $("busy-state").style.display = "none"; + $("result-state").innerHTML = `
Upload Failed
`; + } +} + +function copyToClipboard(btn) { + const input = $("share-url"); + input.select(); + const fullUrl = window.location.protocol + "//" + input.value; + navigator.clipboard.writeText(fullUrl); + btn.innerText = "Copied!"; + setTimeout(() => (btn.innerText = "Copy"), 2000); +} + +function resetUI() { + location.reload(); +} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..7fb3571 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,31 @@ +{{define "base"}} + + + + + + safebin + + + +
+
+

safebin

+
Encrypted Temporary File Storage
+
+ + {{template "content" .}} + +
+
CLI Usage
+
+curl -F file=@yourfile {{.Host}}
+
+
+ + + + + +{{end}} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..1b79316 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,18 @@ +{{define "content"}} +
+
+
+
Click or drag to upload
+
Max size: {{.MaxMB}}MB
+
+ + + +
+
+{{end}}