Files
bluebird/internal/app/upload.go
T

679 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"unicode"
"filippo.io/age"
"golang.org/x/sys/unix"
)
type ManifestFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
type Manifest struct {
Files []ManifestFile `json:"files"`
Days int `json:"days"`
Downloads int `json:"downloads"`
Password string `json:"password"`
Renew bool `json:"renew"`
}
func reserveSize(n int64, encrypted bool) int64 {
if encrypted {
n += 1024 + ((n/65536)+1)*16
}
return ((n+4095)/4096)*4096 + 4096
}
func cleanName(s string) string {
s = strings.ReplaceAll(s, "\\", "/")
s = filepath.Base(s)
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
return strings.TrimSpace(s)
}
func objectPath(id string) string { return "objects/" + id[:2] + "/" + id }
func (a *App) capacity() (used, reserved, metadata int64, err error) {
err = a.db.QueryRow("SELECT COALESCE(sum(stored_bytes),0) FROM objects").Scan(&used)
if err != nil {
return
}
err = a.db.QueryRow("SELECT COALESCE(sum(reserved),0) FROM files WHERE object_id IS NULL AND reserved>0 AND status!='deleted'").Scan(&reserved)
if err != nil {
return
}
for _, p := range []string{"db/bluebird.db", "db/bluebird.db-wal", "db/bluebird.db-shm"} {
s, e := a.root.Stat(p)
if e == nil {
metadata += allocated(s)
} else if !errors.Is(e, os.ErrNotExist) {
err = e
return
}
}
metadata += a.extraDisk
return
}
func allocated(s os.FileInfo) int64 {
if st, ok := s.Sys().(*syscall.Stat_t); ok {
return max(st.Blocks*512, s.Size())
}
if st, ok := s.Sys().(*unix.Stat_t); ok {
return max(st.Blocks*512, s.Size())
}
return ((s.Size() + 4095) / 4096) * 4096
}
func (a *App) createUpload(w http.ResponseWriter, r *http.Request) {
if !a.allow(r, "upload", 20) {
fail(w, 429, "上传请求过于频繁")
return
}
var m Manifest
if !decode(w, r, &m) {
return
}
a.mu.Lock()
defer a.mu.Unlock()
p, e := a.policy()
if e != nil {
a.serverError(w, e)
return
}
if !p.AnonymousUpload {
fail(w, 403, "管理员已关闭上传")
return
}
if m.Days == 0 {
m.Days = p.DefaultDays
}
if m.Downloads == 0 {
m.Downloads = p.DefaultDownloads
}
if m.Days < 1 || m.Days > p.MaxInitialDays || m.Downloads < 1 || m.Downloads > p.MaxDownloads || len(m.Files) < 1 || len(m.Files) > p.MaxFiles {
fail(w, 400, "文件数量、期限或下载次数超出策略")
return
}
if p.RequirePassword && m.Password == "" {
fail(w, 400, "当前策略要求取件密码")
return
}
hash := ""
if m.Password != "" {
if len(m.Password) < 8 || len(m.Password) > 128 {
fail(w, 400, "取件密码需为 8–128 字节")
return
}
select {
case a.passwordSlots <- struct{}{}:
default:
fail(w, 429, "密码验证繁忙")
return
}
hash, e = hashPassword(m.Password)
<-a.passwordSlots
if e != nil {
fail(w, 400, e.Error())
return
}
}
var total, reservation int64
for i, f := range m.Files {
m.Files[i].Name = cleanName(f.Name)
if m.Files[i].Name == "" || m.Files[i].Name == "." || m.Files[i].Name == ".." || len(m.Files[i].Name) > 240 || f.Size < 0 || f.Size > p.MaxFileBytes || total > p.MaxBatchBytes-f.Size {
fail(w, 400, "文件名、文件大小或批次总大小无效")
return
}
total += f.Size
reservation += reserveSize(f.Size, a.cfg.Encryption)
}
if total > p.MaxBatchBytes {
fail(w, 400, "批次总大小超出限制")
return
}
var count int
if e = a.db.QueryRow("SELECT count(*) FROM files").Scan(&count); e != nil {
a.serverError(w, e)
return
}
if count+len(m.Files) > a.cfg.MaxRecords {
fail(w, 507, "文件记录已满")
return
}
used, reserved, meta, e := a.capacity()
if e != nil {
a.serverError(w, e)
return
}
var disk unix.Statfs_t
if e = unix.Statfs(a.cfg.DataDir, &disk); e != nil {
a.serverError(w, e)
return
}
free := int64(disk.Bavail) * int64(disk.Bsize)
limits, e := a.runtimeSettings()
if e != nil {
a.serverError(w, e)
return
}
if used+reserved+reservation > limits.FileBudget || used+reserved+reservation+meta > limits.DataBudget || free-reserved-reservation < limits.MinFreeBytes || (disk.Files > 0 && disk.Ffree < uint64(len(m.Files)+32)) {
fail(w, 507, "可用存储不足,暂时无法接收文件")
return
}
visitor, e := a.visitor(w, r)
if e != nil {
fail(w, 429, "匿名会话暂不可用")
return
}
ip := a.remoteIP(r)
day := a.now().UTC().Format("2006-01-02")
subjects := []string{"ip:" + a.keyed(ip), "visitor:" + visitor}
tx, e := a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
for i, subject := range subjects {
if _, e = tx.Exec("INSERT OR IGNORE INTO usage VALUES(?,?,0,0)", subject, day); e != nil {
a.serverError(w, e)
return
}
var b int64
var n int
if e = tx.QueryRow("SELECT bytes,uploads FROM usage WHERE subject=? AND day=?", subject, day).Scan(&b, &n); e != nil {
a.serverError(w, e)
return
}
limit := p.IPDailyUploads
if i == 1 {
limit = p.SessionDailyUploads
}
if n >= limit || (i == 0 && b > p.IPDailyBytes-total) {
fail(w, 429, "今日上传次数或流量额度已用完")
return
}
if _, e = tx.Exec("UPDATE usage SET bytes=bytes+?,uploads=uploads+1 WHERE subject=? AND day=?", total, subject, day); e != nil {
a.serverError(w, e)
return
}
}
id, manage := randomID(), randomToken()
now := a.now().Unix()
renewal := 0
if m.Renew {
renewal = p.RenewalDays
}
_, e = tx.Exec("INSERT INTO parcels(id,visitor,ip,state,created,password_hash,manage_hash,days,downloads,renewal_days,renewal_mode,lifetime_days,upload_expires,usage_day) VALUES(?,?,?,'uploading',?,?,?,?,?,?,?,?,?,?)", id, visitor, ip, now, hash, tokenHash(manage), m.Days, m.Downloads, renewal, p.RenewalMode, p.MaxLifetimeDays, now+3600, day)
if e != nil {
a.serverError(w, e)
return
}
out := []map[string]any{}
for _, f := range m.Files {
fid := randomID()
if _, e = tx.Exec("INSERT INTO files(id,parcel,name,size,status,reserved,remaining,charged) VALUES(?,?,?,?,'pending',?,?,?)", fid, id, f.Name, f.Size, reserveSize(f.Size, a.cfg.Encryption), m.Downloads, f.Size); e != nil {
a.serverError(w, e)
return
}
out = append(out, map[string]any{"id": fid, "name": f.Name, "size": f.Size})
}
if e = tx.Commit(); e != nil {
a.serverError(w, e)
return
}
a.audit("upload.create", id, ip)
respond(w, 201, map[string]any{"id": id, "manage_token": manage, "files": out})
}
func (a *App) owner(r *http.Request, id string) bool {
var owner, manage string
if e := a.db.QueryRow("SELECT visitor,manage_hash FROM parcels WHERE id=?", id).Scan(&owner, &manage); e != nil {
return false
}
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if token != "" && tokenHash(token) == manage {
return true
}
v := a.currentVisitor(r)
return v != "" && v == owner
}
func (a *App) uploadFile(w http.ResponseWriter, r *http.Request) {
id, fid := r.PathValue("parcel"), r.PathValue("file")
ip := a.remoteIP(r)
if !a.allow(r, "file-transfer", 120) {
fail(w, 429, "文件传输请求过于频繁")
return
}
if !a.acquire("upload", ip) {
fail(w, 429, "上传并发已满,请稍后重试")
return
}
defer a.release("upload", ip)
a.deadlines(w, r)
a.mu.Lock()
if !a.owner(r, id) {
a.mu.Unlock()
fail(w, 404, "上传会话不存在")
return
}
var state, status string
var expected, reserved, expiry int64
e := a.db.QueryRow("SELECT p.state,f.status,f.size,f.reserved,p.upload_expires FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.id=? AND p.id=?", fid, id).Scan(&state, &status, &expected, &reserved, &expiry)
if e != nil || state != "uploading" || expiry <= a.now().Unix() {
a.mu.Unlock()
fail(w, 409, "上传会话已失效")
return
}
if status == "ready" {
a.mu.Unlock()
respond(w, 200, map[string]bool{"ready": true})
return
}
if a.activeFiles[fid] {
a.mu.Unlock()
fail(w, 409, "该文件正在上传")
return
}
var attempts int
var sessionIP, visitor, day string
if e = a.db.QueryRow("SELECT f.attempts,p.ip,p.visitor,p.usage_day FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.id=?", fid).Scan(&attempts, &sessionIP, &visitor, &day); e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
policy, pe := a.policy()
if pe != nil {
a.mu.Unlock()
a.serverError(w, pe)
return
}
if !policy.AnonymousUpload {
a.mu.Unlock()
fail(w, 403, "上传已关闭")
return
}
tx, e := a.db.Begin()
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
if attempts > 0 {
var charged int64
e = tx.QueryRow("SELECT bytes FROM usage WHERE subject=? AND day=?", "ip:"+a.keyed(sessionIP), day).Scan(&charged)
if e == nil && charged > policy.IPDailyBytes-expected {
tx.Rollback()
a.mu.Unlock()
fail(w, 429, "重试上传超过日流量限制")
return
}
if e == nil {
for _, subject := range []string{"ip:" + a.keyed(sessionIP), "visitor:" + visitor} {
_, e = tx.Exec("UPDATE usage SET bytes=bytes+? WHERE subject=? AND day=?", expected, subject, day)
if e != nil {
break
}
}
}
if e == nil {
_, e = tx.Exec("UPDATE files SET charged=charged+? WHERE id=?", expected, fid)
}
}
if e == nil {
_, e = tx.Exec("UPDATE files SET attempts=attempts+1 WHERE id=?", fid)
}
if e == nil {
e = tx.Commit()
} else {
tx.Rollback()
}
if e != nil {
a.mu.Unlock()
a.serverError(w, e)
return
}
a.activeFiles[fid] = true
a.mu.Unlock()
defer func() { a.mu.Lock(); delete(a.activeFiles, fid); a.mu.Unlock() }()
tmp := "tmp/" + fid
_ = a.root.Remove(tmp)
f, e := a.root.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if e != nil {
a.serverError(w, e)
return
}
defer a.root.Remove(tmp)
var dst io.Writer = f
var enc io.WriteCloser
if a.cfg.Encryption {
enc, e = age.Encrypt(f, a.identity.Recipient())
if e != nil {
f.Close()
a.serverError(w, e)
return
}
dst = enc
}
h := sha256.New()
r.Body = http.MaxBytesReader(w, r.Body, expected)
totalDeadline := time.Now().Add(time.Duration(a.cfg.MaxTransferSeconds) * time.Second)
if sessionEnd := time.Unix(expiry, 0); sessionEnd.Before(totalDeadline) {
totalDeadline = sessionEnd
}
src := &idleReader{reader: r.Body, controller: http.NewResponseController(w), total: totalDeadline, idle: time.Duration(a.cfg.TransferIdleSeconds) * time.Second}
received, e := io.CopyBuffer(io.MultiWriter(dst, h), src, make([]byte, 128<<10))
if e == nil && received != expected {
e = errors.New("实际大小与声明不一致")
}
if e == nil && enc != nil {
e = enc.Close()
}
if e == nil {
e = f.Sync()
}
st, se := f.Stat()
ce := f.Close()
if e == nil {
e = ce
}
if e == nil {
e = se
}
a.mu.Lock()
defer a.mu.Unlock()
if _, ue := a.db.Exec("UPDATE files SET received=received+? WHERE id=?", received, fid); ue != nil {
a.serverError(w, ue)
return
}
if e != nil {
fail(w, 400, "文件接收失败或大小与声明不一致,请重试")
return
}
stored := allocated(st)
if stored > reserved {
fail(w, 507, "文件实际落盘开销超出预留")
return
}
if e = a.db.QueryRow("SELECT state FROM parcels WHERE id=?", id).Scan(&state); e != nil || state != "uploading" || expiry <= a.now().Unix() {
fail(w, 409, "上传已取消")
return
}
keyID := "plain"
if a.cfg.Encryption {
keyID = tokenHash(a.identity.Recipient().String())
}
digest := a.keyed(hex.EncodeToString(h.Sum(nil)) + "/" + keyID)
if !a.cfg.Deduplication {
digest += "/" + randomID()
}
var oid string
e = a.db.QueryRow("SELECT id FROM objects WHERE digest=? AND size=?", digest, expected).Scan(&oid)
if e != nil && !errors.Is(e, sql.ErrNoRows) {
a.serverError(w, e)
return
}
newObject := oid == ""
committed := false
defer func() {
if newObject && oid != "" && !committed {
if de := a.root.Remove(objectPath(oid)); de != nil && !errors.Is(de, os.ErrNotExist) {
a.extraDisk += stored
a.log.Error("orphan removal failed", "error", de)
}
}
}()
if newObject {
oid = randomID()
path := objectPath(oid)
if e = a.root.MkdirAll(filepath.Dir(path), 0700); e != nil {
a.serverError(w, e)
return
}
if e = a.root.Rename(tmp, path); e != nil {
a.serverError(w, e)
return
}
if e = syncDir(a.root, filepath.Dir(path)); e != nil {
a.serverError(w, e)
return
}
if e = syncDir(a.root, "tmp"); e != nil {
a.serverError(w, e)
return
}
} else {
if _, e = a.root.Stat(objectPath(oid)); e != nil {
a.serverError(w, e)
return
}
if e = a.root.Remove(tmp); e != nil {
a.serverError(w, e)
return
}
}
tx, e = a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
if newObject {
_, e = tx.Exec("INSERT INTO objects VALUES(?,?,?,?,?,?,?)", oid, digest, expected, stored, a.cfg.Encryption, keyID, a.now().Unix())
if e != nil {
_ = a.root.Remove(objectPath(oid))
a.serverError(w, e)
return
}
}
var attached sql.Result
attached, e = tx.Exec("UPDATE files SET object_id=?,status='ready',reserved=0 WHERE id=? AND status='pending'", oid, fid)
if e == nil {
n, _ := attached.RowsAffected()
if n != 1 {
e = errors.New("文件已被管理员删除")
}
}
if e == nil {
e = tx.Commit()
}
if e != nil {
if newObject {
_ = a.root.Remove(objectPath(oid))
}
a.serverError(w, e)
return
}
committed = true
respond(w, 200, map[string]bool{"ready": true})
}
func (a *App) publish(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
var state string
var days, lifetime int
var uploadExpiry int64
e := a.db.QueryRow("SELECT state,days,lifetime_days,upload_expires FROM parcels WHERE id=?", id).Scan(&state, &days, &lifetime, &uploadExpiry)
if e != nil {
a.serverError(w, e)
return
}
if state == "ready" {
respond(w, 200, map[string]any{"published": true, "message": "已发布;如丢失取件码,请重新生成"})
return
}
policy, pe := a.policy()
if pe != nil {
a.serverError(w, pe)
return
}
if !policy.AnonymousUpload {
fail(w, 403, "上传已关闭")
return
}
if state != "uploading" || uploadExpiry <= a.now().Unix() {
fail(w, 409, "上传会话已失效")
return
}
var pending int
e = a.db.QueryRow("SELECT count(*) FROM files WHERE parcel=? AND status!='ready'", id).Scan(&pending)
if e != nil {
a.serverError(w, e)
return
}
if pending > 0 {
fail(w, 409, "尚有文件未上传完成")
return
}
now := a.now().Unix()
expires := now + int64(days)*86400
hard := now + int64(lifetime)*86400
code := pickupCode()
tx, e := a.db.Begin()
if e != nil {
a.serverError(w, e)
return
}
defer tx.Rollback()
_, e = tx.Exec("UPDATE parcels SET state='ready',created=?,expires=?,hard_expires=?,code_hash=? WHERE id=?", now, expires, hard, a.keyed(code), id)
if e == nil {
_, e = tx.Exec("UPDATE files SET expires=?,hard_expires=? WHERE parcel=?", expires, hard, id)
}
if e == nil {
e = tx.Commit()
}
if e != nil {
a.serverError(w, e)
return
}
if e = a.settleUsage(id); e != nil {
a.serverError(w, e)
return
}
a.audit("parcel.publish", id, a.remoteIP(r))
respond(w, 200, a.shareResult(r, code, expires))
}
func (a *App) shareResult(r *http.Request, code string, expires int64) map[string]any {
links := []map[string]string{}
for _, base := range a.cfg.accessOrigins() {
label := "公网HTTP"
if strings.HasPrefix(base, "https://") {
label = "公网HTTPS"
}
u, _ := url.Parse(base)
ip := net.ParseIP(u.Hostname())
if ip != nil && (ip.IsPrivate() || ip.IsLoopback()) {
label = "内网入口"
}
links = append(links, map[string]string{"label": label + " · " + base, "url": base + "/#/pickup/" + code})
}
return map[string]any{"code": code, "url": a.accessFor(r).base + "/#/pickup/" + code, "urls": links, "expires": expires}
}
func (a *App) regenerate(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
var expiry int64
if e := a.db.QueryRow("SELECT expires FROM parcels WHERE id=? AND state='ready' AND EXISTS(SELECT 1 FROM files WHERE parcel=parcels.id AND status='ready' AND remaining>0 AND expires>?)", id, a.now().Unix()).Scan(&expiry); e != nil || expiry <= a.now().Unix() {
fail(w, 409, "包裹不可取件")
return
}
code := pickupCode()
if _, e := a.db.Exec("UPDATE parcels SET code_hash=?,version=version+1 WHERE id=?", a.keyed(code), id); e != nil {
a.serverError(w, e)
return
}
respond(w, 200, a.shareResult(r, code, expiry))
}
func (a *App) revoke(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
if _, e := a.db.Exec("UPDATE parcels SET state='revoked',version=version+1 WHERE id=?", id); e != nil {
a.serverError(w, e)
return
}
a.audit("parcel.revoke", id, a.remoteIP(r))
respond(w, 200, map[string]bool{"revoked": true})
}
func (a *App) uploadStatus(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id := r.PathValue("parcel")
if !a.owner(r, id) {
fail(w, 404, "包裹不存在")
return
}
var state string
var created, expires int64
if e := a.db.QueryRow("SELECT state,created,expires FROM parcels WHERE id=?", id).Scan(&state, &created, &expires); e != nil {
a.serverError(w, e)
return
}
files, e := a.fileList(id)
if e != nil {
a.serverError(w, e)
return
}
respond(w, 200, map[string]any{"id": id, "state": state, "created": created, "expires": expires, "files": files})
}
func (a *App) mine(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
v := a.currentVisitor(r)
if v == "" {
respond(w, 200, []any{})
return
}
rows, e := a.db.Query("SELECT p.id,p.state,p.created,p.expires,(SELECT count(*) FROM files WHERE parcel=p.id),(SELECT COALESCE(sum(size),0) FROM files WHERE parcel=p.id) FROM parcels p WHERE visitor=? ORDER BY created DESC LIMIT 100", v)
if e != nil {
a.serverError(w, e)
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, state string
var created, expires, size int64
var n int
if e = rows.Scan(&id, &state, &created, &expires, &n, &size); e != nil {
a.serverError(w, e)
return
}
out = append(out, map[string]any{"id": id, "state": state, "created": created, "expires": expires, "count": n, "size": size})
}
if e = rows.Err(); e != nil {
a.serverError(w, e)
return
}
respond(w, 200, out)
}