278 lines
8.8 KiB
Go
278 lines
8.8 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func validSHA256(value string) bool {
|
|
if len(value) != sha256.Size*2 || strings.ToLower(value) != value {
|
|
return false
|
|
}
|
|
_, err := hex.DecodeString(value)
|
|
return err == nil
|
|
}
|
|
|
|
func blobPath(root, hash string) string {
|
|
return filepath.Join(root, hash[:2], hash[2:4], hash)
|
|
}
|
|
|
|
func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request, scope authenticatedDevice) {
|
|
// Reserve a little multipart framing headroom while the file itself is
|
|
// measured independently. The stream is never materialized in memory.
|
|
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.Limits.MaxBlobBytes+(1<<20))
|
|
reader, err := r.MultipartReader()
|
|
if err != nil {
|
|
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "invalid multipart request")
|
|
return
|
|
}
|
|
part, err := reader.NextPart()
|
|
if errors.Is(err, io.EOF) {
|
|
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "file field is required")
|
|
return
|
|
}
|
|
if err != nil {
|
|
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "invalid multipart request")
|
|
return
|
|
}
|
|
defer part.Close()
|
|
if part.FormName() != "file" || part.FileName() == "" {
|
|
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "exactly one file field is required")
|
|
return
|
|
}
|
|
tmp, err := os.CreateTemp(s.blobsDir, ".upload-*")
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
tmpName := tmp.Name()
|
|
cleanupTmp := true
|
|
defer func() {
|
|
if cleanupTmp {
|
|
_ = os.Remove(tmpName)
|
|
}
|
|
}()
|
|
if err := tmp.Chmod(0640); err != nil {
|
|
_ = tmp.Close()
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
hash := sha256.New()
|
|
written, err := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(part, s.cfg.Limits.MaxBlobBytes+1))
|
|
if err != nil {
|
|
_ = tmp.Close()
|
|
var tooLarge *http.MaxBytesError
|
|
if errors.As(err, &tooLarge) {
|
|
jsonErrCode(w, http.StatusRequestEntityTooLarge, "blob_too_large", "blob is too large")
|
|
} else {
|
|
jsonInternalError(w, err)
|
|
}
|
|
return
|
|
}
|
|
if written > s.cfg.Limits.MaxBlobBytes {
|
|
_ = tmp.Close()
|
|
jsonErrCode(w, http.StatusRequestEntityTooLarge, "blob_too_large", "blob is too large")
|
|
return
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
// Do not silently accept a second part: this endpoint has one unambiguous
|
|
// file contract and no form fields that could hide extra request data.
|
|
if extra, err := reader.NextPart(); err != io.EOF {
|
|
if extra != nil {
|
|
_ = extra.Close()
|
|
}
|
|
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "exactly one file field is required")
|
|
return
|
|
}
|
|
sha := hex.EncodeToString(hash.Sum(nil))
|
|
if err := s.attachBlob(scope.UserID, scope.VaultID, sha, written, tmpName); err != nil {
|
|
if errors.Is(err, errBlobTooLarge) {
|
|
jsonErrCode(w, http.StatusRequestEntityTooLarge, "quota_exceeded", "blob quota exceeded")
|
|
return
|
|
}
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
jsonOK(w, map[string]interface{}{"sha256": sha, "size": written})
|
|
}
|
|
|
|
var errBlobTooLarge = errors.New("blob quota exceeded")
|
|
|
|
// attachBlob checks logical quotas before the rename, then atomically makes
|
|
// the content reachable and commits the scope reference. A DB error removes a
|
|
// newly-created physical file so rejected requests leave no blob behind.
|
|
func (s *Server) attachBlob(userID, vaultID, sha string, size int64, tmpName string) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
var existingSize int64
|
|
err = tx.QueryRow(`SELECT size FROM server_blob_refs WHERE user_id=? AND vault_id=? AND sha256=?`, userID, vaultID, sha).Scan(&existingSize)
|
|
if err == nil {
|
|
if existingSize != size {
|
|
return fmt.Errorf("existing blob reference has a different size")
|
|
}
|
|
if _, err := tx.Exec(`UPDATE server_blob_refs SET last_accessed=? WHERE user_id=? AND vault_id=? AND sha256=?`, time.Now().UTC().Format(time.RFC3339), userID, vaultID, sha); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
var vaultUsed, userUsed int64
|
|
if err := tx.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM server_blob_refs WHERE user_id=? AND vault_id=?`, userID, vaultID).Scan(&vaultUsed); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM server_blob_refs WHERE user_id=?`, userID).Scan(&userUsed); err != nil {
|
|
return err
|
|
}
|
|
if vaultUsed+size > s.cfg.Limits.MaxVaultBlobBytes || userUsed+size > s.cfg.Limits.MaxUserBlobBytes {
|
|
return errBlobTooLarge
|
|
}
|
|
destination := blobPath(s.blobsDir, sha)
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0750); err != nil {
|
|
return err
|
|
}
|
|
createdPhysical := false
|
|
if _, err := os.Stat(destination); errors.Is(err, os.ErrNotExist) {
|
|
if err := os.Rename(tmpName, destination); err != nil {
|
|
return err
|
|
}
|
|
createdPhysical = true
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)`, sha, size, now); err != nil {
|
|
if createdPhysical {
|
|
_ = os.Remove(destination)
|
|
}
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`INSERT INTO server_blob_refs (user_id, vault_id, sha256, size, created_at, last_accessed) VALUES (?, ?, ?, ?, ?, ?)`, userID, vaultID, sha, size, now, now); err != nil {
|
|
if createdPhysical {
|
|
_ = os.Remove(destination)
|
|
}
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
if createdPhysical {
|
|
_ = os.Remove(destination)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleBlobDownload(w http.ResponseWriter, r *http.Request, scope authenticatedDevice, sha string) {
|
|
if !validSHA256(sha) {
|
|
jsonErrCode(w, http.StatusBadRequest, "invalid_blob_hash", "invalid SHA-256")
|
|
return
|
|
}
|
|
var size int64
|
|
err := s.db.QueryRow(`SELECT size FROM server_blob_refs WHERE user_id=? AND vault_id=? AND sha256=?`, scope.UserID, scope.VaultID, sha).Scan(&size)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
jsonErrCode(w, http.StatusNotFound, "blob_not_found", "blob not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
file, err := os.Open(blobPath(s.blobsDir, sha))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
jsonErrCode(w, http.StatusNotFound, "blob_not_found", "blob not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
if !info.Mode().IsRegular() || info.Size() != size {
|
|
jsonErrCode(w, http.StatusInternalServerError, "blob_unavailable", "blob is unavailable")
|
|
return
|
|
}
|
|
if _, err := s.db.Exec(`UPDATE server_blob_refs SET last_accessed=? WHERE user_id=? AND vault_id=? AND sha256=?`, time.Now().UTC().Format(time.RFC3339), scope.UserID, scope.VaultID, sha); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+sha+"\"")
|
|
// ServeContent streams from the file and provides safe Range support.
|
|
http.ServeContent(w, r, sha, info.ModTime(), file)
|
|
}
|
|
|
|
// validateScopedBlobReferences ensures an accepted operation can later be
|
|
// restored by every authorized device in this scope. Legacy base64 binary
|
|
// payloads are deliberately rejected: binary content belongs in Blob storage,
|
|
// never in the operation log.
|
|
func validateScopedBlobReferences(tx *sql.Tx, scope authenticatedDevice, ops []syncPushOperation) (code, message string, err error) {
|
|
for _, op := range ops {
|
|
if op.EntityType != "file" || op.PayloadJSON == "" {
|
|
continue
|
|
}
|
|
var payload struct {
|
|
DataBase64 *string `json:"dataBase64"`
|
|
Blob *struct {
|
|
SHA256 string `json:"sha256"`
|
|
Size int64 `json:"size"`
|
|
} `json:"blob"`
|
|
}
|
|
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
|
return "invalid_payload", "operation payload_json must be valid JSON", nil
|
|
}
|
|
if payload.DataBase64 != nil {
|
|
return "inline_binary_forbidden", "binary file payload must reference a blob", nil
|
|
}
|
|
if payload.Blob == nil {
|
|
continue
|
|
}
|
|
if !validSHA256(payload.Blob.SHA256) || payload.Blob.Size < 0 {
|
|
return "invalid_blob_reference", "invalid blob reference", nil
|
|
}
|
|
var storedSize int64
|
|
err := tx.QueryRow(`SELECT size FROM server_blob_refs WHERE user_id=? AND vault_id=? AND sha256=?`,
|
|
scope.UserID, scope.VaultID, payload.Blob.SHA256).Scan(&storedSize)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "blob_not_owned", "blob must be uploaded before its operation", nil
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if storedSize != payload.Blob.Size {
|
|
return "invalid_blob_reference", "blob size does not match uploaded content", nil
|
|
}
|
|
}
|
|
return "", "", nil
|
|
}
|