// Copyright 2026 MarketAlly. All rights reserved. // SPDX-License-Identifier: MIT package repo import ( "context" "fmt" "os" "path" "time" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" ) // UploadSessionStatus represents the status of an upload session type UploadSessionStatus int const ( // UploadSessionStatusActive indicates the session is active and accepting chunks UploadSessionStatusActive UploadSessionStatus = iota // UploadSessionStatusComplete indicates all chunks have been received UploadSessionStatusComplete // UploadSessionStatusExpired indicates the session has expired UploadSessionStatusExpired // UploadSessionStatusFailed indicates the upload failed UploadSessionStatusFailed ) // UploadSession represents a chunked upload session for large files type UploadSession struct { ID int64 `xorm:"pk autoincr"` UUID string `xorm:"uuid UNIQUE NOT NULL"` RepoID int64 `xorm:"INDEX NOT NULL"` ReleaseID int64 `xorm:"INDEX"` // 0 if not a release attachment IssueID int64 `xorm:"INDEX"` // 0 if not an issue attachment UploaderID int64 `xorm:"INDEX NOT NULL"` FileName string `xorm:"NOT NULL"` FileSize int64 `xorm:"DEFAULT -1"` // -1 if unknown ChunkSize int64 `xorm:"NOT NULL"` ChunksExpected int64 `xorm:"DEFAULT -1"` // -1 if unknown (calculated from FileSize/ChunkSize) ChunksReceived int64 `xorm:"DEFAULT 0"` BytesReceived int64 `xorm:"DEFAULT 0"` Status UploadSessionStatus `xorm:"DEFAULT 0"` TempPath string `xorm:"NOT NULL"` // relative path to temp storage CreatedUnix timeutil.TimeStamp `xorm:"created"` UpdatedUnix timeutil.TimeStamp `xorm:"updated"` ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"` } func init() { db.RegisterModel(new(UploadSession)) } // DefaultChunkSize is 10MB const DefaultChunkSize = 10 * 1024 * 1024 // DefaultSessionExpiry is 24 hours const DefaultSessionExpiry = 24 * time.Hour // TempRelativePath returns the relative path for temporary chunk storage func UploadSessionTempPath(uuid string) string { return path.Join("upload-sessions", uuid[0:1], uuid[1:2], uuid) } // GetTempPath returns the full path to the temp directory for this session func (s *UploadSession) GetTempPath() string { return path.Join(setting.AppDataPath, s.TempPath) } // GetChunkPath returns the path to a specific chunk file func (s *UploadSession) GetChunkPath(chunkNumber int64) string { return path.Join(s.GetTempPath(), fmt.Sprintf("chunk_%d", chunkNumber)) } // IsExpired checks if the session has expired func (s *UploadSession) IsExpired() bool { return s.ExpiresUnix.AsTime().Before(time.Now()) } // IsComplete checks if all chunks have been received func (s *UploadSession) IsComplete() bool { if s.FileSize > 0 && s.ChunksExpected > 0 { return s.ChunksReceived >= s.ChunksExpected } return s.Status == UploadSessionStatusComplete } // CalculateChunksExpected calculates the expected number of chunks based on file size func (s *UploadSession) CalculateChunksExpected() int64 { if s.FileSize <= 0 { return -1 } chunks := s.FileSize / s.ChunkSize if s.FileSize%s.ChunkSize > 0 { chunks++ } return chunks } // ErrUploadSessionNotExist represents a "UploadSessionNotExist" kind of error. type ErrUploadSessionNotExist struct { ID int64 UUID string } // IsErrUploadSessionNotExist checks if an error is a ErrUploadSessionNotExist. func IsErrUploadSessionNotExist(err error) bool { _, ok := err.(ErrUploadSessionNotExist) return ok } func (err ErrUploadSessionNotExist) Error() string { return fmt.Sprintf("upload session does not exist [id: %d, uuid: %s]", err.ID, err.UUID) } func (err ErrUploadSessionNotExist) Unwrap() error { return util.ErrNotExist } // ErrUploadSessionExpired represents an expired session error type ErrUploadSessionExpired struct { UUID string } func IsErrUploadSessionExpired(err error) bool { _, ok := err.(ErrUploadSessionExpired) return ok } func (err ErrUploadSessionExpired) Error() string { return fmt.Sprintf("upload session has expired [uuid: %s]", err.UUID) } // CreateUploadSession creates a new upload session func CreateUploadSession(ctx context.Context, session *UploadSession) error { if session.ChunkSize <= 0 { session.ChunkSize = DefaultChunkSize } if session.FileSize > 0 { session.ChunksExpected = session.CalculateChunksExpected() } if session.ExpiresUnix == 0 { session.ExpiresUnix = timeutil.TimeStamp(time.Now().Add(DefaultSessionExpiry).Unix()) } session.TempPath = UploadSessionTempPath(session.UUID) // Create the temp directory tempDir := session.GetTempPath() if err := os.MkdirAll(tempDir, 0o755); err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } return db.Insert(ctx, session) } // GetUploadSessionByUUID returns an upload session by UUID func GetUploadSessionByUUID(ctx context.Context, uuid string) (*UploadSession, error) { session := &UploadSession{} has, err := db.GetEngine(ctx).Where("uuid=?", uuid).Get(session) if err != nil { return nil, err } else if !has { return nil, ErrUploadSessionNotExist{0, uuid} } return session, nil } // GetUploadSessionByID returns an upload session by ID func GetUploadSessionByID(ctx context.Context, id int64) (*UploadSession, error) { session := &UploadSession{} has, err := db.GetEngine(ctx).ID(id).Get(session) if err != nil { return nil, err } else if !has { return nil, ErrUploadSessionNotExist{ID: id} } return session, nil } // UpdateUploadSession updates an upload session func UpdateUploadSession(ctx context.Context, session *UploadSession) error { session.UpdatedUnix = timeutil.TimeStamp(time.Now().Unix()) _, err := db.GetEngine(ctx).ID(session.ID).AllCols().Update(session) return err } // UpdateUploadSessionChunks updates the chunk counts for a session func UpdateUploadSessionChunks(ctx context.Context, session *UploadSession) error { session.UpdatedUnix = timeutil.TimeStamp(time.Now().Unix()) _, err := db.GetEngine(ctx).ID(session.ID).Cols("chunks_received", "bytes_received", "updated_unix").Update(session) return err } // DeleteUploadSession deletes an upload session and its temp files func DeleteUploadSession(ctx context.Context, session *UploadSession) error { // Delete temp directory tempDir := session.GetTempPath() if err := os.RemoveAll(tempDir); err != nil && !os.IsNotExist(err) { log.Warn("Failed to remove temp directory for upload session %s: %v", session.UUID, err) } _, err := db.GetEngine(ctx).ID(session.ID).Delete(session) return err } // DeleteUploadSessionByUUID deletes an upload session by UUID func DeleteUploadSessionByUUID(ctx context.Context, uuid string) error { session, err := GetUploadSessionByUUID(ctx, uuid) if err != nil { return err } return DeleteUploadSession(ctx, session) } // GetExpiredUploadSessions returns all expired upload sessions func GetExpiredUploadSessions(ctx context.Context) ([]*UploadSession, error) { sessions := make([]*UploadSession, 0, 10) return sessions, db.GetEngine(ctx). Where("expires_unix < ? AND status = ?", time.Now().Unix(), UploadSessionStatusActive). Find(&sessions) } // DeleteExpiredUploadSessions deletes all expired upload sessions func DeleteExpiredUploadSessions(ctx context.Context) (int, error) { sessions, err := GetExpiredUploadSessions(ctx) if err != nil { return 0, err } count := 0 for _, session := range sessions { if err := DeleteUploadSession(ctx, session); err != nil { log.Warn("Failed to delete expired upload session %s: %v", session.UUID, err) continue } count++ } return count, nil } // GetActiveUploadSessionsForRelease returns active upload sessions for a release func GetActiveUploadSessionsForRelease(ctx context.Context, releaseID int64) ([]*UploadSession, error) { sessions := make([]*UploadSession, 0) return sessions, db.GetEngine(ctx). Where("release_id = ? AND status = ?", releaseID, UploadSessionStatusActive). Find(&sessions) } // GetUploadSessionsForUser returns all upload sessions for a user func GetUploadSessionsForUser(ctx context.Context, userID int64) ([]*UploadSession, error) { sessions := make([]*UploadSession, 0) return sessions, db.GetEngine(ctx). Where("uploader_id = ?", userID). Desc("created_unix"). Find(&sessions) }