WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
package resumable
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func respond(c *gin.Context, status int, value any, err error) {
|
||||
if err == nil {
|
||||
c.JSON(status, gin.H{"success": true, "data": value})
|
||||
return
|
||||
}
|
||||
message := publicError(err)
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
status = 404
|
||||
message = "上传不存在或不属于当前用户"
|
||||
case errors.Is(err, ErrForbidden):
|
||||
status = 403
|
||||
case errors.Is(err, ErrInvalid):
|
||||
status = 400
|
||||
case errors.Is(err, ErrConflict):
|
||||
status = 409
|
||||
case errors.Is(err, ErrQuota):
|
||||
status = 413
|
||||
default:
|
||||
var input UserError
|
||||
if errors.As(err, &input) {
|
||||
status = 400
|
||||
} else {
|
||||
status = 500
|
||||
logger.Errorf(c.Request.Context(), "Upload request failed: %v", err)
|
||||
}
|
||||
}
|
||||
c.AbortWithStatusJSON(status, gin.H{"success": false, "message": message})
|
||||
}
|
||||
|
||||
// Register requires the existing authenticated API group. Every operation also
|
||||
// resolves tenant and real-user identity and enforces upload ownership itself.
|
||||
func (s *Service) Register(group *gin.RouterGroup) {
|
||||
r := group.Group("/uploads")
|
||||
r.GET("", func(c *gin.Context) {
|
||||
rows, e := s.List(c.Request.Context(), c.Query("kind"), c.Query("target_id"))
|
||||
if e == nil && c.Query("view") == "summary" {
|
||||
for i := range rows {
|
||||
rows[i].Result = uploadResultSummary(rows[i].Result)
|
||||
}
|
||||
}
|
||||
respond(c, 200, rows, e)
|
||||
})
|
||||
r.POST("", func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 40<<10)
|
||||
var in CreateInput
|
||||
if c.ShouldBindJSON(&in) != nil {
|
||||
respond(c, 400, nil, ErrInvalid)
|
||||
return
|
||||
}
|
||||
row, e := s.Create(c.Request.Context(), in)
|
||||
respond(c, 201, row, e)
|
||||
})
|
||||
r.GET("/:upload_id", func(c *gin.Context) {
|
||||
row, e := s.Get(c.Request.Context(), c.Param("upload_id"))
|
||||
respond(c, 200, row, e)
|
||||
})
|
||||
r.GET("/:upload_id/chunks", func(c *gin.Context) {
|
||||
offset, e := strconv.ParseInt(c.DefaultQuery("offset", "0"), 10, 64)
|
||||
if e != nil {
|
||||
respond(c, 400, nil, ErrInvalid)
|
||||
return
|
||||
}
|
||||
chunks, e := s.Chunks(c.Request.Context(), c.Param("upload_id"), offset)
|
||||
respond(c, 200, chunks, e)
|
||||
})
|
||||
r.PUT("/:upload_id/chunks", func(c *gin.Context) {
|
||||
offset, e := strconv.ParseInt(c.GetHeader("Upload-Offset"), 10, 64)
|
||||
if e != nil {
|
||||
respond(c, 400, nil, ErrInvalid)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, ChunkSize)
|
||||
row, e := s.Put(c.Request.Context(), c.Param("upload_id"), offset, c.Request.ContentLength, c.GetHeader("Upload-Checksum"), c.Request.Body)
|
||||
respond(c, 200, row, e)
|
||||
})
|
||||
r.POST("/:upload_id/finalize", func(c *gin.Context) {
|
||||
row, e := s.Finalize(c.Request.Context(), c.Param("upload_id"))
|
||||
respond(c, 202, row, e)
|
||||
})
|
||||
r.DELETE("/:upload_id", func(c *gin.Context) {
|
||||
e := s.Cancel(c.Request.Context(), c.Param("upload_id"))
|
||||
respond(c, 200, gin.H{"cancelled": e == nil}, e)
|
||||
})
|
||||
}
|
||||
|
||||
// Shell polling needs progress and the extraction mode, not extracted source
|
||||
// text. Full results remain available on the owned upload's detail endpoint.
|
||||
func uploadResultSummary(result types.JSON) types.JSON {
|
||||
var summary struct {
|
||||
Selection string `json:"selection,omitempty"`
|
||||
}
|
||||
if json.Unmarshal(result, &summary) != nil || (summary.Selection != "full" && summary.Selection != "sampled") {
|
||||
return nil
|
||||
}
|
||||
encoded, _ := json.Marshal(summary)
|
||||
return types.JSON(encoded)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package resumable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUploadSummaryKeepsProgressAndScopeWithoutSourcePayload(t *testing.T) {
|
||||
s := uploadService(t)
|
||||
secretSource := strings.Repeat("private-source-body-", 5000)
|
||||
result, err := json.Marshal(map[string]any{"selection": "sampled", "source": map[string]string{"name": "source.txt", "text": secretSource}, "knowledge_ids": []string{"private-document"}})
|
||||
require.NoError(t, err)
|
||||
for _, fixture := range []struct {
|
||||
id, owner, target string
|
||||
tenant uint64
|
||||
}{
|
||||
{"owned", "alice", "allowed", 1}, {"other-user", "bob", "allowed", 1},
|
||||
{"other-tenant", "alice", "allowed", 2}, {"revoked", "alice", "revoked", 1},
|
||||
} {
|
||||
require.NoError(t, s.db.Create(&Upload{ID: fixture.id, TenantID: fixture.tenant, OwnerID: fixture.owner,
|
||||
Kind: "digital-human", TargetID: fixture.target, Name: fixture.id + ".txt", Size: 100,
|
||||
Offset: 100, IngestOffset: 100, State: "completed", Fingerprint: "fingerprint",
|
||||
Options: types.JSON(`{"purpose":"distillation"}`), Result: result, ExpiresAt: time.Now().Add(time.Hour)}).Error)
|
||||
}
|
||||
s.authorize = func(_ context.Context, _, target string) error {
|
||||
if target == "revoked" {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
router := gin.New()
|
||||
group := router.Group("/api/v1", func(c *gin.Context) { c.Request = c.Request.WithContext(uploadContext(1, "alice")) })
|
||||
s.Register(group)
|
||||
request := func(route string) []byte {
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest("GET", route, nil))
|
||||
require.Equal(t, 200, response.Code)
|
||||
return response.Body.Bytes()
|
||||
}
|
||||
full := request("/api/v1/uploads")
|
||||
summary := request("/api/v1/uploads?view=summary")
|
||||
var envelope struct {
|
||||
Data []Upload `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(summary, &envelope))
|
||||
require.Len(t, envelope.Data, 1)
|
||||
row := envelope.Data[0]
|
||||
require.Equal(t, "owned", row.ID)
|
||||
require.Equal(t, "completed", row.State)
|
||||
require.EqualValues(t, 100, row.Offset)
|
||||
require.EqualValues(t, 100, row.IngestOffset)
|
||||
require.Equal(t, "fingerprint", row.Fingerprint)
|
||||
require.JSONEq(t, `{"purpose":"distillation"}`, string(row.Options))
|
||||
require.JSONEq(t, `{"selection":"sampled"}`, string(row.Result))
|
||||
require.NotContains(t, string(summary), "private-source-body")
|
||||
require.NotContains(t, string(summary), "private-document")
|
||||
require.Less(t, len(summary)*10, len(full))
|
||||
require.Contains(t, string(full), secretSource)
|
||||
require.Contains(t, string(request("/api/v1/uploads/owned")), secretSource)
|
||||
stored, err := s.Get(uploadContext(1, "alice"), "owned")
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(result), string(stored.Result))
|
||||
}
|
||||
|
||||
func TestUploadResultSummaryOmitsUnknownOrInvalidPayload(t *testing.T) {
|
||||
for _, raw := range []string{`{}`, `null`, `[]`, `{"selection":"unexpected","source":{"text":"private"}}`, `{"selection":3}`, `invalid`} {
|
||||
require.Nil(t, uploadResultSummary(types.JSON(raw)), raw)
|
||||
}
|
||||
require.JSONEq(t, `{"selection":"full"}`, string(uploadResultSummary(types.JSON(`{"selection":"full","source":{"text":"private"}}`))))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package resumable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MaxFileSize int64 = 20 << 30
|
||||
const ChunkSize int64 = 4 << 20
|
||||
const UserQuota int64 = 40 << 30
|
||||
const TenantQuota int64 = 100 << 30
|
||||
const MaxActive = 20
|
||||
|
||||
var ErrInvalid = errors.New("上传参数或校验和无效")
|
||||
var ErrConflict = errors.New("上传状态或分块偏移已变化,请刷新后继续")
|
||||
var ErrQuota = errors.New("上传暂存配额不足,请取消其他上传或等待过期清理")
|
||||
var ErrForbidden = errors.New("没有此上传或目标资源的权限")
|
||||
|
||||
// UserError contains only an intentionally public explanation written by the
|
||||
// upload implementation. Driver, filesystem and provider errors are never
|
||||
// promoted to this type and remain confined to server logs.
|
||||
type UserError string
|
||||
|
||||
func (e UserError) Error() string { return string(e) }
|
||||
|
||||
func publicError(err error) string {
|
||||
var public UserError
|
||||
if errors.As(err, &public) {
|
||||
return public.Error()
|
||||
}
|
||||
var input *types.DigitalHumanInputError
|
||||
if errors.As(err, &input) {
|
||||
return input.Error()
|
||||
}
|
||||
for _, sentinel := range []error{ErrInvalid, ErrConflict, ErrQuota, ErrForbidden} {
|
||||
if errors.Is(err, sentinel) {
|
||||
return sentinel.Error()
|
||||
}
|
||||
}
|
||||
return "文件处理暂不可用,请稍后重试或联系管理员检查服务日志"
|
||||
}
|
||||
|
||||
type Upload struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
|
||||
TenantID uint64 `json:"-" gorm:"index"`
|
||||
OwnerID string `json:"-" gorm:"type:varchar(36);index"`
|
||||
Kind string `json:"kind"`
|
||||
TargetID string `json:"target_id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Offset int64 `json:"offset"`
|
||||
ChunkSize int64 `json:"chunk_size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
State string `json:"state" gorm:"index"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Options types.JSON `json:"options" gorm:"type:jsonb"`
|
||||
Result types.JSON `json:"result,omitempty" gorm:"type:jsonb"`
|
||||
IngestOffset int64 `json:"ingest_offset"`
|
||||
Part int `json:"part"`
|
||||
ExpiresAt time.Time `json:"expires_at" gorm:"index"`
|
||||
LeaseUntil *time.Time `json:"-"`
|
||||
LeaseToken string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Upload) TableName() string { return "resumable_uploads" }
|
||||
|
||||
type Chunk struct {
|
||||
UploadID string `json:"-" gorm:"primaryKey;type:varchar(36)"`
|
||||
Offset int64 `json:"offset" gorm:"primaryKey"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
func (Chunk) TableName() string { return "resumable_upload_chunks" }
|
||||
|
||||
type CreateInput struct {
|
||||
Kind string `json:"kind"`
|
||||
TargetID string `json:"target_id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Options types.JSON `json:"options"`
|
||||
}
|
||||
|
||||
// Import processes one bounded unit, returning a durable checkpoint. The worker
|
||||
// renews the lease around each unit and never queues the entire file at once.
|
||||
type ImportFunc func(context.Context, *Upload, string) (offset int64, result types.JSON, done bool, err error)
|
||||
type ContextFunc func(context.Context, *Upload) (context.Context, error)
|
||||
type AuthorizeFunc func(context.Context, string, string) error
|
||||
@@ -0,0 +1,335 @@
|
||||
package resumable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
root string
|
||||
authorize AuthorizeFunc
|
||||
workerContext ContextFunc
|
||||
ingest ImportFunc
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, root string, authorize AuthorizeFunc, workerContext ContextFunc, ingest ImportFunc) (*Service, error) {
|
||||
if root == "" {
|
||||
root = filepath.Join("data", "resumable-uploads")
|
||||
}
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.MkdirAll(root, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{db: db, root: root, authorize: authorize, workerContext: workerContext, ingest: ingest}, nil
|
||||
}
|
||||
|
||||
func identity(ctx context.Context) (uint64, string, error) {
|
||||
t, ok := types.TenantIDFromContext(ctx)
|
||||
u, userOK := types.UserIDFromContext(ctx)
|
||||
if !ok || t == 0 || !userOK || u == "" || types.IsSyntheticUserID(u) {
|
||||
return 0, "", ErrForbidden
|
||||
}
|
||||
return t, u, nil
|
||||
}
|
||||
func (s *Service) scoped(ctx context.Context) (*gorm.DB, error) {
|
||||
t, u, e := identity(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return s.db.WithContext(ctx).Where("tenant_id=? AND owner_id=?", t, u), nil
|
||||
}
|
||||
func validHash(v string) bool {
|
||||
b, e := hex.DecodeString(v)
|
||||
return e == nil && len(b) == 32 && strings.ToLower(v) == v
|
||||
}
|
||||
func (s *Service) path(id string) string { return filepath.Join(s.root, id+".upload") }
|
||||
|
||||
func (s *Service) Create(ctx context.Context, in CreateInput) (*Upload, error) {
|
||||
t, u, e := identity(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if (in.Kind != "knowledge" && in.Kind != "digital-human") || len(in.TargetID) > 36 || in.TargetID == "" || in.Size < 1 || in.Size > MaxFileSize || len(in.Name) > 500 || strings.TrimSpace(in.Name) == "" || strings.ContainsAny(in.Name, "\x00\r\n") || len(in.Fingerprint) > 128 || (in.SHA256 != "" && !validHash(in.SHA256)) || len(in.Options) > 32<<10 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
if s.authorize == nil {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if e = s.authorize(ctx, in.Kind, in.TargetID); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(in.Options) == 0 {
|
||||
in.Options = types.JSON(`{}`)
|
||||
}
|
||||
row := &Upload{ID: uuid.NewString(), TenantID: t, OwnerID: u, Kind: in.Kind, TargetID: in.TargetID, Name: filepath.Base(in.Name), Size: in.Size, ChunkSize: ChunkSize, SHA256: in.SHA256, Fingerprint: in.Fingerprint, Options: in.Options, Result: types.JSON(`{}`), State: "uploading", ExpiresAt: time.Now().Add(7 * 24 * time.Hour)}
|
||||
e = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Serialize reservations across every user in this tenant.
|
||||
var tenant types.Tenant
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", t).Take(&tenant).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
var totals struct {
|
||||
TenantBytes int64
|
||||
UserBytes int64
|
||||
Active int64
|
||||
}
|
||||
e := tx.Model(&Upload{}).Select("COALESCE(SUM(size),0) AS tenant_bytes, COALESCE(SUM(CASE WHEN owner_id=? THEN size ELSE 0 END),0) AS user_bytes, COALESCE(SUM(CASE WHEN owner_id=? AND state IN ('uploading','queued','verifying','processing') THEN 1 ELSE 0 END),0) AS active", u, u).Where("tenant_id=? AND state NOT IN ('cancelled','expired')", t).Scan(&totals).Error
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if totals.Active >= MaxActive || totals.UserBytes+in.Size > UserQuota || totals.TenantBytes+in.Size > TenantQuota {
|
||||
return ErrQuota
|
||||
}
|
||||
if tenant.StorageQuota > 0 && tenant.StorageUsed+totals.TenantBytes+in.Size > tenant.StorageQuota {
|
||||
return ErrQuota
|
||||
}
|
||||
return tx.Create(row).Error
|
||||
})
|
||||
return row, e
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id string) (*Upload, error) {
|
||||
q, e := s.scoped(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var row Upload
|
||||
e = q.Where("id=?", id).Take(&row).Error
|
||||
if e == nil {
|
||||
if s.authorize == nil {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if e = s.authorize(ctx, row.Kind, row.TargetID); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
if e == nil && row.ExpiresAt.Before(time.Now()) && row.State == "uploading" {
|
||||
row.State = "expired"
|
||||
}
|
||||
return &row, e
|
||||
}
|
||||
|
||||
// Chunks exposes bounded digests so a reselected local file can verify every
|
||||
// committed byte before sending its remaining suffix.
|
||||
func (s *Service) Chunks(ctx context.Context, id string, offset int64) ([]Chunk, error) {
|
||||
row, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if offset < 0 || offset > row.Offset || row.ChunkSize <= 0 || (offset != row.Offset && offset%row.ChunkSize != 0) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
chunks := []Chunk{}
|
||||
err = s.db.WithContext(ctx).Where("upload_id=? AND \"offset\">=? AND \"offset\"<?", row.ID, offset, row.Offset).Order("\"offset\"").Limit(128).Find(&chunks).Error
|
||||
return chunks, err
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, kind, target string) ([]Upload, error) {
|
||||
q, e := s.scoped(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
rows := []Upload{}
|
||||
q = q.Where("state NOT IN ('cancelled','expired')")
|
||||
if kind != "" {
|
||||
q = q.Where("kind=?", kind)
|
||||
}
|
||||
if target != "" {
|
||||
q = q.Where("target_id=?", target)
|
||||
}
|
||||
e = q.Order("created_at DESC").Limit(100).Find(&rows).Error
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
visible := make([]Upload, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if s.authorize != nil && s.authorize(ctx, row.Kind, row.TargetID) == nil {
|
||||
visible = append(visible, row)
|
||||
}
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// Put uses a bounded buffer and fsyncs before committing the offset. A retry is
|
||||
// accepted only for the exact persisted chunk (including size and checksum).
|
||||
func (s *Service) Put(ctx context.Context, id string, offset, size int64, checksum string, body io.Reader) (*Upload, error) {
|
||||
if offset < 0 || size < 1 || size > ChunkSize || !validHash(checksum) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
t, u, e := identity(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var row Upload
|
||||
e = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND tenant_id=? AND owner_id=?", id, t, u).Take(&row).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if row.State != "uploading" || row.ExpiresAt.Before(time.Now()) {
|
||||
return ErrConflict
|
||||
}
|
||||
if s.authorize == nil {
|
||||
return ErrForbidden
|
||||
}
|
||||
if e := s.authorize(ctx, row.Kind, row.TargetID); e != nil {
|
||||
return e
|
||||
}
|
||||
if row.ChunkSize <= 0 || offset%row.ChunkSize != 0 || size != min(row.ChunkSize, row.Size-offset) {
|
||||
return ErrInvalid
|
||||
}
|
||||
if offset < row.Offset {
|
||||
var chunk Chunk
|
||||
if e := tx.Where("upload_id=? AND \"offset\"=?", id, offset).Take(&chunk).Error; e != nil {
|
||||
return ErrConflict
|
||||
}
|
||||
if chunk.Size != size || chunk.SHA256 != checksum {
|
||||
return ErrConflict
|
||||
}
|
||||
h := sha256.New()
|
||||
n, e := io.Copy(h, io.LimitReader(body, size+1))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if n != size || hex.EncodeToString(h.Sum(nil)) != checksum {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if offset != row.Offset || offset+size > row.Size {
|
||||
return ErrConflict
|
||||
}
|
||||
f, e := os.OpenFile(s.path(id), os.O_CREATE|os.O_RDWR, 0600)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer f.Close()
|
||||
stat, e := f.Stat()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if stat.Size() < offset {
|
||||
return fmt.Errorf("上传暂存文件缺失,请重新上传")
|
||||
}
|
||||
if e = f.Truncate(offset); e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e = f.Seek(offset, io.SeekStart); e != nil {
|
||||
return e
|
||||
}
|
||||
h := sha256.New()
|
||||
n, e := io.Copy(io.MultiWriter(f, h), io.LimitReader(body, size+1))
|
||||
if e != nil || n != size || hex.EncodeToString(h.Sum(nil)) != checksum {
|
||||
_ = f.Truncate(offset)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return ErrInvalid
|
||||
}
|
||||
if e = f.Sync(); e != nil {
|
||||
return e
|
||||
}
|
||||
if e = tx.Create(&Chunk{UploadID: id, Offset: offset, Size: size, SHA256: checksum}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
row.Offset += size
|
||||
row.ExpiresAt = time.Now().Add(7 * 24 * time.Hour)
|
||||
return tx.Model(&row).Updates(map[string]any{"offset": row.Offset, "expires_at": row.ExpiresAt, "updated_at": time.Now()}).Error
|
||||
})
|
||||
return &row, e
|
||||
}
|
||||
|
||||
func (s *Service) Finalize(ctx context.Context, id string) (*Upload, error) {
|
||||
t, u, e := identity(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var row Upload
|
||||
e = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND tenant_id=? AND owner_id=?", id, t, u).Take(&row).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e := s.authorize(ctx, row.Kind, row.TargetID); e != nil {
|
||||
return e
|
||||
}
|
||||
if row.State == "queued" || row.State == "verifying" || row.State == "processing" || row.State == "completed" {
|
||||
return nil
|
||||
}
|
||||
if (row.State != "uploading" && row.State != "failed") || row.Offset != row.Size || row.ExpiresAt.Before(time.Now()) {
|
||||
return ErrConflict
|
||||
}
|
||||
row.State = "queued"
|
||||
row.Error = ""
|
||||
row.ExpiresAt = time.Now().Add(7 * 24 * time.Hour)
|
||||
return tx.Model(&row).Updates(map[string]any{"state": row.State, "error": "", "expires_at": row.ExpiresAt, "updated_at": time.Now()}).Error
|
||||
})
|
||||
return &row, e
|
||||
}
|
||||
func (s *Service) Cancel(ctx context.Context, id string) error {
|
||||
t, u, e := identity(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var row Upload
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND tenant_id=? AND owner_id=?", id, t, u).Take(&row).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if row.State == "verifying" || row.State == "processing" {
|
||||
return ErrConflict
|
||||
}
|
||||
if e := os.Remove(s.path(id)); e != nil && !errors.Is(e, os.ErrNotExist) {
|
||||
return e
|
||||
}
|
||||
if e := tx.Where("upload_id=?", id).Delete(&Chunk{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
return tx.Model(&row).Updates(map[string]any{"state": "cancelled", "result": types.JSON(`{}`), "updated_at": time.Now()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Cleanup(ctx context.Context) error {
|
||||
var rows []Upload
|
||||
if e := s.db.WithContext(ctx).Where("expires_at < ? AND (lease_until IS NULL OR lease_until < ?) AND state NOT IN ('expired','cancelled')", time.Now(), time.Now()).Limit(100).Find(&rows).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
for _, row := range rows {
|
||||
if e := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current Upload
|
||||
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", row.ID).Take(¤t).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if current.ExpiresAt.After(time.Now()) || (current.LeaseUntil != nil && current.LeaseUntil.After(time.Now())) {
|
||||
return nil
|
||||
}
|
||||
if e := os.Remove(s.path(row.ID)); e != nil && !errors.Is(e, os.ErrNotExist) {
|
||||
return e
|
||||
}
|
||||
if e := tx.Where("upload_id=?", row.ID).Delete(&Chunk{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
return tx.Model(¤t).Updates(map[string]any{"state": "expired", "result": types.JSON(`{}`), "updated_at": time.Now()}).Error
|
||||
}); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package resumable
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func uploadContext(tenant uint64, user string) context.Context {
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, tenant)
|
||||
return context.WithValue(ctx, types.UserIDContextKey, user)
|
||||
}
|
||||
func uploadService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
db, e := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "uploads.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, e)
|
||||
require.NoError(t, db.AutoMigrate(&Upload{}, &Chunk{}))
|
||||
require.NoError(t, db.Exec("CREATE TABLE tenants(id INTEGER PRIMARY KEY,storage_quota BIGINT,storage_used BIGINT,deleted_at DATETIME)").Error)
|
||||
require.NoError(t, db.Exec("INSERT INTO tenants(id,storage_quota,storage_used) VALUES(1,0,0),(2,0,0)").Error)
|
||||
sql, e := db.DB()
|
||||
require.NoError(t, e)
|
||||
sql.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sql.Close() })
|
||||
s, e := NewService(db, t.TempDir(), func(context.Context, string, string) error { return nil }, func(ctx context.Context, _ *Upload) (context.Context, error) { return ctx, nil }, func(_ context.Context, u *Upload, _ string) (int64, types.JSON, bool, error) {
|
||||
return u.Size, types.JSON(`{"ok":true}`), true, nil
|
||||
})
|
||||
require.NoError(t, e)
|
||||
return s
|
||||
}
|
||||
func sum(data []byte) string { h := sha256.Sum256(data); return hex.EncodeToString(h[:]) }
|
||||
func create(t *testing.T, s *Service, data []byte) *Upload {
|
||||
t.Helper()
|
||||
u, e := s.Create(uploadContext(1, "alice"), CreateInput{Kind: "knowledge", TargetID: "kb", Name: "fixture.txt", Size: int64(len(data)), SHA256: sum(data)})
|
||||
require.NoError(t, e)
|
||||
return u
|
||||
}
|
||||
|
||||
func TestUploadResumeChecksumOwnershipAndCrashRecovery(t *testing.T) {
|
||||
s := uploadService(t)
|
||||
ctx := uploadContext(1, "alice")
|
||||
data := []byte("abcdef")
|
||||
u := create(t, s, data)
|
||||
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Update("chunk_size", 3).Error)
|
||||
_, e := s.Put(ctx, u.ID, 0, 3, sum([]byte("wrong")), bytes.NewReader(data[:3]))
|
||||
require.ErrorIs(t, e, ErrInvalid)
|
||||
row, e := s.Get(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
require.Zero(t, row.Offset)
|
||||
_, e = s.Put(uploadContext(1, "bob"), u.ID, 0, 3, sum(data[:3]), bytes.NewReader(data[:3]))
|
||||
require.ErrorIs(t, e, gorm.ErrRecordNotFound)
|
||||
_, e = s.Get(uploadContext(2, "alice"), u.ID)
|
||||
require.ErrorIs(t, e, gorm.ErrRecordNotFound)
|
||||
row, e = s.Put(ctx, u.ID, 0, 3, sum(data[:3]), bytes.NewReader(data[:3]))
|
||||
require.NoError(t, e)
|
||||
require.EqualValues(t, 3, row.Offset)
|
||||
chunks, e := s.Chunks(ctx, u.ID, 0)
|
||||
require.NoError(t, e)
|
||||
require.Len(t, chunks, 1)
|
||||
require.Equal(t, sum(data[:3]), chunks[0].SHA256)
|
||||
_, e = s.Chunks(uploadContext(1, "bob"), u.ID, 0)
|
||||
require.ErrorIs(t, e, gorm.ErrRecordNotFound)
|
||||
_, e = s.Chunks(ctx, u.ID, 1)
|
||||
require.ErrorIs(t, e, ErrInvalid)
|
||||
// A response lost after commit may replay exactly the same chunk.
|
||||
row, e = s.Put(ctx, u.ID, 0, 3, sum(data[:3]), bytes.NewReader(data[:3]))
|
||||
require.NoError(t, e)
|
||||
require.EqualValues(t, 3, row.Offset)
|
||||
_, e = s.Put(ctx, u.ID, 0, 3, sum(data[3:]), bytes.NewReader(data[3:]))
|
||||
require.ErrorIs(t, e, ErrConflict)
|
||||
_, e = s.Finalize(ctx, u.ID)
|
||||
require.ErrorIs(t, e, ErrConflict)
|
||||
// Bytes written before a killed transaction must not survive the resume.
|
||||
f, e := os.OpenFile(s.path(u.ID), os.O_APPEND|os.O_WRONLY, 0600)
|
||||
require.NoError(t, e)
|
||||
_, e = f.WriteString("uncommitted")
|
||||
require.NoError(t, e)
|
||||
require.NoError(t, f.Close())
|
||||
restarted, e := NewService(s.db, s.root, s.authorize, s.workerContext, s.ingest)
|
||||
require.NoError(t, e)
|
||||
row, e = restarted.Put(ctx, u.ID, 3, 3, sum(data[3:]), bytes.NewReader(data[3:]))
|
||||
require.NoError(t, e)
|
||||
require.EqualValues(t, 6, row.Offset)
|
||||
stored, e := os.ReadFile(s.path(u.ID))
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, data, stored)
|
||||
row, e = s.Finalize(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, "queued", row.State)
|
||||
_, e = s.Finalize(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
worked, e := s.ProcessNext(context.Background())
|
||||
require.NoError(t, e)
|
||||
require.True(t, worked)
|
||||
row, e = s.Get(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, "completed", row.State)
|
||||
require.EqualValues(t, 6, row.IngestOffset)
|
||||
}
|
||||
|
||||
func TestUploadBoundsQuotaCancelAndExpiry(t *testing.T) {
|
||||
s := uploadService(t)
|
||||
ctx := uploadContext(1, "alice")
|
||||
in := CreateInput{Kind: "knowledge", TargetID: "kb", Name: "large.txt", Size: MaxFileSize + 1}
|
||||
_, e := s.Create(ctx, in)
|
||||
require.ErrorIs(t, e, ErrInvalid)
|
||||
in.Size = MaxFileSize
|
||||
first, e := s.Create(ctx, in)
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, MaxFileSize, first.Size)
|
||||
_, e = s.Create(ctx, in)
|
||||
require.NoError(t, e)
|
||||
in.Size = 1
|
||||
_, e = s.Create(ctx, in)
|
||||
require.ErrorIs(t, e, ErrQuota)
|
||||
require.NoError(t, s.Cancel(ctx, first.ID))
|
||||
small, e := s.Create(ctx, in)
|
||||
require.NoError(t, e)
|
||||
_, e = s.Put(ctx, small.ID, 0, ChunkSize+1, strings.Repeat("a", 64), strings.NewReader("x"))
|
||||
require.ErrorIs(t, e, ErrInvalid)
|
||||
_, e = s.Put(ctx, small.ID, 0, 1, sum([]byte("x")), strings.NewReader("xx"))
|
||||
require.ErrorIs(t, e, ErrInvalid)
|
||||
_, e = s.Put(ctx, small.ID, 0, 1, sum([]byte("x")), strings.NewReader("x"))
|
||||
require.NoError(t, e)
|
||||
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", small.ID).Update("expires_at", time.Now().Add(-time.Hour)).Error)
|
||||
_, e = s.Finalize(ctx, small.ID)
|
||||
require.ErrorIs(t, e, ErrConflict)
|
||||
require.NoError(t, s.Cleanup(ctx))
|
||||
_, e = os.Stat(s.path(small.ID))
|
||||
require.True(t, os.IsNotExist(e))
|
||||
row, e := s.Get(ctx, small.ID)
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, "expired", row.State)
|
||||
var chunks int64
|
||||
require.NoError(t, s.db.Model(&Chunk{}).Where("upload_id=?", small.ID).Count(&chunks).Error)
|
||||
require.Zero(t, chunks)
|
||||
}
|
||||
|
||||
func TestUploadWorkerChecksWholeFileAndReauthorizes(t *testing.T) {
|
||||
for _, scenario := range []string{"corruption", "revoked"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s := uploadService(t)
|
||||
ctx := uploadContext(1, "alice")
|
||||
data := []byte("source")
|
||||
u := create(t, s, data)
|
||||
_, e := s.Put(ctx, u.ID, 0, int64(len(data)), sum(data), bytes.NewReader(data))
|
||||
require.NoError(t, e)
|
||||
_, e = s.Finalize(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
called := false
|
||||
s.ingest = func(context.Context, *Upload, string) (int64, types.JSON, bool, error) {
|
||||
called = true
|
||||
return 6, types.JSON(`{}`), true, nil
|
||||
}
|
||||
if scenario == "corruption" {
|
||||
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Update("sha256", "").Error)
|
||||
require.NoError(t, os.WriteFile(s.path(u.ID), []byte("edited"), 0600))
|
||||
} else {
|
||||
s.authorize = func(context.Context, string, string) error { return ErrForbidden }
|
||||
}
|
||||
worked, e := s.ProcessNext(context.Background())
|
||||
require.NoError(t, e)
|
||||
require.True(t, worked)
|
||||
var row Upload
|
||||
require.NoError(t, s.db.Where("id=?", u.ID).Take(&row).Error)
|
||||
require.Equal(t, "failed", row.State)
|
||||
require.False(t, called)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRevocationHidesEvidenceAndStopsWritesButAllowsCleanup(t *testing.T) {
|
||||
s := uploadService(t)
|
||||
ctx := uploadContext(1, "alice")
|
||||
u := create(t, s, []byte("source"))
|
||||
_, e := s.Put(ctx, u.ID, 0, 6, sum([]byte("source")), strings.NewReader("source"))
|
||||
require.NoError(t, e)
|
||||
s.authorize = func(context.Context, string, string) error { return ErrForbidden }
|
||||
_, e = s.Get(ctx, u.ID)
|
||||
require.ErrorIs(t, e, ErrForbidden)
|
||||
_, e = s.Put(ctx, u.ID, 0, 6, sum([]byte("source")), strings.NewReader("source"))
|
||||
require.ErrorIs(t, e, ErrForbidden)
|
||||
_, e = s.Finalize(ctx, u.ID)
|
||||
require.ErrorIs(t, e, ErrForbidden)
|
||||
rows, e := s.List(ctx, "", "")
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, rows)
|
||||
require.NoError(t, s.Cancel(ctx, u.ID))
|
||||
var count int64
|
||||
require.NoError(t, s.db.Model(&Chunk{}).Where("upload_id=?", u.ID).Count(&count).Error)
|
||||
require.Zero(t, count)
|
||||
_, e = os.Stat(s.path(u.ID))
|
||||
require.True(t, os.IsNotExist(e))
|
||||
}
|
||||
|
||||
func TestUploadWorkerDurableUnitsAndExpiredLease(t *testing.T) {
|
||||
s := uploadService(t)
|
||||
ctx := uploadContext(1, "alice")
|
||||
u := create(t, s, []byte("ab"))
|
||||
_, e := s.Put(ctx, u.ID, 0, 2, sum([]byte("ab")), strings.NewReader("ab"))
|
||||
require.NoError(t, e)
|
||||
_, e = s.Finalize(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
s.ingest = func(_ context.Context, row *Upload, _ string) (int64, types.JSON, bool, error) {
|
||||
return row.IngestOffset + 1, types.JSON(`{"checkpoint":true}`), row.IngestOffset == 1, nil
|
||||
}
|
||||
worked, e := s.ProcessNext(context.Background())
|
||||
require.NoError(t, e)
|
||||
require.True(t, worked)
|
||||
row, e := s.Get(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
require.EqualValues(t, 1, row.IngestOffset)
|
||||
require.Equal(t, "processing", row.State)
|
||||
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Updates(map[string]any{"lease_until": time.Now().Add(time.Hour), "lease_token": "crashed-worker"}).Error)
|
||||
worked, e = s.ProcessNext(context.Background())
|
||||
require.NoError(t, e)
|
||||
require.False(t, worked)
|
||||
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Update("lease_until", time.Now().Add(-time.Minute)).Error)
|
||||
worked, e = s.ProcessNext(context.Background())
|
||||
require.NoError(t, e)
|
||||
require.True(t, worked)
|
||||
row, e = s.Get(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, "completed", row.State)
|
||||
require.EqualValues(t, 2, row.IngestOffset)
|
||||
}
|
||||
|
||||
func TestUploadWorkerAndHTTPNeverExposeInternalFailures(t *testing.T) {
|
||||
const secret = "postgres://private:credential-sentinel@internal-host/database"
|
||||
s := uploadService(t)
|
||||
ctx := uploadContext(1, "alice")
|
||||
u := create(t, s, []byte("ok"))
|
||||
_, e := s.Put(ctx, u.ID, 0, 2, sum([]byte("ok")), strings.NewReader("ok"))
|
||||
require.NoError(t, e)
|
||||
_, e = s.Finalize(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
s.ingest = func(context.Context, *Upload, string) (int64, types.JSON, bool, error) {
|
||||
return 0, nil, false, errors.New(secret)
|
||||
}
|
||||
worked, e := s.ProcessNext(context.Background())
|
||||
require.NoError(t, e)
|
||||
require.True(t, worked)
|
||||
row, e := s.Get(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, "failed", row.State)
|
||||
require.NotContains(t, row.Error, "credential-sentinel")
|
||||
require.Contains(t, row.Error, "服务日志")
|
||||
r := gin.New()
|
||||
r.GET("/error", func(c *gin.Context) { respond(c, 200, nil, errors.New(secret)) })
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/error", nil))
|
||||
require.Equal(t, 500, w.Code)
|
||||
require.NotContains(t, w.Body.String(), "credential-sentinel")
|
||||
// A retry preserves the completed transport and checkpoint.
|
||||
_, e = s.Finalize(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
s.ingest = func(_ context.Context, row *Upload, _ string) (int64, types.JSON, bool, error) {
|
||||
return row.Size, types.JSON(`{}`), true, nil
|
||||
}
|
||||
worked, e = s.ProcessNext(context.Background())
|
||||
require.NoError(t, e)
|
||||
require.True(t, worked)
|
||||
row, e = s.Get(ctx, u.ID)
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, "completed", row.State)
|
||||
require.Empty(t, row.Error)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package resumable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Start runs one bounded ingest worker per process. Database leases support
|
||||
// recovery after restarts and prevent the same upload running on two workers.
|
||||
func (s *Service) Start(ctx context.Context) {
|
||||
s.once.Do(func() {
|
||||
go func() {
|
||||
timer := time.NewTicker(2 * time.Second)
|
||||
defer timer.Stop()
|
||||
lastCleanup := time.Time{}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if time.Since(lastCleanup) > time.Minute {
|
||||
if e := s.Cleanup(ctx); e != nil {
|
||||
logger.Errorf(ctx, "Upload cleanup failed: %v", e)
|
||||
}
|
||||
lastCleanup = time.Now()
|
||||
}
|
||||
worked, e := s.ProcessNext(ctx)
|
||||
if e != nil {
|
||||
logger.Errorf(ctx, "Upload worker failed: %v", e)
|
||||
}
|
||||
if worked && e == nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) ProcessNext(ctx context.Context) (bool, error) {
|
||||
if s.ingest == nil || s.workerContext == nil {
|
||||
return false, nil
|
||||
}
|
||||
var row Upload
|
||||
e := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var rows []Upload
|
||||
e := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("state IN ('queued','verifying','processing') AND expires_at > ? AND (lease_until IS NULL OR lease_until < ?)", time.Now(), time.Now()).Order("updated_at, created_at").Limit(1).Find(&rows).Error
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
row = rows[0]
|
||||
lease := time.Now().Add(2 * time.Minute)
|
||||
row.LeaseUntil = &lease
|
||||
row.LeaseToken = uuid.NewString()
|
||||
if row.State == "queued" {
|
||||
row.State = "verifying"
|
||||
}
|
||||
return tx.Model(&row).Updates(map[string]any{"state": row.State, "lease_until": lease, "lease_token": row.LeaseToken, "updated_at": time.Now()}).Error
|
||||
})
|
||||
if errors.Is(e, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if e != nil {
|
||||
return false, e
|
||||
}
|
||||
s.process(ctx, &row)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Service) process(parent context.Context, row *Upload) {
|
||||
ctx, cancel := context.WithTimeout(parent, 20*time.Minute)
|
||||
defer cancel()
|
||||
heartbeatDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(heartbeatDone)
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r := s.db.WithContext(ctx).Model(&Upload{}).Where("id=? AND lease_token=? AND state IN ('verifying','processing')", row.ID, row.LeaseToken).Updates(map[string]any{"lease_until": time.Now().Add(2 * time.Minute), "expires_at": time.Now().Add(7 * 24 * time.Hour), "updated_at": time.Now()})
|
||||
if r.Error != nil || r.RowsAffected != 1 {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() { cancel(); <-heartbeatDone }()
|
||||
finish := func(failure error) {
|
||||
finishCtx, done := context.WithTimeout(context.WithoutCancel(parent), 10*time.Second)
|
||||
defer done()
|
||||
logger.Errorf(finishCtx, "Upload %s processing failed: %v", row.ID, failure)
|
||||
detail := publicError(failure)
|
||||
if len(detail) > 2000 {
|
||||
detail = detail[:2000]
|
||||
}
|
||||
if e := s.db.WithContext(finishCtx).Model(&Upload{}).Where("id=? AND lease_token=?", row.ID, row.LeaseToken).Updates(map[string]any{"state": "failed", "error": detail, "lease_until": nil, "lease_token": "", "updated_at": time.Now()}).Error; e != nil {
|
||||
logger.Errorf(finishCtx, "Upload finalization failed: %v", e)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logger.Errorf(ctx, "Upload worker panicked: %v", recovered)
|
||||
finish(UserError("上传解析意外中断,请重新提交"))
|
||||
}
|
||||
}()
|
||||
workerCtx, e := s.workerContext(ctx, row)
|
||||
if e != nil {
|
||||
finish(e)
|
||||
return
|
||||
}
|
||||
if e = s.authorize(workerCtx, row.Kind, row.TargetID); e != nil {
|
||||
finish(e)
|
||||
return
|
||||
}
|
||||
if row.State == "verifying" {
|
||||
hash, e := s.verifyFile(ctx, row)
|
||||
if e != nil {
|
||||
finish(e)
|
||||
return
|
||||
}
|
||||
if row.SHA256 != "" && row.SHA256 != hash {
|
||||
finish(UserError("完整文件校验失败,请重新上传"))
|
||||
return
|
||||
}
|
||||
row.SHA256 = hash
|
||||
row.State = "processing"
|
||||
result := s.db.WithContext(ctx).Model(&Upload{}).Where("id=? AND lease_token=?", row.ID, row.LeaseToken).Updates(map[string]any{"state": "processing", "sha256": hash, "updated_at": time.Now()})
|
||||
if result.Error != nil {
|
||||
finish(result.Error)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return
|
||||
}
|
||||
}
|
||||
offset, result, done, e := s.ingest(workerCtx, row, s.path(row.ID))
|
||||
if e != nil {
|
||||
finish(e)
|
||||
return
|
||||
}
|
||||
if offset < row.IngestOffset || offset > row.Size || (!done && offset == row.IngestOffset) {
|
||||
finish(UserError("解析进度无效"))
|
||||
return
|
||||
}
|
||||
state := "processing"
|
||||
if done {
|
||||
state = "completed"
|
||||
}
|
||||
update := s.db.WithContext(ctx).Model(&Upload{}).Where("id=? AND lease_token=?", row.ID, row.LeaseToken).Updates(map[string]any{"state": state, "ingest_offset": offset, "result": result, "part": row.Part + 1, "lease_until": nil, "lease_token": "", "expires_at": time.Now().Add(7 * 24 * time.Hour), "updated_at": time.Now()})
|
||||
if update.Error != nil {
|
||||
finish(update.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Revalidate the persisted chunks while computing the full digest. This also
|
||||
// detects storage corruption when a browser cannot provide a whole-file digest
|
||||
// without first reading 20 GiB. Chunk metadata is fetched in bounded pages.
|
||||
func (s *Service) verifyFile(ctx context.Context, row *Upload) (string, error) {
|
||||
f, err := os.Open(s.path(row.ID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if stat.Size() != row.Size {
|
||||
return "", UserError("完整文件长度校验失败,请重新上传")
|
||||
}
|
||||
whole := sha256.New()
|
||||
offset := int64(0)
|
||||
for offset < row.Size {
|
||||
var chunks []Chunk
|
||||
if err := s.db.WithContext(ctx).Where("upload_id=? AND \"offset\">=?", row.ID, offset).Order("\"offset\"").Limit(128).Find(&chunks).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
return "", UserError("上传分块记录缺失,请重新上传")
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Offset != offset || chunk.Size != min(row.ChunkSize, row.Size-offset) {
|
||||
return "", UserError("上传分块长度不一致,请重新上传")
|
||||
}
|
||||
h := sha256.New()
|
||||
n, err := io.CopyN(io.MultiWriter(whole, h), &contextReader{ctx: ctx, r: f}, chunk.Size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n != chunk.Size || hex.EncodeToString(h.Sum(nil)) != chunk.SHA256 {
|
||||
return "", UserError("上传分块校验失败,请重新上传")
|
||||
}
|
||||
offset += n
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(whole.Sum(nil)), nil
|
||||
}
|
||||
|
||||
type contextReader struct {
|
||||
ctx context.Context
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (r *contextReader) Read(p []byte) (int, error) {
|
||||
if e := r.ctx.Err(); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
return r.r.Read(p)
|
||||
}
|
||||
Reference in New Issue
Block a user