Files
hpc/internal/model/file.go
dailz 0e4f523746 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 <clio-agent@sisyphuslabs.ai>
2026-04-15 09:22:25 +08:00

194 lines
7.0 KiB
Go

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)
}