From 0e4f523746cd40c83a8eb204097c9f90b7d4231b Mon Sep 17 00:00:00 2001 From: dailz Date: Wed, 15 Apr 2026 09:22:25 +0800 Subject: [PATCH] feat(model): add file storage GORM models and DTOs Add FileBlob, File, Folder, UploadSession, UploadChunk models with validators. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/model/file.go | 193 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 internal/model/file.go diff --git a/internal/model/file.go b/internal/model/file.go new file mode 100644 index 0000000..4668289 --- /dev/null +++ b/internal/model/file.go @@ -0,0 +1,193 @@ +package model + +import ( + "fmt" + "strings" + "time" + "unicode" + + "gorm.io/gorm" +) + +// FileBlob represents a physical file stored in MinIO, deduplicated by SHA256. +type FileBlob struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + SHA256 string `gorm:"uniqueIndex;size:64;not null" json:"sha256"` + MinioKey string `gorm:"size:255;not null" json:"minio_key"` + FileSize int64 `gorm:"not null" json:"file_size"` + MimeType string `gorm:"size:255" json:"mime_type"` + RefCount int `gorm:"not null;default:0" json:"ref_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (FileBlob) TableName() string { + return "hpc_file_blobs" +} + +// File represents a logical file visible to users, backed by a FileBlob. +type File struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + FolderID *int64 `gorm:"index" json:"folder_id,omitempty"` + BlobSHA256 string `gorm:"size:64;not null" json:"blob_sha256"` + UserID *int64 `gorm:"index" json:"user_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` +} + +func (File) TableName() string { + return "hpc_files" +} + +// Folder represents a directory in the virtual file system. +type Folder struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + ParentID *int64 `gorm:"index" json:"parent_id,omitempty"` + Path string `gorm:"uniqueIndex;size:768;not null" json:"path"` + UserID *int64 `gorm:"index" json:"user_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` +} + +func (Folder) TableName() string { + return "hpc_folders" +} + +// UploadSession represents an in-progress chunked upload. +// State transitions: pending→uploading, pending→completed(zero-byte), uploading→merging, +// uploading→cancelled, merging→completed, merging→failed, any→expired +type UploadSession struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + FileName string `gorm:"size:255;not null" json:"file_name"` + FileSize int64 `gorm:"not null" json:"file_size"` + ChunkSize int64 `gorm:"not null" json:"chunk_size"` + TotalChunks int `gorm:"not null" json:"total_chunks"` + SHA256 string `gorm:"size:64;not null" json:"sha256"` + FolderID *int64 `gorm:"index" json:"folder_id,omitempty"` + Status string `gorm:"size:20;not null;default:pending" json:"status"` + MinioPrefix string `gorm:"size:255;not null" json:"minio_prefix"` + MimeType string `gorm:"size:255;default:'application/octet-stream'" json:"mime_type"` + UserID *int64 `gorm:"index" json:"user_id,omitempty"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (UploadSession) TableName() string { + return "hpc_upload_sessions" +} + +// UploadChunk represents a single chunk of an upload session. +type UploadChunk struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + SessionID int64 `gorm:"not null;uniqueIndex:idx_session_chunk" json:"session_id"` + ChunkIndex int `gorm:"not null;uniqueIndex:idx_session_chunk" json:"chunk_index"` + MinioKey string `gorm:"size:255;not null" json:"minio_key"` + SHA256 string `gorm:"size:64" json:"sha256,omitempty"` + Size int64 `gorm:"not null" json:"size"` + Status string `gorm:"size:20;not null;default:pending" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (UploadChunk) TableName() string { + return "hpc_upload_chunks" +} + +// InitUploadRequest is the DTO for initiating a chunked upload. +type InitUploadRequest struct { + FileName string `json:"file_name" binding:"required"` + FileSize int64 `json:"file_size" binding:"required"` + SHA256 string `json:"sha256" binding:"required"` + FolderID *int64 `json:"folder_id,omitempty"` + ChunkSize *int64 `json:"chunk_size,omitempty"` + MimeType string `json:"mime_type,omitempty"` +} + +// CreateFolderRequest is the DTO for creating a new folder. +type CreateFolderRequest struct { + Name string `json:"name" binding:"required"` + ParentID *int64 `json:"parent_id,omitempty"` +} + +// UploadSessionResponse is the DTO returned when creating/querying an upload session. +type UploadSessionResponse struct { + ID int64 `json:"id"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + ChunkSize int64 `json:"chunk_size"` + TotalChunks int `json:"total_chunks"` + SHA256 string `json:"sha256"` + Status string `json:"status"` + UploadedChunks []int `json:"uploaded_chunks"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +// FileResponse is the DTO for a file in API responses. +type FileResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + FolderID *int64 `json:"folder_id,omitempty"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + SHA256 string `json:"sha256"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// FolderResponse is the DTO for a folder in API responses. +type FolderResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + ParentID *int64 `json:"parent_id,omitempty"` + Path string `json:"path"` + FileCount int64 `json:"file_count"` + SubFolderCount int64 `json:"subfolder_count"` + CreatedAt time.Time `json:"created_at"` +} + +// ListFilesResponse is the paginated response for listing files. +type ListFilesResponse struct { + Files []FileResponse `json:"files"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// ValidateFileName rejects empty, "..", "/", "\", null bytes, control chars, leading/trailing spaces. +func ValidateFileName(name string) error { + if name == "" { + return fmt.Errorf("file name cannot be empty") + } + if strings.TrimSpace(name) != name { + return fmt.Errorf("file name cannot have leading or trailing spaces") + } + if name == ".." { + return fmt.Errorf("file name cannot be '..'") + } + if strings.Contains(name, "/") || strings.Contains(name, "\\") { + return fmt.Errorf("file name cannot contain '/' or '\\'") + } + for _, r := range name { + if r == 0 { + return fmt.Errorf("file name cannot contain null bytes") + } + if unicode.IsControl(r) { + return fmt.Errorf("file name cannot contain control characters") + } + } + return nil +} + +// ValidateFolderName rejects same as ValidateFileName plus ".". +func ValidateFolderName(name string) error { + if name == "." { + return fmt.Errorf("folder name cannot be '.'") + } + return ValidateFileName(name) +}