diff --git a/internal/server/api/assets/response.go b/internal/server/api/assets/response.go index e2a4147..ec2354b 100644 --- a/internal/server/api/assets/response.go +++ b/internal/server/api/assets/response.go @@ -12,7 +12,7 @@ const ( CreationMes string = "Object successfully created" DeletionMes string = "Object successfully deleted" NotFoundMes string = "Requested object not found" - BadRequestMes string = "Request did not satisfy requirements" + BadRequestMes string = "Request did not satisfy requirements (bad request)" ConflictMes string = "Duplicate object" IntErrMes string = "An internal error occured, contact your administrator" ) @@ -24,20 +24,20 @@ type ResponseObject struct { // we swap out the hash for the keycontent type KeyResponse struct { - ID int `json:"id"` - MetaName string `json:"metaName"` - KeyName string `json:"keyName"` - KeyContent string `json:"keyContent"` - Revoked bool `json:"revoked"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - ExpiresAt time.Time `json:"expiresAt"` + ID int `json:"ID"` + MetaName string `json:"metaName"` + KeyID string `json:"keyID"` + KeySecret string `json:"keySecret"` + Revoked bool `json:"revoked"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ExpiresAt time.Time `json:"expiresAt"` } type FileResponse struct { - ID int `json:"id"` + ID int `json:"ID"` MetaName string `json:"metaName"` - FileName string `json:"fileName"` + FileID string `json:"fileID"` FilePath string `json:"filePath"` Checksum string `json:"checksum"` MediaType string `json:"mediaType"` diff --git a/internal/server/api/middleware/middleware.go b/internal/server/api/middleware/middleware.go index feca7ea..e433a65 100644 --- a/internal/server/api/middleware/middleware.go +++ b/internal/server/api/middleware/middleware.go @@ -5,7 +5,6 @@ import ( "net/http" "orbits-server/internal/server/api/assets" "orbits-server/internal/server/service" - "orbits-server/internal/shared/security" "strings" "time" @@ -13,6 +12,8 @@ import ( "gorm.io/gorm" ) +const authPrefix = "Bearer" + func SlogMiddleware(logger *slog.Logger) gin.HandlerFunc { // Make a slog-looking logger, inspired by the gin docs themself // JSON logger: https://gin-gonic.com/en/docs/logging/structured-logging/ @@ -45,40 +46,30 @@ func AuthMiddleware(db *gorm.DB) gin.HandlerFunc { keyService := service.NewKeyService(db) return func(c *gin.Context) { - authorizationHeader := c.GetHeader("Authorization") - if len(authorizationHeader) == 0 { + header := c.GetHeader("Authorization") + if len(header) == 0 { c.AbortWithStatusJSON(http.StatusUnauthorized, assets.ResponseObject{ Msg: "Authorization header is required", }) return } - headerParts := strings.Split(authorizationHeader, " ") - // The header must be a specific format, 0 being the bearer text and 1 being the token itself, making it 2 pieces total - // In the following if statement we verify both parts if the part after Bearer is empty its only 1 part for example - if len(headerParts) != 2 || headerParts[0] != "Bearer" { + if !strings.HasPrefix(header, authPrefix) { c.AbortWithStatusJSON(http.StatusUnauthorized, assets.ResponseObject{ - Msg: "Authorization header is invalid", + Msg: "Invalid authorization header", }) return } - candidateKey := headerParts[1] - storedKeys, err := keyService.ListValidKeyHashes() - if err != nil { - slog.Error("failed to retrieve key hashes", "error", err) - assets.InternalErrorResponse(c) + token := strings.TrimSpace(header[len(authPrefix):]) + ok := keyService.Validate(token) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, assets.ResponseObject{ + Msg: "Invalid key", + }) + return } - for _, key := range storedKeys { - if match := security.CompareKey(key, candidateKey); match { - c.Next() - return - } - } - - c.AbortWithStatusJSON(http.StatusUnauthorized, assets.ResponseObject{ - Msg: "invalid key", - }) + c.Next() } } diff --git a/internal/server/api/routes/fileroute.go b/internal/server/api/routes/fileroute.go index b57e777..c9fde84 100644 --- a/internal/server/api/routes/fileroute.go +++ b/internal/server/api/routes/fileroute.go @@ -18,8 +18,8 @@ func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *go // for example: /file/ // file download route / display contents - file.GET("/:fileName", func(c *gin.Context) { - fileParam := c.Param("fileName") + file.GET("/:fileID", func(c *gin.Context) { + fileParam := c.Param("fileID") fp := filepath.Join(env.ContentDirectory, fileParam) assets.FileDownloadResponse(c, fp) @@ -72,8 +72,8 @@ func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *go }) // delete route - file.DELETE("/:filename", func(c *gin.Context) { - fileParam := c.Param("filename") + file.DELETE("/:fileID", func(c *gin.Context) { + fileParam := c.Param("fileID") if err := fileService.DeleteByName(fileParam); err != nil { slog.Error("file not found", "error", err) @@ -81,7 +81,7 @@ func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *go return } - slog.Info("received a delete request for a file", "fileName", fileParam) + slog.Info("received a delete request for a file", "fileID", fileParam) assets.DeletionResponse(c) }) diff --git a/internal/server/api/routes/keyroute.go b/internal/server/api/routes/keyroute.go index 92208ff..422881c 100644 --- a/internal/server/api/routes/keyroute.go +++ b/internal/server/api/routes/keyroute.go @@ -28,13 +28,12 @@ func RegisterKeyRoutes(api *gin.RouterGroup, db *gorm.DB) { keyResponse, err := keyService.Create(body.Name, body.ExpiresAt) if err != nil { - slog.Error("failed to create key", "error", err) - assets.InternalErrorResponse(c) + slog.Error("failed to build key record", "error", err) + assets.BadRequestResponse(c) return } slog.Info("saved key to database") - assets.CreationResponse(c, keyResponse) }) @@ -44,6 +43,7 @@ func RegisterKeyRoutes(api *gin.RouterGroup, db *gorm.DB) { if err := keyService.DeleteByName(keyParam); err != nil { slog.Error("key not found", "error", err) assets.NotFoundResponse(c) + return } slog.Info("received a delete request for a key", "keyName", keyParam) diff --git a/internal/server/database/functions.go b/internal/server/database/functions.go index fc871e0..99ffaac 100644 --- a/internal/server/database/functions.go +++ b/internal/server/database/functions.go @@ -30,9 +30,9 @@ func ListKeys(db *gorm.DB) ([]AccessKey, error) { return keys, err } -func FindKeyByName(db *gorm.DB, name string) (AccessKey, error) { +func FindKeyByKeyID(db *gorm.DB, name string) (AccessKey, error) { var key AccessKey - err := db.Where("key_name = ?", name).First(&key).Error + err := db.Where("key_id = ?", name).First(&key).Error return key, err } @@ -70,9 +70,9 @@ func ListFiles(db *gorm.DB) ([]File, error) { return files, err } -func FindFileByName(db *gorm.DB, name string) (File, error) { +func FindFileByFileID(db *gorm.DB, name string) (File, error) { var file File - err := db.Where("file_name = ?", name).First(&file).Error + err := db.Where("file_id = ?", name).First(&file).Error return file, err } diff --git a/internal/server/database/types.go b/internal/server/database/types.go index 220edac..67bf42e 100644 --- a/internal/server/database/types.go +++ b/internal/server/database/types.go @@ -28,7 +28,7 @@ type AccessKey struct { ID int `gorm:"primaryKey;not null;"` MetaName string // UUID for safe storage - KeyName string `gorm:"not null;"` + KeyID string `gorm:"not null;"` // We don't store the key itself, we hash the key KeyHash string `gorm:"uniqueIndex;not null;"` // revoked status @@ -83,7 +83,7 @@ type File struct { MediaType utility.MediaType `gorm:"type:varchar(20);not null;"` // the name given by the user MetaName string - FileName string `gorm:"not null;"` + FileID string `gorm:"not null;"` FilePath string `gorm:"not null;"` // hex encoded sha512 checksum Checksum string `gorm:"uniqueIndex;not null;"` diff --git a/internal/server/database/utility.go b/internal/server/database/utility.go index cc51a19..66e35b9 100644 --- a/internal/server/database/utility.go +++ b/internal/server/database/utility.go @@ -25,13 +25,13 @@ func BuildFileRecord(r io.Reader, metaName string, contentDirectory string) (Fil return File{}, err } - safeName := security.GenerateSafeCategoryName(category, ext) + safeName := security.GenerateSafeName() + ext destPath := filepath.Join(contentDirectory, safeName) f := File{ MediaType: category, MetaName: metaName, - FileName: safeName, + FileID: safeName, FilePath: destPath, Checksum: checksum, } @@ -39,16 +39,21 @@ func BuildFileRecord(r io.Reader, metaName string, contentDirectory string) (Fil return f, nil } -func BuildKeyRecord(keyHash string, metaName string, expiresAt time.Time) AccessKey { - safeName := security.GenerateSafeName() +func BuildKeyRecord(keyHash string, metaName string, expiresAt time.Time) (AccessKey, error) { + now := time.Now() + if expiresAt.Before(now) { + return AccessKey{}, fmt.Errorf("key is already expired") + } + + safeName := "orbits_" + security.GenerateSafeName() k := AccessKey{ MetaName: metaName, - KeyName: safeName, + KeyID: safeName, KeyHash: keyHash, Revoked: false, ExpiresAt: expiresAt, } - return k + return k, nil } diff --git a/internal/server/service/fileservice.go b/internal/server/service/fileservice.go index 50b39e1..21ae56b 100644 --- a/internal/server/service/fileservice.go +++ b/internal/server/service/fileservice.go @@ -2,10 +2,12 @@ package service import ( "io" + "log/slog" "orbits-server/internal/server/api/assets" "orbits-server/internal/server/bootstrap" "orbits-server/internal/server/database" "os" + "path/filepath" "gorm.io/gorm" ) @@ -19,6 +21,35 @@ func NewFileService(db *gorm.DB, env bootstrap.Environment) *FileService { return &FileService{db: db, env: env} } +func (s *FileService) SyncFile(fp string) { + f, err := os.Open(fp) + if err != nil { + slog.Error("failed to open the file", "file", fp, "error", err) + return + } + defer f.Close() + + fileData, err := database.BuildFileRecord(f, filepath.Base(fp), s.env.ContentDirectory) + if err != nil { + slog.Error("failed to build file record", "error", err) + return + } + + if err := database.CreateFile(s.db, &fileData); err != nil { + slog.Error("failed to create file record", "error", err) + return + } + + if err := os.Rename(fp, fileData.FilePath); err != nil { + // if this fails across mounts then try copy + delete + slog.Error("failed to move file", "error", err) + + database.DeleteFileByID(s.db, fileData.ID) + + return + } +} + func (s *FileService) ListFiles() ([]assets.FileResponse, error) { fileRecords, err := database.ListFiles(s.db) if err != nil { @@ -32,7 +63,7 @@ func (s *FileService) ListFiles() ([]assets.FileResponse, error) { ID: f.ID, MetaName: f.MetaName, MediaType: string(f.MediaType), - FileName: f.FileName, + FileID: f.FileID, FilePath: f.FilePath, Checksum: f.Checksum, CreatedAt: f.CreatedAt, @@ -57,7 +88,7 @@ func (s *FileService) Create(r io.Reader, filename string) (assets.FileResponse, ID: f.ID, MetaName: f.MetaName, MediaType: string(f.MediaType), - FileName: f.FileName, + FileID: f.FileID, FilePath: f.FilePath, Checksum: f.Checksum, CreatedAt: f.CreatedAt, @@ -68,7 +99,7 @@ func (s *FileService) Create(r io.Reader, filename string) (assets.FileResponse, } func (s *FileService) DeleteByName(filename string) error { - fileRecord, err := database.FindFileByName(s.db, filename) + fileRecord, err := database.FindFileByFileID(s.db, filename) if err != nil { return err } diff --git a/internal/server/service/keyservice.go b/internal/server/service/keyservice.go index 6f1fe98..5b341cb 100644 --- a/internal/server/service/keyservice.go +++ b/internal/server/service/keyservice.go @@ -4,6 +4,7 @@ import ( "orbits-server/internal/server/api/assets" "orbits-server/internal/server/database" "orbits-server/internal/shared/security" + "strings" "time" "gorm.io/gorm" @@ -23,20 +24,6 @@ func NewKeyService(db *gorm.DB) *KeyService { } } -func (s *KeyService) ListValidKeyHashes() ([]string, error) { - keyRecords, err := database.ListKeys(s.db) - if err != nil { - return nil, err - } - - hashList := make([]string, 0, len(keyRecords)) - for _, k := range keyRecords { - hashList = append(hashList, k.KeyHash) - } - - return hashList, nil -} - func (s *KeyService) Create(name string, expiresAt time.Time) (assets.KeyResponse, error) { keyContent := security.GenerateChars(accessKeyLen) @@ -45,27 +32,55 @@ func (s *KeyService) Create(name string, expiresAt time.Time) (assets.KeyRespons return assets.KeyResponse{}, err } - keyRecord := database.BuildKeyRecord(hash, name, expiresAt) + keyRecord, err := database.BuildKeyRecord(hash, name, expiresAt) + if err != nil { + return assets.KeyResponse{}, err + } if err := database.CreateKey(s.db, &keyRecord); err != nil { return assets.KeyResponse{}, err } keyResponse := assets.KeyResponse{ - ID: keyRecord.ID, - MetaName: keyRecord.MetaName, - KeyName: keyRecord.KeyName, - KeyContent: keyContent, - CreatedAt: keyRecord.CreatedAt, - UpdatedAt: keyRecord.UpdatedAt, - ExpiresAt: keyRecord.ExpiresAt, + ID: keyRecord.ID, + MetaName: keyRecord.MetaName, + KeyID: keyRecord.KeyID, + KeySecret: keyContent, + CreatedAt: keyRecord.CreatedAt, + UpdatedAt: keyRecord.UpdatedAt, + ExpiresAt: keyRecord.ExpiresAt, } return keyResponse, nil } +func (s *KeyService) Validate(token string) bool { + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return false + } + + keyID := parts[0] + secret := parts[1] + + key, err := database.FindKeyByKeyID(s.db, keyID) + if err != nil { + return false + } + + if key.Revoked || time.Now().After(key.ExpiresAt) { + return false + } + + if !security.CompareKey(key.KeyHash, secret) { + return false + } + + return true +} + func (s *KeyService) DeleteByName(name string) error { - keyRecord, err := database.FindKeyByName(s.db, name) + keyRecord, err := database.FindKeyByKeyID(s.db, name) if err != nil { return err } diff --git a/internal/server/watchdog/executor.go b/internal/server/watchdog/executor.go index fdf6019..527be03 100644 --- a/internal/server/watchdog/executor.go +++ b/internal/server/watchdog/executor.go @@ -4,41 +4,37 @@ import ( "log/slog" "orbits-server/internal/server/bootstrap" "orbits-server/internal/server/database" + "orbits-server/internal/server/service" "os" - "path/filepath" + "time" "gorm.io/gorm" ) +func revokeExpired(db *gorm.DB) { + now := time.Now() + + if err := db.Model(&database.AccessKey{}). + Where("expires_at < ? AND revoked = ?", now, false). + Update("revoked", true).Error; err != nil { + + slog.Error("failed to revoke expired keys", "error", err) + } +} + func applyFS(env bootstrap.Environment, db *gorm.DB, fsOrphans []string) { + fileService := service.NewFileService(db, env) for _, fp := range fsOrphans { switch env.WatchdogSyncMode { - case "sync": - f, err := os.Open(fp) - if err != nil { - continue - } - - func() { - defer f.Close() - - fileData, err := database.BuildFileRecord( - f, - filepath.Base(fp), - env.ContentDirectory, - ) - if err != nil { - return - } - - _ = database.CreateFile(db, &fileData) - _ = os.Rename(fp, fileData.FilePath) - }() + fileService.SyncFile(fp) case "strict": - _ = os.Remove(fp) + if err := os.Remove(fp); err != nil { + slog.Error("failed to remove file", "error", err) + continue + } case "dry": slog.Debug("dry mode", "file", fp) diff --git a/internal/server/watchdog/scanner.go b/internal/server/watchdog/scanner.go index 629bcdf..00a6db8 100644 --- a/internal/server/watchdog/scanner.go +++ b/internal/server/watchdog/scanner.go @@ -35,6 +35,10 @@ func scanFS(env bootstrap.Environment) (map[string]struct{}, error) { resp := make(map[string]struct{}) for _, f := range fsFiles { + if f.IsDir() { + continue + } + full := filepath.Join(env.ContentDirectory, f.Name()) resp[full] = struct{}{} } diff --git a/internal/server/watchdog/watchdog.go b/internal/server/watchdog/watchdog.go index 4c5677f..45034e4 100644 --- a/internal/server/watchdog/watchdog.go +++ b/internal/server/watchdog/watchdog.go @@ -28,22 +28,40 @@ func Kickoff(env bootstrap.Environment, db *gorm.DB) { run(env, db) + running := false for range ticker.C { - run(env, db) + if running { + slog.Warn("watchdog is still running, skipping tick") + continue + } + + running = true + + func() { + defer func() { running = false }() + run(env, db) + }() } }() } +// the watchdog has 2 tasks +// 1 revoke expires keys +// 2 remove / sync orphaned files/db records func run(env bootstrap.Environment, db *gorm.DB) { slog.Debug("watchdog cycle start") + revokeExpired(db) + fsState, err := scanFS(env) if err != nil { + slog.Error("scanFS failed", "error", err) return } dbState, err := scanDB(db) if err != nil { + slog.Error("scanDB failed", "error", err) return } diff --git a/internal/shared/security/generator.go b/internal/shared/security/generator.go index fb32302..3af6416 100644 --- a/internal/shared/security/generator.go +++ b/internal/shared/security/generator.go @@ -3,15 +3,10 @@ package security import ( "crypto/rand" "encoding/base64" - "orbits-server/internal/shared/utility" "github.com/google/uuid" ) -func GenerateSafeCategoryName(category utility.MediaType, ext string) string { - return uuid.New().String() + "_" + string(category) + ext -} - func GenerateSafeName() string { return uuid.New().String() } diff --git a/internal/shared/security/hash.go b/internal/shared/security/hash.go index 2ca499d..0d42719 100644 --- a/internal/shared/security/hash.go +++ b/internal/shared/security/hash.go @@ -13,11 +13,11 @@ import ( ) const ( - argonTime = 3 - argonMemory = 64 * 1024 + argonTime = 1 + argonMemory = 32 * 1024 argonThreads = 2 argonSaltLen = 16 - argonKeyLen = 64 + argonKeyLen = 32 ) func HashFileReader(r io.Reader) (string, error) {