feat: implement secure file upload system with JWT authentication

- Add JWT-based secure file access for local storage with 1-hour expiry
- Implement GORM repository methods for attachment CRUD operations
- Add secure file serving endpoint with token validation
- Update storage interface to support user context in URL generation
- Add comprehensive security features including path traversal protection
- Update documentation with security model and configuration examples
- Add utility functions for hex/byte conversion and UUID validation
- Configure secure file permissions (0600) for uploaded files

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-03 15:45:25 +12:00
parent b2b77eb4da
commit 8b6ba74ce9
19 changed files with 546 additions and 43 deletions

View File

@@ -372,4 +372,167 @@ func (r *GormRepository) Escape(sql string) string {
// GORM handles SQL injection protection automatically
// This method is kept for interface compatibility
return sql
}
// Attachment repository methods
func (r *GormRepository) InsertAttachment(attachment *types.Attachment) error {
// Convert UUID strings to bytes (remove dashes if present)
idBytes, err := stringToIDBytes(attachment.Id)
if err != nil {
return err
}
transactionIdBytes, err := stringToIDBytes(attachment.TransactionId)
if err != nil {
return err
}
orgIdBytes, err := stringToIDBytes(attachment.OrgId)
if err != nil {
return err
}
userIdBytes, err := stringToIDBytes(attachment.UserId)
if err != nil {
return err
}
// Convert types.Attachment to models.Attachment
gormAttachment := &models.Attachment{
ID: idBytes,
TransactionID: transactionIdBytes,
OrgID: orgIdBytes,
UserID: userIdBytes,
FileName: attachment.FileName,
OriginalName: attachment.OriginalName,
ContentType: attachment.ContentType,
FileSize: attachment.FileSize,
FilePath: attachment.FilePath,
Description: attachment.Description,
Uploaded: attachment.Uploaded,
Deleted: attachment.Deleted,
}
result := r.db.Create(gormAttachment)
return result.Error
}
func (r *GormRepository) GetAttachmentsByTransaction(transactionId, orgId, userId string) ([]*types.Attachment, error) {
transactionIdBytes, err := stringToIDBytes(transactionId)
if err != nil {
return nil, err
}
orgIdBytes, err := stringToIDBytes(orgId)
if err != nil {
return nil, err
}
var gormAttachments []models.Attachment
result := r.db.Where("transactionId = ? AND orgId = ? AND deleted = ?",
transactionIdBytes, orgIdBytes, false).Find(&gormAttachments)
if result.Error != nil {
return nil, result.Error
}
attachments := make([]*types.Attachment, len(gormAttachments))
for i, gormAttachment := range gormAttachments {
attachments[i] = convertGormToTypesAttachment(&gormAttachment)
}
return attachments, nil
}
func (r *GormRepository) GetAttachment(attachmentId, transactionId, orgId, userId string) (*types.Attachment, error) {
attachmentIdBytes, err := stringToIDBytes(attachmentId)
if err != nil {
return nil, err
}
var gormAttachment models.Attachment
query := r.db.Where("id = ? AND deleted = ?", attachmentIdBytes, false)
// Add additional filters if provided
if transactionId != "" {
transactionIdBytes, err := stringToIDBytes(transactionId)
if err != nil {
return nil, err
}
query = query.Where("transactionId = ?", transactionIdBytes)
}
if orgId != "" {
orgIdBytes, err := stringToIDBytes(orgId)
if err != nil {
return nil, err
}
query = query.Where("orgId = ?", orgIdBytes)
}
result := query.First(&gormAttachment)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, result.Error
}
return convertGormToTypesAttachment(&gormAttachment), nil
}
func (r *GormRepository) DeleteAttachment(attachmentId, transactionId, orgId, userId string) error {
attachmentIdBytes, err := stringToIDBytes(attachmentId)
if err != nil {
return err
}
query := r.db.Model(&models.Attachment{}).Where("id = ?", attachmentIdBytes)
// Add additional filters if provided
if transactionId != "" {
transactionIdBytes, err := stringToIDBytes(transactionId)
if err != nil {
return err
}
query = query.Where("transactionId = ?", transactionIdBytes)
}
if orgId != "" {
orgIdBytes, err := stringToIDBytes(orgId)
if err != nil {
return err
}
query = query.Where("orgId = ?", orgIdBytes)
}
// Soft delete by setting deleted = true
result := query.Update("deleted", true)
return result.Error
}
// Helper function to convert UUID string (with or without dashes) to bytes
func stringToIDBytes(id string) ([]byte, error) {
// Remove dashes if present
cleanId := strings.ReplaceAll(id, "-", "")
return util.HexToBytes(cleanId)
}
// Helper function to convert bytes to UUID string (without dashes, for compatibility)
func idBytesToString(bytes []byte) string {
return util.BytesToHex(bytes)
}
// Helper function to convert GORM attachment to types attachment
func convertGormToTypesAttachment(gormAttachment *models.Attachment) *types.Attachment {
return &types.Attachment{
Id: idBytesToString(gormAttachment.ID),
TransactionId: idBytesToString(gormAttachment.TransactionID),
OrgId: idBytesToString(gormAttachment.OrgID),
UserId: idBytesToString(gormAttachment.UserID),
FileName: gormAttachment.FileName,
OriginalName: gormAttachment.OriginalName,
ContentType: gormAttachment.ContentType,
FileSize: gormAttachment.FileSize,
FilePath: gormAttachment.FilePath,
Description: gormAttachment.Description,
Uploaded: gormAttachment.Uploaded,
Deleted: gormAttachment.Deleted,
}
}