feat: add basic workings

This commit is contained in:
2026-04-29 23:38:43 +02:00
parent da3dee9ae7
commit 76c893ae7e
14 changed files with 167 additions and 112 deletions
+6 -6
View File
@@ -12,7 +12,7 @@ const (
CreationMes string = "Object successfully created" CreationMes string = "Object successfully created"
DeletionMes string = "Object successfully deleted" DeletionMes string = "Object successfully deleted"
NotFoundMes string = "Requested object not found" 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" ConflictMes string = "Duplicate object"
IntErrMes string = "An internal error occured, contact your administrator" IntErrMes string = "An internal error occured, contact your administrator"
) )
@@ -24,10 +24,10 @@ type ResponseObject struct {
// we swap out the hash for the keycontent // we swap out the hash for the keycontent
type KeyResponse struct { type KeyResponse struct {
ID int `json:"id"` ID int `json:"ID"`
MetaName string `json:"metaName"` MetaName string `json:"metaName"`
KeyName string `json:"keyName"` KeyID string `json:"keyID"`
KeyContent string `json:"keyContent"` KeySecret string `json:"keySecret"`
Revoked bool `json:"revoked"` Revoked bool `json:"revoked"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
@@ -35,9 +35,9 @@ type KeyResponse struct {
} }
type FileResponse struct { type FileResponse struct {
ID int `json:"id"` ID int `json:"ID"`
MetaName string `json:"metaName"` MetaName string `json:"metaName"`
FileName string `json:"fileName"` FileID string `json:"fileID"`
FilePath string `json:"filePath"` FilePath string `json:"filePath"`
Checksum string `json:"checksum"` Checksum string `json:"checksum"`
MediaType string `json:"mediaType"` MediaType string `json:"mediaType"`
+13 -22
View File
@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"orbits-server/internal/server/api/assets" "orbits-server/internal/server/api/assets"
"orbits-server/internal/server/service" "orbits-server/internal/server/service"
"orbits-server/internal/shared/security"
"strings" "strings"
"time" "time"
@@ -13,6 +12,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const authPrefix = "Bearer"
func SlogMiddleware(logger *slog.Logger) gin.HandlerFunc { func SlogMiddleware(logger *slog.Logger) gin.HandlerFunc {
// Make a slog-looking logger, inspired by the gin docs themself // Make a slog-looking logger, inspired by the gin docs themself
// JSON logger: https://gin-gonic.com/en/docs/logging/structured-logging/ // 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) keyService := service.NewKeyService(db)
return func(c *gin.Context) { return func(c *gin.Context) {
authorizationHeader := c.GetHeader("Authorization") header := c.GetHeader("Authorization")
if len(authorizationHeader) == 0 { if len(header) == 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, assets.ResponseObject{ c.AbortWithStatusJSON(http.StatusUnauthorized, assets.ResponseObject{
Msg: "Authorization header is required", Msg: "Authorization header is required",
}) })
return return
} }
headerParts := strings.Split(authorizationHeader, " ") if !strings.HasPrefix(header, authPrefix) {
// 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" {
c.AbortWithStatusJSON(http.StatusUnauthorized, assets.ResponseObject{ c.AbortWithStatusJSON(http.StatusUnauthorized, assets.ResponseObject{
Msg: "Authorization header is invalid", Msg: "Invalid authorization header",
}) })
return return
} }
candidateKey := headerParts[1] token := strings.TrimSpace(header[len(authPrefix):])
storedKeys, err := keyService.ListValidKeyHashes() ok := keyService.Validate(token)
if err != nil { if !ok {
slog.Error("failed to retrieve key hashes", "error", err) c.AbortWithStatusJSON(http.StatusUnauthorized, assets.ResponseObject{
assets.InternalErrorResponse(c) Msg: "Invalid key",
})
return
} }
for _, key := range storedKeys {
if match := security.CompareKey(key, candidateKey); match {
c.Next() c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusUnauthorized, assets.ResponseObject{
Msg: "invalid key",
})
} }
} }
+5 -5
View File
@@ -18,8 +18,8 @@ func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *go
// for example: /file/<file-name> // for example: /file/<file-name>
// file download route / display contents // file download route / display contents
file.GET("/:fileName", func(c *gin.Context) { file.GET("/:fileID", func(c *gin.Context) {
fileParam := c.Param("fileName") fileParam := c.Param("fileID")
fp := filepath.Join(env.ContentDirectory, fileParam) fp := filepath.Join(env.ContentDirectory, fileParam)
assets.FileDownloadResponse(c, fp) assets.FileDownloadResponse(c, fp)
@@ -72,8 +72,8 @@ func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *go
}) })
// delete route // delete route
file.DELETE("/:filename", func(c *gin.Context) { file.DELETE("/:fileID", func(c *gin.Context) {
fileParam := c.Param("filename") fileParam := c.Param("fileID")
if err := fileService.DeleteByName(fileParam); err != nil { if err := fileService.DeleteByName(fileParam); err != nil {
slog.Error("file not found", "error", err) slog.Error("file not found", "error", err)
@@ -81,7 +81,7 @@ func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *go
return 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) assets.DeletionResponse(c)
}) })
+3 -3
View File
@@ -28,13 +28,12 @@ func RegisterKeyRoutes(api *gin.RouterGroup, db *gorm.DB) {
keyResponse, err := keyService.Create(body.Name, body.ExpiresAt) keyResponse, err := keyService.Create(body.Name, body.ExpiresAt)
if err != nil { if err != nil {
slog.Error("failed to create key", "error", err) slog.Error("failed to build key record", "error", err)
assets.InternalErrorResponse(c) assets.BadRequestResponse(c)
return return
} }
slog.Info("saved key to database") slog.Info("saved key to database")
assets.CreationResponse(c, keyResponse) assets.CreationResponse(c, keyResponse)
}) })
@@ -44,6 +43,7 @@ func RegisterKeyRoutes(api *gin.RouterGroup, db *gorm.DB) {
if err := keyService.DeleteByName(keyParam); err != nil { if err := keyService.DeleteByName(keyParam); err != nil {
slog.Error("key not found", "error", err) slog.Error("key not found", "error", err)
assets.NotFoundResponse(c) assets.NotFoundResponse(c)
return
} }
slog.Info("received a delete request for a key", "keyName", keyParam) slog.Info("received a delete request for a key", "keyName", keyParam)
+4 -4
View File
@@ -30,9 +30,9 @@ func ListKeys(db *gorm.DB) ([]AccessKey, error) {
return keys, err return keys, err
} }
func FindKeyByName(db *gorm.DB, name string) (AccessKey, error) { func FindKeyByKeyID(db *gorm.DB, name string) (AccessKey, error) {
var key AccessKey var key AccessKey
err := db.Where("key_name = ?", name).First(&key).Error err := db.Where("key_id = ?", name).First(&key).Error
return key, err return key, err
} }
@@ -70,9 +70,9 @@ func ListFiles(db *gorm.DB) ([]File, error) {
return files, err return files, err
} }
func FindFileByName(db *gorm.DB, name string) (File, error) { func FindFileByFileID(db *gorm.DB, name string) (File, error) {
var file File var file File
err := db.Where("file_name = ?", name).First(&file).Error err := db.Where("file_id = ?", name).First(&file).Error
return file, err return file, err
} }
+2 -2
View File
@@ -28,7 +28,7 @@ type AccessKey struct {
ID int `gorm:"primaryKey;not null;"` ID int `gorm:"primaryKey;not null;"`
MetaName string MetaName string
// UUID for safe storage // 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 // We don't store the key itself, we hash the key
KeyHash string `gorm:"uniqueIndex;not null;"` KeyHash string `gorm:"uniqueIndex;not null;"`
// revoked status // revoked status
@@ -83,7 +83,7 @@ type File struct {
MediaType utility.MediaType `gorm:"type:varchar(20);not null;"` MediaType utility.MediaType `gorm:"type:varchar(20);not null;"`
// the name given by the user // the name given by the user
MetaName string MetaName string
FileName string `gorm:"not null;"` FileID string `gorm:"not null;"`
FilePath string `gorm:"not null;"` FilePath string `gorm:"not null;"`
// hex encoded sha512 checksum // hex encoded sha512 checksum
Checksum string `gorm:"uniqueIndex;not null;"` Checksum string `gorm:"uniqueIndex;not null;"`
+11 -6
View File
@@ -25,13 +25,13 @@ func BuildFileRecord(r io.Reader, metaName string, contentDirectory string) (Fil
return File{}, err return File{}, err
} }
safeName := security.GenerateSafeCategoryName(category, ext) safeName := security.GenerateSafeName() + ext
destPath := filepath.Join(contentDirectory, safeName) destPath := filepath.Join(contentDirectory, safeName)
f := File{ f := File{
MediaType: category, MediaType: category,
MetaName: metaName, MetaName: metaName,
FileName: safeName, FileID: safeName,
FilePath: destPath, FilePath: destPath,
Checksum: checksum, Checksum: checksum,
} }
@@ -39,16 +39,21 @@ func BuildFileRecord(r io.Reader, metaName string, contentDirectory string) (Fil
return f, nil return f, nil
} }
func BuildKeyRecord(keyHash string, metaName string, expiresAt time.Time) AccessKey { func BuildKeyRecord(keyHash string, metaName string, expiresAt time.Time) (AccessKey, error) {
safeName := security.GenerateSafeName() now := time.Now()
if expiresAt.Before(now) {
return AccessKey{}, fmt.Errorf("key is already expired")
}
safeName := "orbits_" + security.GenerateSafeName()
k := AccessKey{ k := AccessKey{
MetaName: metaName, MetaName: metaName,
KeyName: safeName, KeyID: safeName,
KeyHash: keyHash, KeyHash: keyHash,
Revoked: false, Revoked: false,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
} }
return k return k, nil
} }
+34 -3
View File
@@ -2,10 +2,12 @@ package service
import ( import (
"io" "io"
"log/slog"
"orbits-server/internal/server/api/assets" "orbits-server/internal/server/api/assets"
"orbits-server/internal/server/bootstrap" "orbits-server/internal/server/bootstrap"
"orbits-server/internal/server/database" "orbits-server/internal/server/database"
"os" "os"
"path/filepath"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -19,6 +21,35 @@ func NewFileService(db *gorm.DB, env bootstrap.Environment) *FileService {
return &FileService{db: db, env: env} 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) { func (s *FileService) ListFiles() ([]assets.FileResponse, error) {
fileRecords, err := database.ListFiles(s.db) fileRecords, err := database.ListFiles(s.db)
if err != nil { if err != nil {
@@ -32,7 +63,7 @@ func (s *FileService) ListFiles() ([]assets.FileResponse, error) {
ID: f.ID, ID: f.ID,
MetaName: f.MetaName, MetaName: f.MetaName,
MediaType: string(f.MediaType), MediaType: string(f.MediaType),
FileName: f.FileName, FileID: f.FileID,
FilePath: f.FilePath, FilePath: f.FilePath,
Checksum: f.Checksum, Checksum: f.Checksum,
CreatedAt: f.CreatedAt, CreatedAt: f.CreatedAt,
@@ -57,7 +88,7 @@ func (s *FileService) Create(r io.Reader, filename string) (assets.FileResponse,
ID: f.ID, ID: f.ID,
MetaName: f.MetaName, MetaName: f.MetaName,
MediaType: string(f.MediaType), MediaType: string(f.MediaType),
FileName: f.FileName, FileID: f.FileID,
FilePath: f.FilePath, FilePath: f.FilePath,
Checksum: f.Checksum, Checksum: f.Checksum,
CreatedAt: f.CreatedAt, 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 { func (s *FileService) DeleteByName(filename string) error {
fileRecord, err := database.FindFileByName(s.db, filename) fileRecord, err := database.FindFileByFileID(s.db, filename)
if err != nil { if err != nil {
return err return err
} }
+33 -18
View File
@@ -4,6 +4,7 @@ import (
"orbits-server/internal/server/api/assets" "orbits-server/internal/server/api/assets"
"orbits-server/internal/server/database" "orbits-server/internal/server/database"
"orbits-server/internal/shared/security" "orbits-server/internal/shared/security"
"strings"
"time" "time"
"gorm.io/gorm" "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) { func (s *KeyService) Create(name string, expiresAt time.Time) (assets.KeyResponse, error) {
keyContent := security.GenerateChars(accessKeyLen) keyContent := security.GenerateChars(accessKeyLen)
@@ -45,7 +32,10 @@ func (s *KeyService) Create(name string, expiresAt time.Time) (assets.KeyRespons
return assets.KeyResponse{}, err 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 { if err := database.CreateKey(s.db, &keyRecord); err != nil {
return assets.KeyResponse{}, err return assets.KeyResponse{}, err
@@ -54,8 +44,8 @@ func (s *KeyService) Create(name string, expiresAt time.Time) (assets.KeyRespons
keyResponse := assets.KeyResponse{ keyResponse := assets.KeyResponse{
ID: keyRecord.ID, ID: keyRecord.ID,
MetaName: keyRecord.MetaName, MetaName: keyRecord.MetaName,
KeyName: keyRecord.KeyName, KeyID: keyRecord.KeyID,
KeyContent: keyContent, KeySecret: keyContent,
CreatedAt: keyRecord.CreatedAt, CreatedAt: keyRecord.CreatedAt,
UpdatedAt: keyRecord.UpdatedAt, UpdatedAt: keyRecord.UpdatedAt,
ExpiresAt: keyRecord.ExpiresAt, ExpiresAt: keyRecord.ExpiresAt,
@@ -64,8 +54,33 @@ func (s *KeyService) Create(name string, expiresAt time.Time) (assets.KeyRespons
return keyResponse, nil 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 { func (s *KeyService) DeleteByName(name string) error {
keyRecord, err := database.FindKeyByName(s.db, name) keyRecord, err := database.FindKeyByKeyID(s.db, name)
if err != nil { if err != nil {
return err return err
} }
+19 -23
View File
@@ -4,41 +4,37 @@ import (
"log/slog" "log/slog"
"orbits-server/internal/server/bootstrap" "orbits-server/internal/server/bootstrap"
"orbits-server/internal/server/database" "orbits-server/internal/server/database"
"orbits-server/internal/server/service"
"os" "os"
"path/filepath" "time"
"gorm.io/gorm" "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) { func applyFS(env bootstrap.Environment, db *gorm.DB, fsOrphans []string) {
fileService := service.NewFileService(db, env)
for _, fp := range fsOrphans { for _, fp := range fsOrphans {
switch env.WatchdogSyncMode { switch env.WatchdogSyncMode {
case "sync": case "sync":
f, err := os.Open(fp) fileService.SyncFile(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)
}()
case "strict": case "strict":
_ = os.Remove(fp) if err := os.Remove(fp); err != nil {
slog.Error("failed to remove file", "error", err)
continue
}
case "dry": case "dry":
slog.Debug("dry mode", "file", fp) slog.Debug("dry mode", "file", fp)
+4
View File
@@ -35,6 +35,10 @@ func scanFS(env bootstrap.Environment) (map[string]struct{}, error) {
resp := make(map[string]struct{}) resp := make(map[string]struct{})
for _, f := range fsFiles { for _, f := range fsFiles {
if f.IsDir() {
continue
}
full := filepath.Join(env.ContentDirectory, f.Name()) full := filepath.Join(env.ContentDirectory, f.Name())
resp[full] = struct{}{} resp[full] = struct{}{}
} }
+18
View File
@@ -28,22 +28,40 @@ func Kickoff(env bootstrap.Environment, db *gorm.DB) {
run(env, db) run(env, db)
running := false
for range ticker.C { for range ticker.C {
if running {
slog.Warn("watchdog is still running, skipping tick")
continue
}
running = true
func() {
defer func() { running = false }()
run(env, db) 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) { func run(env bootstrap.Environment, db *gorm.DB) {
slog.Debug("watchdog cycle start") slog.Debug("watchdog cycle start")
revokeExpired(db)
fsState, err := scanFS(env) fsState, err := scanFS(env)
if err != nil { if err != nil {
slog.Error("scanFS failed", "error", err)
return return
} }
dbState, err := scanDB(db) dbState, err := scanDB(db)
if err != nil { if err != nil {
slog.Error("scanDB failed", "error", err)
return return
} }
-5
View File
@@ -3,15 +3,10 @@ package security
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"orbits-server/internal/shared/utility"
"github.com/google/uuid" "github.com/google/uuid"
) )
func GenerateSafeCategoryName(category utility.MediaType, ext string) string {
return uuid.New().String() + "_" + string(category) + ext
}
func GenerateSafeName() string { func GenerateSafeName() string {
return uuid.New().String() return uuid.New().String()
} }
+3 -3
View File
@@ -13,11 +13,11 @@ import (
) )
const ( const (
argonTime = 3 argonTime = 1
argonMemory = 64 * 1024 argonMemory = 32 * 1024
argonThreads = 2 argonThreads = 2
argonSaltLen = 16 argonSaltLen = 16
argonKeyLen = 64 argonKeyLen = 32
) )
func HashFileReader(r io.Reader) (string, error) { func HashFileReader(r io.Reader) (string, error) {