package app import ( "database/sql" "errors" "io" "mime" "net/http" "strconv" "time" "filippo.io/age" ) func (a *App) resolve(w http.ResponseWriter, r *http.Request) { if !a.allow(r, "pickup", 10) { fail(w, 429, "取件尝试过于频繁") return } var in struct { Code string `json:"code"` Password string `json:"password"` } if !decode(w, r, &in) { return } code := normalizeCode(in.Code) if len(code) != 10 { fail(w, 404, "取件信息无效或已失效") return } p, e := a.policy() if e != nil { a.serverError(w, e) return } if !p.AnonymousDownload { fail(w, 403, "管理员已关闭取件") return } var id, hash string var version int now := a.now().Unix() e = a.db.QueryRow("SELECT id,password_hash,version FROM parcels WHERE code_hash=? AND state='ready' AND EXISTS(SELECT 1 FROM files WHERE parcel=parcels.id AND status='ready' AND remaining>0 AND expires>?)", a.keyed(code), now).Scan(&id, &hash, &version) if e != nil { if !errors.Is(e, sql.ErrNoRows) { a.serverError(w, e) return } fail(w, 404, "取件信息无效或已失效") return } if hash != "" { if in.Password == "" { respond(w, 401, map[string]any{"error": "请输入取件密码", "password_required": true}) return } select { case a.passwordSlots <- struct{}{}: default: fail(w, 429, "密码验证繁忙") return } valid := verifyPassword(in.Password, hash) <-a.passwordSlots if !valid { fail(w, 404, "取件信息无效或已失效") return } } a.mu.Lock() defer a.mu.Unlock() var current int e = a.db.QueryRow("SELECT version FROM parcels WHERE id=? AND state='ready' AND code_hash=?", id, a.keyed(code)).Scan(¤t) if e != nil || current != version { fail(w, 404, "取件信息无效或已失效") return } var count int if e = a.db.QueryRow("SELECT count(*) FROM grants").Scan(&count); e != nil { a.serverError(w, e) return } if count >= a.cfg.MaxRecords { fail(w, 429, "取件会话繁忙") return } token := randomToken() if _, e = a.db.Exec("INSERT INTO grants VALUES(?,?,?,?)", tokenHash(token), id, version, now+600); e != nil { a.serverError(w, e) return } a.cookie(w, r, "bb_pickup", token, "/api/v1/pickup", 600) a.pickup(w, r.Clone(r.Context()), id) } // Explicit parcel IDs are only used immediately after a validated code exchange. func (a *App) grant(r *http.Request) (string, error) { var id string e := a.db.QueryRow("SELECT p.id FROM grants g JOIN parcels p ON p.id=g.parcel WHERE g.token_hash=? AND g.expires>? AND p.state='ready' AND p.version=g.version", tokenHash(cookieValue(r, "bb_pickup")), a.now().Unix()).Scan(&id) return id, e } func (a *App) fileList(id string) ([]map[string]any, error) { rows, e := a.db.Query("SELECT id,name,size,status,remaining,expires FROM files WHERE parcel=? ORDER BY rowid", id) if e != nil { return nil, e } defer rows.Close() out := []map[string]any{} for rows.Next() { var fid, name, status string var size, expires int64 var remaining int if e = rows.Scan(&fid, &name, &size, &status, &remaining, &expires); e != nil { return nil, e } out = append(out, map[string]any{"id": fid, "name": name, "size": size, "status": status, "remaining": remaining, "expires": expires, "available": status == "ready" && remaining > 0 && expires > a.now().Unix()}) } return out, rows.Err() } func (a *App) pickup(w http.ResponseWriter, r *http.Request, explicit ...string) { id := "" if len(explicit) > 0 { id = explicit[0] } else { var e error id, e = a.grant(r) if e != nil { fail(w, 401, "取件授权已失效,请重新输入取件码") return } } p, e := a.policy() if e != nil { a.serverError(w, e) return } if !p.AnonymousDownload { fail(w, 403, "管理员已关闭取件") return } files, e := a.fileList(id) if e != nil { a.serverError(w, e) return } var renewal int var mode string if e = a.db.QueryRow("SELECT renewal_days,renewal_mode FROM parcels WHERE id=?", id).Scan(&renewal, &mode); e != nil { a.serverError(w, e) return } respond(w, 200, map[string]any{"id": id, "files": files, "renewal_days": renewal, "renewal_mode": mode}) } func (a *App) pickupHandler(w http.ResponseWriter, r *http.Request) { a.pickup(w, r) } type startWriter struct { dst http.ResponseWriter start func() error started bool bytes int64 controller *http.ResponseController total time.Time idle time.Duration } func (w *startWriter) Write(b []byte) (int, error) { if !w.started { if e := w.start(); e != nil { return 0, e } w.started = true } if w.controller != nil { end := time.Now().Add(w.idle) if end.After(w.total) { end = w.total } _ = w.controller.SetWriteDeadline(end) } n, e := w.dst.Write(b) w.bytes += int64(n) return n, e } func (a *App) download(w http.ResponseWriter, r *http.Request) { ip := a.remoteIP(r) if !a.acquire("download", ip) { fail(w, 429, "下载并发已满") return } defer a.release("download", ip) a.deadlines(w, r) a.mu.Lock() parcel, e := a.grant(r) if e != nil { a.mu.Unlock() fail(w, 401, "取件授权失效") return } policy, e := a.policy() if e != nil { a.mu.Unlock() a.serverError(w, e) return } if !policy.AnonymousDownload { a.mu.Unlock() fail(w, 403, "取件已关闭") return } fid := r.PathValue("file") var oid, name, status string var size, expiry int64 var remaining int var encrypted bool e = a.db.QueryRow("SELECT f.object_id,f.name,f.size,f.status,f.remaining,f.expires,o.encrypted FROM files f JOIN objects o ON o.id=f.object_id WHERE f.id=? AND f.parcel=?", fid, parcel).Scan(&oid, &name, &size, &status, &remaining, &expiry, &encrypted) if e != nil || status != "ready" || remaining < 1 || expiry <= a.now().Unix() { a.mu.Unlock() fail(w, 404, "文件已失效") return } file, e := a.root.Open(objectPath(oid)) if e != nil { a.mu.Unlock() fail(w, 404, "文件不可用") return } a.activeObjects[oid]++ defer func() { a.mu.Lock() _ = file.Close() a.activeObjects[oid]-- if a.activeObjects[oid] == 0 { delete(a.activeObjects, oid) } a.mu.Unlock() }() var reader io.Reader = file if encrypted { if a.identity == nil { a.mu.Unlock() a.serverError(w, errors.New("缺少私钥")) return } reader, e = age.Decrypt(file, a.identity) if e != nil { a.mu.Unlock() a.serverError(w, e) return } } if r.Method == "HEAD" { a.mu.Unlock() w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) w.WriteHeader(200) return } attempt := randomID() tx, e := a.db.Begin() if e != nil { a.mu.Unlock() a.serverError(w, e) return } result, e := tx.Exec("UPDATE files SET remaining=remaining-1 WHERE id=? AND remaining>0 AND status='ready' AND expires>?", fid, a.now().Unix()) if e == nil { n, _ := result.RowsAffected() if n != 1 { e = errors.New("次数或期限不足") } } if e == nil { _, e = tx.Exec("INSERT INTO downloads(id,file_id,created) VALUES(?,?,?)", attempt, fid, a.now().Unix()) } if e == nil { e = tx.Commit() } else { tx.Rollback() } if e != nil { a.mu.Unlock() a.serverError(w, e) return } a.mu.Unlock() w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name})) w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) w.Header().Set("Accept-Ranges", "none") writer := &startWriter{dst: w, controller: http.NewResponseController(w), total: time.Now().Add(time.Duration(a.cfg.MaxTransferSeconds) * time.Second), idle: time.Duration(a.cfg.TransferIdleSeconds) * time.Second, start: func() error { _, e := a.db.Exec("UPDATE downloads SET started=1 WHERE id=?", attempt); return e }} // A zero-byte download is still an accepted download and must consume a count. if size == 0 { var b [1]byte n, re := reader.Read(b[:]) if n != 0 || re != io.EOF { e = errors.New("空文件内容异常") } if e == nil { e = writer.start() } if e == nil { writer.started = true w.WriteHeader(200) } } else { _, e = io.CopyBuffer(writer, io.LimitReader(reader, size+1), make([]byte, 128<<10)) } a.mu.Lock() defer a.mu.Unlock() if !writer.started { a.refund(attempt, fid) w.Header().Del("Content-Length") w.Header().Del("Content-Disposition") a.serverError(w, errors.New("下载开始前失败")) return } if e == nil && writer.bytes == size { if e = a.completeDownload(attempt, fid, parcel); e != nil { a.log.Error("download completion failed", "error", e) } } else { a.log.Warn("download interrupted", "file", fid, "bytes", writer.bytes) } } func (a *App) refund(attempt, fid string) { tx, e := a.db.Begin() if e != nil { return } defer tx.Rollback() res, e := tx.Exec("UPDATE downloads SET refunded=1 WHERE id=? AND started=0 AND refunded=0", attempt) if e != nil { return } n, _ := res.RowsAffected() if n == 1 { _, e = tx.Exec("UPDATE files SET remaining=remaining+1 WHERE id=?", fid) } if e == nil { _ = tx.Commit() } } func (a *App) completeDownload(attempt, fid, parcel string) error { tx, e := a.db.Begin() if e != nil { return e } defer tx.Rollback() res, e := tx.Exec("UPDATE downloads SET completed=1 WHERE id=? AND completed=0", attempt) if e != nil { return e } n, _ := res.RowsAffected() if n == 0 { return tx.Commit() } var days, remaining int var mode, state, status string var expiry, hard int64 e = tx.QueryRow("SELECT p.renewal_days,p.renewal_mode,p.state,f.status,f.expires,f.hard_expires,f.remaining FROM files f JOIN parcels p ON p.id=f.parcel WHERE f.id=?", fid).Scan(&days, &mode, &state, &status, &expiry, &hard, &remaining) if e != nil { return e } if state == "ready" && status == "ready" && remaining > 0 && days > 0 { newExpiry := max(expiry, a.now().Unix()+int64(days)*86400) if mode == "add" { newExpiry = max(expiry, a.now().Unix()) + int64(days)*86400 } newExpiry = min(newExpiry, hard) if newExpiry > expiry { if _, e = tx.Exec("UPDATE files SET expires=? WHERE id=?", newExpiry, fid); e != nil { return e } if _, e = tx.Exec("UPDATE parcels SET expires=(SELECT max(expires) FROM files WHERE parcel=?) WHERE id=?", parcel, parcel); e != nil { return e } } } return tx.Commit() }