6 Commits

Author SHA1 Message Date
x 180f32902b fix: patch flaws and refactor routes
Signed-off-by: skidoodle <contact@albert.lol>
2026-01-22 05:55:24 +01:00
x 89b4d3f4e6 chore: use scratch
Signed-off-by: skidoodle <contact@albert.lol>
2026-01-22 04:37:33 +01:00
x 577c4b67f6 feat: implement sequential chunk reading and decryption
Signed-off-by: skidoodle <contact@albert.lol>
2026-01-22 04:37:20 +01:00
x 5c13d24736 chore: update deps
Signed-off-by: skidoodle <contact@albert.lol>
2026-01-22 04:10:46 +01:00
x 297db0effa chore: update readme
Signed-off-by: skidoodle <contact@albert.lol>
2026-01-22 04:10:35 +01:00
x f0336b21b8 feat: show version on website
Signed-off-by: skidoodle <contact@albert.lol>
2026-01-19 01:31:28 +01:00
13 changed files with 351 additions and 214 deletions
+5 -1
View File
@@ -4,6 +4,9 @@ before:
hooks: hooks:
- go mod tidy - go mod tidy
snapshot:
version_template: "{{ .Version }}"
builds: builds:
- env: - env:
- CGO_ENABLED=0 - CGO_ENABLED=0
@@ -13,7 +16,8 @@ builds:
- amd64 - amd64
- arm64 - arm64
ldflags: ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} - -s -w
- -X github.com/skidoodle/safebin/internal/app.Version={{.Version}}
flags: flags:
- -trimpath - -trimpath
+22 -21
View File
@@ -1,6 +1,5 @@
FROM --platform=$BUILDPLATFORM golang:1.25.6 AS builder FROM --platform=$BUILDPLATFORM golang:1.25.6-alpine AS builder
WORKDIR /src
WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
@@ -9,33 +8,35 @@ COPY . .
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
ARG VERSION=dev
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \ CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
-ldflags="-s -w" \ -ldflags="-s -w -X github.com/skidoodle/safebin/internal/app.Version=$VERSION" \
-trimpath \ -trimpath \
-o /app/safebin . -o /bin/safebin .
FROM debian:trixie-slim FROM alpine:latest AS sys-context
RUN apk add --no-cache ca-certificates mailcap
RUN echo "appuser:x:10001:10001:appuser:/:/sbin/nologin" > /etc/passwd_app \
&& echo "appuser:x:10001:appuser" > /etc/group_app
RUN mkdir -p /app/storage
LABEL org.opencontainers.image.source="https://github.com/skidoodle/safebin" FROM scratch
LABEL org.opencontainers.image.description="Minimalist, self-hosted file storage with Zero-Knowledge at Rest encryption." COPY --from=sys-context /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
LABEL org.opencontainers.image.licenses="GPL-2.0-only" COPY --from=sys-context /etc/mime.types /etc/mime.types
COPY --from=sys-context /etc/passwd_app /etc/passwd
COPY --from=sys-context /etc/group_app /etc/group
COPY --from=builder /bin/safebin /app/safebin
COPY --from=sys-context --chown=10001:10001 /app/storage /app/storage
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
media-types \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -u 10001 -s /bin/bash appuser
WORKDIR /app WORKDIR /app
COPY --from=builder /app/safebin .
RUN mkdir -p /app/storage && chown 10001:10001 /app/storage
VOLUME ["/app/storage"]
USER 10001 USER 10001
VOLUME ["/app/storage"]
EXPOSE 8080 EXPOSE 8080
ENV SAFEBIN_HOST=0.0.0.0 \
SAFEBIN_PORT=8080 \
SAFEBIN_STORAGE=/app/storage
ENTRYPOINT ["/app/safebin"] ENTRYPOINT ["/app/safebin"]
+18 -12
View File
@@ -1,19 +1,25 @@
FROM debian:trixie-slim FROM alpine:latest AS sys-context
RUN apk add --no-cache ca-certificates mailcap
RUN echo "appuser:x:10001:10001:appuser:/:/sbin/nologin" > /etc/passwd_app \
&& echo "appuser:x:10001:appuser" > /etc/group_app
RUN mkdir -p /app/storage
RUN apt-get update && apt-get install -y --no-install-recommends \ FROM scratch
ca-certificates \ COPY --from=sys-context /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
media-types \ COPY --from=sys-context /etc/mime.types /etc/mime.types
&& rm -rf /var/lib/apt/lists/* COPY --from=sys-context /etc/passwd_app /etc/passwd
COPY --from=sys-context /etc/group_app /etc/group
COPY safebin /app/safebin
COPY --from=sys-context --chown=10001:10001 /app/storage /app/storage
RUN useradd -m -u 10001 -s /bin/bash appuser
WORKDIR /app WORKDIR /app
COPY safebin .
RUN mkdir -p /app/storage && chown 10001:10001 /app/storage
VOLUME ["/app/storage"]
USER 10001 USER 10001
VOLUME ["/app/storage"]
EXPOSE 8080 EXPOSE 8080
ENV SAFEBIN_HOST=0.0.0.0 \
SAFEBIN_PORT=8080 \
SAFEBIN_STORAGE=/app/storage
ENTRYPOINT ["/app/safebin"] ENTRYPOINT ["/app/safebin"]
+66 -46
View File
@@ -1,45 +1,36 @@
# safebin # safebin
`safebin` is a minimalist, self-hosted file storage service with **Zero-Knowledge at Rest** encryption. [![Go Version](https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go)](https://go.dev/)
[![License](https://img.shields.io/badge/License-GPLv2-blue.svg?style=flat-square)](LICENSE)
[![Docker Image](https://img.shields.io/badge/Docker-ghcr.io%2Fskidoodle%2Fsafebin-blue?style=flat-square&logo=docker)](https://github.com/skidoodle/safebin/pkgs/container/safebin)
## Features **safebin** is a minimalist, self-hosted file storage service designed for efficiency and privacy. It utilizes **Convergent Encryption** to provide secure storage at rest while automatically deduplicating identical files to save disk space.
- **End-to-End Encryption**: Files are encrypted using AES-128-GCM before being written to disk. ## 📖 Architecture & Security Model
- **Key-Derived URLs**: The decryption key is part of the URL. The server uses this key to locate and decrypt the file on the fly.
- **Integrity**: Uses GCM (Galois/Counter Mode) to ensure files cannot be tampered with while stored.
- **Storage Deduplication**: Identical files result in the same ID, saving disk space.
- **Chunked Uploads**: Supports large file uploads via the web interface using 8MB chunks.
## Usage Safebin is designed to be **Host-Proof at Rest**. While it is not a client-side E2EE solution, it ensures that the server cannot access stored data without the specific link generated at upload time.
### Web Interface ### How it Works
Simply drag and drop files into the browser. The interface handles chunking and provides a shareable link once the upload is finalized. 1. **Upload**: The server receives the file stream and calculates a SHA-256 hash of the content.
2. **Key Generation**: This hash becomes the encryption key (Convergent Encryption).
3. **Encryption**: The file is encrypted using **AES-128-GCM** and written to disk.
4. **Deduplication**: Because the key is derived from the content, identical files generate the same ID. The server detects this and stores only one physical copy, regardless of how many times it is uploaded.
5. **Zero-Knowledge Storage**: The server saves the file metadata (ID, size, expiry) but **discards the encryption key**.
6. **Link Generation**: The key is encoded into the URL fragment returned to the user.
### Command Line (CLI) > **Security Note**: If the server's database or physical storage is seized, the files are mathematically inaccessible. However, because encryption occurs on the server, the process does have access to the plaintext in memory during the brief window of upload and download.
You can upload files directly using `curl`:
```bash ## ✨ Features
curl -F 'file=@photo.jpg' https://bin.example.com
```
The server will return a direct link: - **Convergent Encryption & Deduplication**: Files are addressed by their content. Uploading the same file twice results in a single storage entry, significantly reducing disk usage.
`https://bin.example.com/0iEZGtW-ikVdu...jpg` - **Tamper-Proof Storage**: Uses Galois/Counter Mode (GCM) to ensure data integrity. Modified files will fail decryption.
- **Volatile Keys**: Decryption keys reside only in the generated URLs, not in the database.
- **Smart Retention**: A cubic scaling algorithm prioritizes keeping small files (snippets, logs) for a long time, while large binaries expire quickly.
- **Chunked Uploads**: Robust handling of large files via the web interface using 8MB chunks.
## Configuration ## 🚀 Deployment
`safebin` can be configured via environment variables or command-line flags: ### Docker Compose (Recommended)
| Flag | Environment Variable | Description | Default |
| :--- | :--- | :--- | :--- |
| `-h` | `SAFEBIN_HOST` | Bind address for the server. | `0.0.0.0` |
| `-p` | `SAFEBIN_PORT` | Port to listen on. | `8080` |
| `-s` | `SAFEBIN_STORAGE` | Directory for encrypted storage. | `./storage` |
| `-m` | `SAFEBIN_MAX_MB` | Maximum file size in MB. | `512` |
## Deployment
### Docker Compose
The easiest way to deploy is using the provided `compose.yaml`:
```yaml ```yaml
services: services:
@@ -48,35 +39,64 @@ services:
container_name: safebin container_name: safebin
restart: unless-stopped restart: unless-stopped
ports: ports:
- 8080:8080 - "8080:8080"
environment: environment:
- SAFEBIN_HOST=0.0.0.0
- SAFEBIN_PORT=8080
- SAFEBIN_STORAGE=/app/storage
- SAFEBIN_MAX_MB=512 - SAFEBIN_MAX_MB=512
volumes: volumes:
- data:/app/storage - safebin_data:/app/storage
volumes: volumes:
data: safebin_data:
``` ```
### Manual Build ### Manual Installation
Requires Go 1.25 or higher. Requires Go 1.25 or higher.
```bash ```bash
# Build the binary
go build -o safebin . go build -o safebin .
./safebin -p 8080 -s ./data
# Run the server
./safebin -p 8080 -s ./data -m 1024
``` ```
## Retention Policy ## ⚙️ Configuration
The server runs a background cleanup task every hour. Retention is calculated using a cubic scaling formula to prioritize small files: Configuration is handled via environment variables or command-line flags. Flags take precedence over environment variables.
- **Small files (e.g., < 1MB)**: Kept for up to **365 days**. | Flag | Environment Variable | Description | Default |
- **Large files (at Max MB)**: Kept for **24 hours**. | :--- | :--- | :--- | :--- |
- **Temporary Uploads**: Unfinished chunked uploads are purged after **4 hours**. | `-h` | `SAFEBIN_HOST` | Interface/Bind address. | `0.0.0.0` |
| `-p` | `SAFEBIN_PORT` | Port to listen on. | `8080` |
| `-s` | `SAFEBIN_STORAGE` | Directory for database and files. | `./storage` |
| `-m` | `SAFEBIN_MAX_MB` | Maximum allowed file size in MB. | `512` |
## License ## 💻 Usage
This project is licensed under the **GNU General Public License v2.0**. ### Web Interface
Navigate to `http://localhost:8080`. Drag and drop files to upload. The browser handles chunking automatically.
### CLI (curl)
Safebin is optimized for terminal usage. You can upload files directly via `curl`:
```bash
# Upload a file
curl -F 'file=@screenshot.png' https://bin.example.com
# Response
https://bin.example.com/0iEZGtW-ikVdu...png
```
## ⏳ Retention Policy
To keep storage manageable, Safebin runs a cleanup task every hour. File lifetime is determined by size using a cubic curve:
* **Small Files (< 1MB)**: Retained for **365 days**.
* **Medium Files (~50% Max Size)**: Retained for ~30 days.
* **Large Files (Max Size)**: Retained for **24 hours**.
* **Incomplete Uploads**: Purged after **4 hours**.
## 📄 License
This project is licensed under the [GNU General Public License v2.0](LICENSE).
+1 -1
View File
@@ -4,4 +4,4 @@ go 1.25.6
require go.etcd.io/bbolt v1.4.3 require go.etcd.io/bbolt v1.4.3
require golang.org/x/sys v0.29.0 // indirect require golang.org/x/sys v0.40.0 // indirect
+2 -2
View File
@@ -8,7 +8,7 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+4
View File
@@ -13,6 +13,10 @@ import (
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
var (
Version = "dev"
)
const ( const (
DefaultHost = "0.0.0.0" DefaultHost = "0.0.0.0"
DefaultPort = 8080 DefaultPort = 8080
+26 -20
View File
@@ -11,23 +11,7 @@ import (
func (app *App) Routes() *http.ServeMux { func (app *App) Routes() *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
fileServer := http.FileServer(http.FS(app.Assets)) mux.Handle("GET /static/", http.StripPrefix("/static/", app.handleStatic()))
staticHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "" || strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
if strings.HasSuffix(r.URL.Path, ".html") {
http.NotFound(w, r)
return
}
fileServer.ServeHTTP(w, r)
})
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler))
mux.HandleFunc("GET /{$}", app.HandleHome) mux.HandleFunc("GET /{$}", app.HandleHome)
mux.HandleFunc("POST /{$}", app.HandleUpload) mux.HandleFunc("POST /{$}", app.HandleUpload)
mux.HandleFunc("POST /upload/chunk", app.HandleChunk) mux.HandleFunc("POST /upload/chunk", app.HandleChunk)
@@ -37,10 +21,23 @@ func (app *App) Routes() *http.ServeMux {
return mux return mux
} }
func (app *App) handleStatic() http.Handler {
fs := http.FileServer(http.FS(app.Assets))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "" || strings.HasSuffix(r.URL.Path, "/") || strings.HasSuffix(r.URL.Path, ".html") {
http.NotFound(w, r)
return
}
fs.ServeHTTP(w, r)
})
}
func (app *App) HandleHome(writer http.ResponseWriter, request *http.Request) { func (app *App) HandleHome(writer http.ResponseWriter, request *http.Request) {
err := app.Tmpl.ExecuteTemplate(writer, "layout", map[string]any{ err := app.Tmpl.ExecuteTemplate(writer, "layout", map[string]any{
"MaxMB": app.Conf.MaxMB, "MaxMB": app.Conf.MaxMB,
"Host": request.Host, "Host": request.Host,
"Version": Version,
}) })
if err != nil { if err != nil {
@@ -51,7 +48,16 @@ func (app *App) HandleHome(writer http.ResponseWriter, request *http.Request) {
func (app *App) RespondWithLink(writer http.ResponseWriter, request *http.Request, key []byte, originalName string) { func (app *App) RespondWithLink(writer http.ResponseWriter, request *http.Request, key []byte, originalName string) {
keySlug := base64.RawURLEncoding.EncodeToString(key) keySlug := base64.RawURLEncoding.EncodeToString(key)
ext := filepath.Ext(originalName) ext := filepath.Ext(originalName)
link := fmt.Sprintf("%s/%s%s", request.Host, keySlug, ext)
const unsafeChars = "\"<> \\/:;?@[]^`{}|~"
safeExt := strings.Map(func(r rune) rune {
if strings.ContainsRune(unsafeChars, r) {
return -1
}
return r
}, ext)
link := fmt.Sprintf("%s/%s%s", request.Host, keySlug, safeExt)
if request.Header.Get("X-Requested-With") == "XMLHttpRequest" { if request.Header.Get("X-Requested-With") == "XMLHttpRequest" {
html := ` html := `
+83 -46
View File
@@ -70,56 +70,93 @@ func (app *App) saveChunk(uid string, idx int, src io.Reader) error {
return nil return nil
} }
func (app *App) getChunkDecryptors(uid string, total int) ([]io.ReadSeeker, func(), error) { func (app *App) openChunkDecryptor(uid string, idx int) (io.ReadCloser, error) {
files := make([]*os.File, 0, total) partPath := filepath.Join(app.Conf.StorageDir, TempDirName, uid, strconv.Itoa(idx))
decryptors := make([]io.ReadSeeker, 0, total) f, err := os.Open(partPath)
if err != nil {
closeAll := func() { return nil, fmt.Errorf("open chunk %d: %w", idx, err)
for _, f := range files {
_ = f.Close()
}
} }
for i := range total { key := make([]byte, crypto.KeySize)
partPath := filepath.Join(app.Conf.StorageDir, TempDirName, uid, strconv.Itoa(i)) if _, err := io.ReadFull(f, key); err != nil {
f, err := os.Open(partPath) _ = f.Close()
if err != nil { return nil, fmt.Errorf("read chunk key %d: %w", idx, err)
closeAll()
return nil, nil, fmt.Errorf("open chunk %d: %w", i, err)
}
files = append(files, f)
key := make([]byte, crypto.KeySize)
if _, err := io.ReadFull(f, key); err != nil {
closeAll()
return nil, nil, fmt.Errorf("read chunk key %d: %w", i, err)
}
info, err := f.Stat()
if err != nil {
closeAll()
return nil, nil, fmt.Errorf("stat chunk %d: %w", i, err)
}
bodySize := info.Size() - int64(crypto.KeySize)
if bodySize < 0 {
closeAll()
return nil, nil, fmt.Errorf("invalid chunk size %d", i)
}
bodyReader := io.NewSectionReader(f, int64(crypto.KeySize), bodySize)
streamer, err := crypto.NewGCMStreamer(key)
if err != nil {
closeAll()
return nil, nil, fmt.Errorf("create streamer %d: %w", i, err)
}
decryptor := crypto.NewDecryptor(bodyReader, streamer.AEAD, bodySize)
decryptors = append(decryptors, decryptor)
} }
return decryptors, closeAll, nil info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("stat chunk %d: %w", idx, err)
}
bodySize := info.Size() - int64(crypto.KeySize)
if bodySize < 0 {
_ = f.Close()
return nil, fmt.Errorf("invalid chunk size %d", idx)
}
bodyReader := io.NewSectionReader(f, int64(crypto.KeySize), bodySize)
streamer, err := crypto.NewGCMStreamer(key)
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("create streamer %d: %w", idx, err)
}
decryptor := crypto.NewDecryptor(bodyReader, streamer.AEAD, bodySize)
return &chunkReadCloser{Decryptor: decryptor, f: f}, nil
}
type chunkReadCloser struct {
*crypto.Decryptor
f *os.File
}
func (c *chunkReadCloser) Close() error {
return c.f.Close()
}
type SequentialChunkReader struct {
app *App
uid string
total int
currentIdx int
currentRC io.ReadCloser
}
func (s *SequentialChunkReader) Read(p []byte) (n int, err error) {
if s.currentRC == nil {
if s.currentIdx >= s.total {
return 0, io.EOF
}
rc, err := s.app.openChunkDecryptor(s.uid, s.currentIdx)
if err != nil {
return 0, err
}
s.currentRC = rc
}
n, err = s.currentRC.Read(p)
if err == io.EOF {
_ = s.currentRC.Close()
s.currentRC = nil
s.currentIdx++
if n > 0 {
return n, nil
}
return s.Read(p)
}
return n, err
}
func (s *SequentialChunkReader) Close() error {
if s.currentRC != nil {
return s.currentRC.Close()
}
return nil
} }
func (app *App) encryptAndSave(src io.Reader, key []byte, finalPath string) error { func (app *App) encryptAndSave(src io.Reader, key []byte, finalPath string) error {
+16 -21
View File
@@ -172,7 +172,7 @@ func TestSaveChunk_EncryptsData(t *testing.T) {
} }
} }
func TestGetChunkDecryptors_RestoresData(t *testing.T) { func TestSequentialChunkReader_RestoresData(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
app := &App{ app := &App{
Conf: Config{StorageDir: tmpDir}, Conf: Config{StorageDir: tmpDir},
@@ -190,29 +190,24 @@ func TestGetChunkDecryptors_RestoresData(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
decryptors, closeFn, err := app.getChunkDecryptors(uid, 2) reader := &SequentialChunkReader{
if err != nil { app: app,
t.Fatalf("getChunkDecryptors failed: %v", err) uid: uid,
total: 2,
} }
defer closeFn() defer func() {
if err := reader.Close(); err != nil {
t.Errorf("Failed to close reader: %v", err)
}
}()
if len(decryptors) != 2 { restored, err := io.ReadAll(reader)
t.Fatalf("Expected 2 decryptors, got %d", len(decryptors)) if err != nil {
t.Fatalf("ReadAll failed: %v", err)
} }
buf1, err := io.ReadAll(decryptors[0]) expected := append(data1, data2...)
if err != nil { if !bytes.Equal(restored, expected) {
t.Fatalf("Failed to read decryptor 1: %v", err) t.Errorf("Restored data mismatch.\nWant: %s\nGot: %s", expected, restored)
}
if !bytes.Equal(buf1, data1) {
t.Errorf("Chunk 1 mismatch. Want %s, got %s", data1, buf1)
}
buf2, err := io.ReadAll(decryptors[1])
if err != nil {
t.Fatalf("Failed to read decryptor 2: %v", err)
}
if !bytes.Equal(buf2, data2) {
t.Errorf("Chunk 2 mismatch. Want %s, got %s", data2, buf2)
} }
} }
+83 -44
View File
@@ -3,12 +3,14 @@ package app
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"errors"
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"github.com/skidoodle/safebin/internal/crypto" "github.com/skidoodle/safebin/internal/crypto"
) )
@@ -19,20 +21,36 @@ func (app *App) HandleUpload(writer http.ResponseWriter, request *http.Request)
limit := (app.Conf.MaxMB * MegaByte) + MegaByte limit := (app.Conf.MaxMB * MegaByte) + MegaByte
request.Body = http.MaxBytesReader(writer, request.Body, limit) request.Body = http.MaxBytesReader(writer, request.Body, limit)
file, header, err := request.FormFile("file") mr, err := request.MultipartReader()
if err != nil { if err != nil {
if err.Error() == "http: request body too large" {
app.SendError(writer, request, http.StatusRequestEntityTooLarge)
return
}
app.SendError(writer, request, http.StatusBadRequest) app.SendError(writer, request, http.StatusBadRequest)
return return
} }
defer func() {
if closeErr := file.Close(); closeErr != nil { var filename string
app.Logger.Error("Failed to close upload file", "err", closeErr) var partReader io.Reader
for {
part, err := mr.NextPart()
if err == io.EOF {
break
} }
}() if err != nil {
app.SendError(writer, request, http.StatusBadRequest)
return
}
if part.FormName() == "file" {
filename = part.FileName()
partReader = part
break
}
}
if partReader == nil {
app.SendError(writer, request, http.StatusBadRequest)
return
}
tmp, err := os.CreateTemp(filepath.Join(app.Conf.StorageDir, TempDirName), "up_*") tmp, err := os.CreateTemp(filepath.Join(app.Conf.StorageDir, TempDirName), "up_*")
if err != nil { if err != nil {
@@ -58,37 +76,36 @@ func (app *App) HandleUpload(writer http.ResponseWriter, request *http.Request)
pr, pw := io.Pipe() pr, pw := io.Pipe()
hasher := sha256.New() hasher := sha256.New()
errChan := make(chan error, 1) errChan := make(chan error, 1)
go func() { go func() {
_, err := io.Copy(io.MultiWriter(hasher, pw), file) _, err := io.Copy(io.MultiWriter(hasher, pw), partReader)
_ = pw.CloseWithError(err) _ = pw.CloseWithError(err)
errChan <- err errChan <- err
}() }()
defer func() {
if closeErr := pr.Close(); closeErr != nil {
app.Logger.Error("Failed to close pipe reader", "err", closeErr)
}
}()
streamer, err := crypto.NewGCMStreamer(ephemeralKey) streamer, err := crypto.NewGCMStreamer(ephemeralKey)
if err != nil { if err != nil {
_ = pr.Close()
app.Logger.Error("Failed to create streamer", "err", err) app.Logger.Error("Failed to create streamer", "err", err)
app.SendError(writer, request, http.StatusInternalServerError) app.SendError(writer, request, http.StatusInternalServerError)
return return
} }
if err := streamer.EncryptStream(tmp, pr); err != nil { if err := streamer.EncryptStream(tmp, pr); err != nil {
_ = pr.Close()
app.Logger.Error("Failed to encrypt stream", "err", err) app.Logger.Error("Failed to encrypt stream", "err", err)
app.SendError(writer, request, http.StatusInternalServerError) app.SendError(writer, request, http.StatusInternalServerError)
return return
} }
if err := <-errChan; err != nil { if err := <-errChan; err != nil {
app.Logger.Error("Failed to read/hash upload", "err", err) if errors.Is(err, http.ErrMissingBoundary) || strings.Contains(err.Error(), "request body too large") {
app.SendError(writer, request, http.StatusRequestEntityTooLarge) app.SendError(writer, request, http.StatusRequestEntityTooLarge)
} else {
app.Logger.Error("Failed to read/hash upload", "err", err)
app.SendError(writer, request, http.StatusInternalServerError)
}
return return
} }
@@ -103,11 +120,12 @@ func (app *App) HandleUpload(writer http.ResponseWriter, request *http.Request)
info, _ := tmp.Stat() info, _ := tmp.Stat()
decryptor := crypto.NewDecryptor(tmp, streamer.AEAD, info.Size()) decryptor := crypto.NewDecryptor(tmp, streamer.AEAD, info.Size())
app.finalizeUpload(writer, request, decryptor, convergentKey, header.Filename) app.finalizeUpload(writer, request, decryptor, convergentKey, filename)
} }
func (app *App) HandleChunk(writer http.ResponseWriter, request *http.Request) { func (app *App) HandleChunk(writer http.ResponseWriter, request *http.Request) {
request.Body = http.MaxBytesReader(writer, request.Body, MaxRequestOverhead) const MaxChunkBody = UploadChunkSize + (1 << 20)
request.Body = http.MaxBytesReader(writer, request.Body, MaxChunkBody)
uid := request.FormValue("upload_id") uid := request.FormValue("upload_id")
idx, err := strconv.Atoi(request.FormValue("index")) idx, err := strconv.Atoi(request.FormValue("index"))
@@ -125,7 +143,7 @@ func (app *App) HandleChunk(writer http.ResponseWriter, request *http.Request) {
file, _, err := request.FormFile("chunk") file, _, err := request.FormFile("chunk")
if err != nil { if err != nil {
if err.Error() == "http: request body too large" { if strings.Contains(err.Error(), "request body too large") {
app.SendError(writer, request, http.StatusRequestEntityTooLarge) app.SendError(writer, request, http.StatusRequestEntityTooLarge)
return return
} }
@@ -159,42 +177,63 @@ func (app *App) HandleFinish(writer http.ResponseWriter, request *http.Request)
return return
} }
decryptors, closeAll, err := app.getChunkDecryptors(uid, total)
if err != nil {
app.Logger.Error("Failed to open chunks", "err", err)
app.SendError(writer, request, http.StatusInternalServerError)
return
}
defer func() { defer func() {
closeAll()
if err := os.RemoveAll(filepath.Join(app.Conf.StorageDir, TempDirName, uid)); err != nil { if err := os.RemoveAll(filepath.Join(app.Conf.StorageDir, TempDirName, uid)); err != nil {
app.Logger.Error("Failed to remove chunk dir", "err", err) app.Logger.Error("Failed to remove chunk dir", "err", err)
} }
}() }()
readers := make([]io.Reader, len(decryptors)) var totalSize int64
for i, d := range decryptors { for i := range total {
readers[i] = d info, err := os.Stat(filepath.Join(app.Conf.StorageDir, TempDirName, uid, strconv.Itoa(i)))
if err != nil {
app.Logger.Error("Missing chunk", "index", i, "err", err)
app.SendError(writer, request, http.StatusBadRequest)
return
}
chunkContentSize := info.Size() - crypto.KeySize
if chunkContentSize < 0 {
app.SendError(writer, request, http.StatusBadRequest)
return
}
totalSize += chunkContentSize
}
if totalSize > (app.Conf.MaxMB * MegaByte) {
app.Logger.Warn("Upload exceeded quota", "uid", uid, "size", totalSize)
app.SendError(writer, request, http.StatusRequestEntityTooLarge)
return
} }
hasher := sha256.New() hasher := sha256.New()
if _, err := io.Copy(hasher, io.MultiReader(readers...)); err != nil { for i := range total {
app.Logger.Error("Failed to hash chunks", "err", err) rc, err := app.openChunkDecryptor(uid, i)
app.SendError(writer, request, http.StatusInternalServerError) if err != nil {
return app.Logger.Error("Failed to open chunk for hashing", "index", i, "err", err)
app.SendError(writer, request, http.StatusInternalServerError)
return
}
if _, err := io.Copy(hasher, rc); err != nil {
_ = rc.Close()
app.Logger.Error("Failed to hash chunk", "index", i, "err", err)
app.SendError(writer, request, http.StatusInternalServerError)
return
}
_ = rc.Close()
} }
convergentKey := hasher.Sum(nil)[:crypto.KeySize] convergentKey := hasher.Sum(nil)[:crypto.KeySize]
for _, d := range decryptors { multiSrc := &SequentialChunkReader{
if _, err := d.Seek(0, io.SeekStart); err != nil { app: app,
app.Logger.Error("Failed to reset chunk decryptor", "err", err) uid: uid,
app.SendError(writer, request, http.StatusInternalServerError) total: total,
return
}
} }
defer func() {
multiSrc := io.MultiReader(readers...) if err := multiSrc.Close(); err != nil {
app.Logger.Error("Failed to close sequential reader", "uid", uid, "err", err)
}
}()
app.finalizeUpload(writer, request, multiSrc, convergentKey, request.FormValue("filename")) app.finalizeUpload(writer, request, multiSrc, convergentKey, request.FormValue("filename"))
} }
+9
View File
@@ -29,6 +29,15 @@
<div class="dim cli-label">CLI Usage</div> <div class="dim cli-label">CLI Usage</div>
<pre class="cli-pre">curl -F file=@yourfile {{.Host}}</pre> <pre class="cli-pre">curl -F file=@yourfile {{.Host}}</pre>
</section> </section>
<footer class="footer">
<div class="dim">
{{if eq .Version "dev"}}
<a href="https://github.com/skidoodle/safebin" target="_blank" rel="noopener noreferrer">dev</a>
{{else}}
<a href="https://github.com/skidoodle/safebin/releases/tag/v{{.Version}}" target="_blank" rel="noopener noreferrer">v{{.Version}}</a>
{{end}}
</div>
</footer>
</div> </div>
<input type="file" id="file-input" class="hidden" /> <input type="file" id="file-input" class="hidden" />
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
+16
View File
@@ -237,10 +237,26 @@ button {
display: none !important; display: none !important;
} }
.footer {
margin-top: 20px;
text-align: center;
opacity: 0.5;
}
.footer a {
color: inherit;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
@media (max-width: 400px) { @media (max-width: 400px) {
.github-btn span { .github-btn span {
display: none; display: none;
} }
.github-btn { .github-btn {
padding: 6px; padding: 6px;
} }