chore: reorder part two

This commit is contained in:
DaanSelen
2026-04-23 15:58:02 +02:00
parent 0556bc4932
commit c3aac38089
15 changed files with 58 additions and 50 deletions
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"fmt"
"log/slog"
"orbits-server/internal/server/api/middleware"
"orbits-server/internal/server/api/routes"
"orbits-server/internal/server/bootstrap"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// All error messages from slog must have an error field with the golang error
// See bottom the the kickoff function for details
func KickoffApi(logger *slog.Logger, env bootstrap.Environment, db *gorm.DB) {
gin.SetMode(gin.ReleaseMode)
// For a nice looking logger:
// r := gin.Default()
// JSON logger: https://gin-gonic.com/en/docs/logging/structured-logging/
r := gin.New()
r.Use(middleware.SlogMiddleware(logger))
r.Use(gin.Recovery())
api := r.Group("/api")
routes.RegisterApiRoutes(api /*env,*/, db)
file := r.Group("/file")
routes.RegisterFileRoutes(file, env, db)
r.Static("/assets", "./web/frontend/dist/assets")
r.NoRoute(func(c *gin.Context) {
c.File("./web/frontend/dist/index.html")
})
err := r.Run(fmt.Sprintf("%s:%d", env.Hostname, env.Port))
if err != nil {
slog.Error("failed to start the Gin server", "error", err)
}
}
@@ -0,0 +1,60 @@
package middleware
import (
"log/slog"
"net/http"
"orbits-server/internal/server/api/response"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func SlogMiddleware(logger *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
logger.Debug("request",
slog.String("method", c.Request.Method),
slog.String("path", path),
slog.String("query", query),
slog.Int("status", c.Writer.Status()),
slog.Duration("latency", time.Since(start)),
slog.String("client_ip", c.ClientIP()),
slog.Int("body_size", c.Writer.Size()),
)
if len(c.Errors) > 0 {
for _, err := range c.Errors {
logger.Error("request error", slog.String("error", err.Error()))
}
}
}
}
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authorizationHeader := c.GetHeader("Authorization")
if len(authorizationHeader) == 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, response.BasicResponse{
Msg: "Authorization header is required",
})
return
}
headerParts := strings.Split(authorizationHeader, " ")
if len(headerParts) != 2 || headerParts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, response.BasicResponse{
Msg: "Authorization header is invalid",
})
return
}
givenKey := headerParts[1]
slog.Info(givenKey)
}
}
+11
View File
@@ -0,0 +1,11 @@
package response
const (
OkMes string = "OK"
IntErrMes string = "An internal error occured, contact your administrator"
)
type BasicResponse struct {
Msg string `json:"msg"`
Data any `json:"data"`
}
+52
View File
@@ -0,0 +1,52 @@
package routes
import (
"log/slog"
"net/http"
"orbits-server/internal/server/api/response"
"orbits-server/internal/server/database"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RegisterApiRoutes(api *gin.RouterGroup /* env runtime.Environment,*/, db *gorm.DB) {
// prefix: api
// Display the information on what is going on at the moment
api.GET("/command", func(c *gin.Context) {
state, err := database.GetState(db)
if err != nil {
slog.Error("unable to determine state", "error", err)
c.JSON(http.StatusInternalServerError, response.BasicResponse{
Msg: response.IntErrMes,
})
return
}
c.JSON(http.StatusOK, response.BasicResponse{
Msg: response.OkMes,
Data: state,
})
})
api.PATCH("/command", func(c *gin.Context) {
})
// define a route to check what is registered
api.GET("/available", func(c *gin.Context) {
files, err := database.GetFiles(db)
if err != nil {
slog.Error("failed to retrieve available files", "error", err)
c.JSON(http.StatusInternalServerError, response.BasicResponse{
Msg: response.IntErrMes,
})
return
}
c.JSON(http.StatusOK, response.BasicResponse{
Msg: response.OkMes,
Data: files,
})
})
}
+82
View File
@@ -0,0 +1,82 @@
package routes
import (
"errors"
"log/slog"
"net/http"
"orbits-server/internal/server/api/response"
"orbits-server/internal/server/bootstrap"
"orbits-server/internal/server/database"
"path/filepath"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *gorm.DB) {
// /file/<file-name>
file.GET("/:filename", func(c *gin.Context) {
f := c.Param("filename")
p := filepath.Join(env.ContentDirectory, f)
c.File(p)
})
// define the upload route
// /file/upload
file.POST("/upload", func(c *gin.Context) {
f, err := c.FormFile("file")
if err != nil {
slog.Debug("no file or file headers provided on the request", "error", err)
c.JSON(http.StatusBadRequest, response.BasicResponse{
Msg: "a file is required",
})
return
}
readerStream, err := f.Open()
if err != nil {
slog.Error("failed to a reader stream")
}
defer readerStream.Close()
fileData, err := database.BuildFileRecord(readerStream, f.Filename, env.ContentDirectory)
if err != nil {
slog.Error("failed to enroll file to the database", "error", err)
c.JSON(http.StatusInternalServerError, response.BasicResponse{
Msg: response.IntErrMes,
})
return
}
if err := database.RegisterFile(db, fileData); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
slog.Debug("discarding file since its checksum is a duplicate", "error", err)
c.JSON(http.StatusConflict, response.BasicResponse{
Msg: "file checksum already exists",
})
} else {
slog.Error("failed to insert filedata to the database", "error", err)
c.JSON(http.StatusInternalServerError, response.BasicResponse{
Msg: response.IntErrMes,
})
}
return
}
// save to filesystem after everything has given a green light
if err := c.SaveUploadedFile(f, fileData.FilePath); err != nil {
slog.Error("failed to receive the file over http:", "error", err)
c.JSON(http.StatusInternalServerError, response.BasicResponse{
Msg: response.IntErrMes,
})
return
}
slog.Info("saved file to local filesystem and database")
c.JSON(http.StatusCreated, response.BasicResponse{
Msg: "file has succesfully been uploaded",
Data: fileData,
})
})
}
+98
View File
@@ -0,0 +1,98 @@
package bootstrap
import (
"os"
"path/filepath"
"slices"
"strconv"
)
type Environment struct {
Version string
Codename string
LogLevel string
DataDirectory string
ContentDirectory string
Hostname string
Port int
WatchdogEnabled bool
WatchdogInterval int
WatchdogSyncMode string
}
var (
validSyncModes = []string{
"sync",
"strict",
"dry",
}
)
// part of environment checking
func safeStringGrab(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
func safeIntGrab(key string, fallback int) int {
if v, ok := os.LookupEnv(key); ok {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return fallback
}
func safeBoolGrab(key string, fallback bool) bool {
if v, ok := os.LookupEnv(key); ok {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
func safeSyncModeGrab(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
if slices.Contains(validSyncModes, v) {
return v
}
}
return fallback
}
func GrabEnvironment() Environment {
cwd, err := os.Getwd()
if err != nil {
cwd = "."
}
fbBase := filepath.Join(cwd, "data")
fbContent := filepath.Join(fbBase, "content")
return Environment{
// Basic server configuration
Version: safeStringGrab("VERSION", "0.0.1"),
Codename: safeStringGrab("CODENAME", "Magical Anomaly"),
LogLevel: safeStringGrab("LOG_LEVEL", "debug"),
// GIN API configuration
DataDirectory: safeStringGrab("DATA_DIR", fbBase),
ContentDirectory: safeStringGrab("CONTENT_DIR", fbContent),
Hostname: safeStringGrab("HOSTNAME", "0.0.0.0"),
Port: safeIntGrab("PORT", 8080),
// watchdog configuration
// watchdog is the integrity checker/steward
WatchdogEnabled: safeBoolGrab("WATCHDOG_ENABLED", true),
WatchdogInterval: safeIntGrab("WATCHDOG_INTERVAL", 60),
// sync: sync local files to the database, for example when you want to allow local inserting files which then get added to the database
// strict: make the database leading, this is when you only allow API uploads to be registered, and remove orphaned filesystem files
// dry: do nothing
WatchdogSyncMode: safeStringGrab("WATCHDOG_SYNC_MODE", "strict"),
}
}
+23
View File
@@ -0,0 +1,23 @@
package bootstrap
import (
"os"
"path/filepath"
)
// part of filesystem checking
func EnsureOperation(workDir string) error {
nDirs := []string{
workDir,
filepath.Join(workDir),
filepath.Join(workDir, "content"),
}
for _, p := range nDirs {
if err := os.MkdirAll(p, 0755); err != nil {
return err
}
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package database
import (
"orbits-server/internal/server/bootstrap"
"path/filepath"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
//var watchdogStop = make(chan struct{})
func KickoffDatabase(workDir string) (*gorm.DB, error) {
dbLoc := filepath.Join(workDir, "station.db")
db, err := gorm.Open(sqlite.Open(dbLoc), &gorm.Config{
Logger: logger.Discard, // disable gorm logging since its not slog (yet)
TranslateError: true,
})
if err != nil {
return nil, err
}
// try to use GORM automigrate if the schema changes
if err := db.AutoMigrate(
&Command{},
&Device{},
&File{},
); err != nil {
return nil, err
}
// create the first row if it does not exist yet
if err := db.FirstOrCreate(&Command{}, Command{
ID: 0,
State: "idle",
MediaType: Unspecified,
}).Error; err != nil {
return nil, err
}
return db, nil
}
func KickoffDatabaseWatchdog(env bootstrap.Environment, db *gorm.DB) {
timeInterval := time.Second * time.Duration(env.WatchdogInterval)
ticker := time.NewTicker(timeInterval)
go func() {
defer ticker.Stop()
// run the watchdog function once to see if all is well.
watchdog(env, db)
// then defer to a decoupled/disowned golang goroutine
for range ticker.C {
watchdog(env, db)
}
}()
}
+75
View File
@@ -0,0 +1,75 @@
package database
import (
"time"
)
type MediaType string
const (
Video MediaType = "video"
Presentation MediaType = "presentation"
Internet MediaType = "internet"
Unspecified MediaType = "unspecified"
)
type Timestamps struct {
CreatedAt time.Time `gorm:"not null;"`
UpdatedAt time.Time `gorm:"not null;"`
ExpiresAt time.Time
}
type Command struct {
ID int `gorm:"primaryKey;not null;"`
State string
// unspecified
// video
// presentation
// internet URL
MediaType MediaType `gorm:"type:varchar(20);not null"` // Must specify what kind of file it is
// Must be target list who are compelled to listen to the command
// can be none when there is no targets specified (init stage)
Targets []string `gorm:"type:json"`
// Must be the location where the file is downloadable on the API
// can be none when there is no media specified (init stage)
Location string
Timestamps
}
type AccessKey struct {
ID int `gorm:"primaryKey;not null;"`
MetaName string
KeyName string `gorm:"not null;"`
// We don't store the key itself, we hash the key
KeyHash string `gorm:"not null;"`
// we're cooking without pepper
Timestamps
}
type Device struct {
ID int `gorm:"primaryKey;not null;"`
// Device type is meant as a field where can be specified what type of device this is
// eg Raspberry Pi, PC, things like that
DeviceType string
Hostname string `gorm:"not null;"`
RemoteAddress string `gorm:"not null;"`
Alive bool `gorm:"not null;"`
Compliant bool `gorm:"not null;"`
Timestamps
}
type File struct {
ID int `gorm:"primaryKey;not null;"`
// unspecified
// video
// presentation
// internet URL
MediaType MediaType `gorm:"type:varchar(20);not null;"`
// the name given by the user
MetaName string
FileName string `gorm:"not null;"`
FilePath string `gorm:"not null;"`
// hex encoded sha512 checksum
Checksum string `gorm:"uniqueIndex;not null;"`
Timestamps
}
+86
View File
@@ -0,0 +1,86 @@
package database
import (
"fmt"
"io"
"log/slog"
"orbits-server/internal/shared/utility"
"path/filepath"
"github.com/google/uuid"
"gorm.io/gorm"
)
// 0: unspecified
// 1: video
// 2: presentation
// 3: internet URL
func CategorizeMediaType(ext string) (MediaType, bool) {
switch ext {
case ".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4a":
return Video, true
case ".pptx", ".ppt", ".key", ".odp":
return Presentation, true
default:
slog.Debug("marking file as invalid due to its undefined extension")
return "", false
}
}
func GenerateSafeName(category MediaType, ext string) string {
return uuid.New().String() + "_" + string(category) + ext
}
// it has been made more general for DRY purposes
// this function should only be called after manually checking the filetype
func BuildFileRecord(r io.Reader, origName string, contentDirectory string) (File, error) {
ext := filepath.Ext(origName)
category, ok := CategorizeMediaType(ext)
if !ok {
return File{}, fmt.Errorf("unsupported filetype")
}
checksum, err := utility.HashReader(r)
if err != nil {
slog.Error("failed to calculate hash of file at given path", "error", err)
return File{}, err
}
safeName := GenerateSafeName(category, ext)
destPath := filepath.Join(contentDirectory, safeName)
fData := File{
MediaType: category,
MetaName: origName,
FileName: safeName,
FilePath: destPath,
Checksum: checksum,
}
return fData, nil
}
func GetState(db *gorm.DB) (Command, error) {
var state Command
if err := db.First(&state).Error; err != nil {
return Command{}, err
}
return state, nil
}
func GetFiles(db *gorm.DB) ([]File, error) {
var files []File
return files, db.Find(&files).Error
}
func RegisterFile(db *gorm.DB, f File) error {
return db.Create(&f).Error
}
func DeregisterFile(db *gorm.DB, f File) error {
return db.Delete(&f).Error
}
+130
View File
@@ -0,0 +1,130 @@
package database
import (
"errors"
"log/slog"
"orbits-server/internal/server/bootstrap"
"orbits-server/internal/shared/utility"
"os"
"path/filepath"
"gorm.io/gorm"
)
func filesystemGather(env bootstrap.Environment) (map[string]struct{}, error) {
fsFiles, err := os.ReadDir(env.ContentDirectory)
if err != nil {
slog.Error("failed to read the content directory on the filesystem", "error", err)
return nil, err
}
// generate a set of filesystem contents
fsSet := make(map[string]struct{}) // cool name for the files that are (now) marked for annihilation
for _, f := range fsFiles {
// absolute path creation
fullPath := filepath.Join(env.ContentDirectory, f.Name())
fsSet[fullPath] = struct{}{}
}
return fsSet, nil
}
func databaseGather(db *gorm.DB) (map[string]File, error) {
dbFiles, err := GetFiles(db)
if err != nil {
slog.Error("failed to retrieve the files indexed from the database", "error", err)
return nil, err
}
// generate a set of Database contents
dbSet := make(map[string]File) // cool name for the files that are going to be deregistered from the database
for _, f := range dbFiles {
dbSet[f.FilePath] = f
}
return dbSet, nil
}
func watchdog(env bootstrap.Environment, db *gorm.DB) {
slog.Debug("performing the watchdog cycle")
fsSet, err := filesystemGather(env)
if err != nil {
return
}
dbSet, err := databaseGather(db)
if err != nil {
return
}
// FS -> DB
// check for orphaned filesystem files
var fsOrphans []string
for path := range fsSet {
if _, exists := dbSet[path]; !exists {
fsOrphans = append(fsOrphans, path)
}
}
if len(fsOrphans) > 0 {
slog.Info("filesystem orphans detected, engaging flow")
//filepath is stored in the slice, the filename or file object, see above
for _, fp := range fsOrphans {
// this switch is guarded by the environment.go its check making sure its one of the three
// the following logic is used to actually perform the sync modes, removal, enrollment or ignore
switch env.WatchdogSyncMode {
case "sync":
readerStream, err := os.Open(fp)
if err != nil {
slog.Error("failed to a reader stream")
}
defer readerStream.Close()
fileData, err := BuildFileRecord(readerStream, filepath.Base(fp), env.ContentDirectory)
if err != nil {
slog.Error("failed to enroll local file into the database", "error", err)
}
if err := RegisterFile(db, fileData); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
slog.Debug("discarding file since its a duplicate", "error", err)
} else {
slog.Error("failed to insert filedata to the database", "error", err)
}
}
// to fully finalize the enrollment process, we rename the locally inserted file to a unique filename
// this is to make all files comply, wether uploaded via the api or locally inserted with the filesystem
if err := os.Rename(fp, fileData.FilePath); err != nil {
slog.Error("failed to move the locally inserted file", "error", err)
}
case "strict":
err := utility.RemoveFile(fp)
if err != nil {
slog.Error("failed to remove local file from the filesystem", "error", err)
}
case "dry":
slog.Debug("dry mode enabled, not purging", "filepath", fp)
}
}
}
// DB -> FS
// check stale database records
var dbdbOrphans []File
for path, f := range dbSet {
if _, exists := fsSet[path]; !exists {
dbdbOrphans = append(dbdbOrphans, f)
}
}
if len(dbdbOrphans) > 0 {
slog.Info("database orphans detected, engaging flow")
for _, f := range dbdbOrphans {
DeregisterFile(db, f)
}
}
}