aboutsummaryrefslogtreecommitdiff
path: root/pkg
diff options
context:
space:
mode:
Diffstat (limited to 'pkg')
-rw-r--r--pkg/components/auth/controller.go57
-rw-r--r--pkg/components/auth/controller_test.go190
-rw-r--r--pkg/components/auth/model.go32
-rw-r--r--pkg/components/filesystem/controller.go89
-rw-r--r--pkg/components/filesystem/model.go10
-rw-r--r--pkg/components/media/model.go57
-rw-r--r--pkg/components/settings/model.go15
-rw-r--r--pkg/database/localfs/filesystem.go49
-rw-r--r--pkg/database/sql/media.go238
-rw-r--r--pkg/database/sql/migration.go17
-rw-r--r--pkg/database/sql/settings.go69
-rw-r--r--pkg/database/sql/user.go182
-rw-r--r--pkg/database/sql/user_test.go110
-rw-r--r--pkg/ext/auth.go72
-rw-r--r--pkg/ext/auth_test.go40
-rw-r--r--pkg/ext/gorm_logger.go58
-rw-r--r--pkg/ext/middleware.go89
-rw-r--r--pkg/ext/responses.go50
-rw-r--r--pkg/ext/router.go51
-rw-r--r--pkg/fileop/exif.go165
-rw-r--r--pkg/list/list.go9
-rw-r--r--pkg/testkit/testkit.go31
-rw-r--r--pkg/view/auth.go97
-rw-r--r--pkg/view/filesystem.go66
-rw-r--r--pkg/view/media.go101
-rw-r--r--pkg/view/settings.go53
-rw-r--r--pkg/view/view.go7
-rw-r--r--pkg/worker/exif_scanner.go43
-rw-r--r--pkg/worker/file_scanner.go81
-rw-r--r--pkg/worker/httpserver.go31
-rw-r--r--pkg/worker/list_processor.go102
-rw-r--r--pkg/worker/list_processor_test.go90
-rw-r--r--pkg/worker/scheduler.go29
-rw-r--r--pkg/worker/worker.go54
34 files changed, 2434 insertions, 0 deletions
diff --git a/pkg/components/auth/controller.go b/pkg/components/auth/controller.go
new file mode 100644
index 0000000..4da6071
--- /dev/null
+++ b/pkg/components/auth/controller.go
@@ -0,0 +1,57 @@
+package auth
+
+import (
+ "context"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "git.sr.ht/~gabrielgio/img/pkg/ext"
+)
+
+type Controller struct {
+ repository Repository
+ key []byte
+}
+
+func NewController(repository Repository, key []byte) *Controller {
+ return &Controller{
+ repository: repository,
+ key: key,
+ }
+}
+
+func (c *Controller) Login(ctx context.Context, username, password []byte) ([]byte, error) {
+ id, err := c.repository.GetIDByUsername(ctx, string(username))
+ if err != nil {
+ return nil, err
+ }
+
+ hashedPassword, err := c.repository.GetPassword(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := bcrypt.CompareHashAndPassword(hashedPassword, password); err != nil {
+ return nil, err
+ }
+
+ token := &ext.Token{
+ UserID: id,
+ Username: string(username),
+ }
+ return ext.WriteToken(token, c.key)
+}
+
+func (c *Controller) Register(ctx context.Context, username, password []byte) error {
+ hash, err := bcrypt.GenerateFromPassword(password, bcrypt.MinCost)
+ if err != nil {
+ return err
+ }
+
+ _, err = c.repository.Create(ctx, &CreateUser{
+ Username: string(username),
+ Password: hash,
+ })
+
+ return err
+}
diff --git a/pkg/components/auth/controller_test.go b/pkg/components/auth/controller_test.go
new file mode 100644
index 0000000..33aa901
--- /dev/null
+++ b/pkg/components/auth/controller_test.go
@@ -0,0 +1,190 @@
+//go:build unit
+
+package auth
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/samber/lo"
+
+ "git.sr.ht/~gabrielgio/img/pkg/ext"
+ "git.sr.ht/~gabrielgio/img/pkg/testkit"
+)
+
+type (
+ scene struct {
+ ctx context.Context
+ mockRepository *MockUserRepository
+ controller Controller
+ }
+
+ mockUser struct {
+ id uint
+ username string
+ password []byte
+ }
+
+ MockUserRepository struct {
+ index uint
+ users []*mockUser
+ err error
+ }
+)
+
+var (
+ _ Repository = &MockUserRepository{}
+ key = []byte("6368616e676520746869732070617373")
+)
+
+func setUp() *scene {
+ mockUserRepository := &MockUserRepository{}
+ return &scene{
+ ctx: context.Background(),
+ mockRepository: mockUserRepository,
+ controller: *NewController(mockUserRepository, key),
+ }
+}
+
+func TestRegisterAndLogin(t *testing.T) {
+ testCases := []struct {
+ name string
+ username string
+ password []byte
+ }{
+ {
+ name: "Normal register",
+ username: "username",
+ password: []byte("password"),
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ scene := setUp()
+
+ err := scene.controller.Register(scene.ctx, []byte(tc.username), tc.password)
+ testkit.TestFatalError(t, "Register", err)
+
+ userID := scene.mockRepository.GetLastId()
+
+ user, err := scene.mockRepository.Get(scene.ctx, userID)
+ testkit.TestFatalError(t, "Get", err)
+ testkit.TestValue(t, "Register", tc.username, user.Username)
+
+ auth, err := scene.controller.Login(scene.ctx, []byte(tc.username), tc.password)
+ testkit.TestFatalError(t, "Login", err)
+
+ token, err := ext.ReadToken(auth, key)
+ testkit.TestFatalError(t, "Login", err)
+
+ testkit.TestValue(t, "Login", tc.username, token.Username)
+ testkit.TestValue(t, "Login", userID, token.UserID)
+ })
+ }
+}
+
+func toUser(m *mockUser, _ int) *User {
+ return &User{
+ ID: m.id,
+ Username: m.username,
+ }
+}
+
+func (m *MockUserRepository) GetLastId() uint {
+ return m.index
+}
+
+func (m *MockUserRepository) List(ctx context.Context) ([]*User, error) {
+ if m.err != nil {
+ return nil, m.err
+ }
+
+ return lo.Map(m.users, toUser), nil
+}
+
+func (m *MockUserRepository) Get(ctx context.Context, id uint) (*User, error) {
+ if m.err != nil {
+ return nil, m.err
+ }
+
+ for _, m := range m.users {
+ if m.id == id {
+ return toUser(m, 0), nil
+ }
+ }
+ return nil, errors.New("Item not found")
+}
+
+func (m *MockUserRepository) GetIDByUsername(ctx context.Context, username string) (uint, error) {
+ if m.err != nil {
+ return 0, m.err
+ }
+
+ for _, m := range m.users {
+ if m.username == username {
+ return m.id, nil
+ }
+ }
+ return 0, errors.New("Item not found")
+}
+
+func (m *MockUserRepository) GetPassword(ctx context.Context, id uint) ([]byte, error) {
+ if m.err != nil {
+ return nil, m.err
+ }
+
+ for _, m := range m.users {
+ if m.id == id {
+ return m.password, nil
+ }
+ }
+ return nil, errors.New("Item not found")
+}
+
+func (m *MockUserRepository) Create(ctx context.Context, createUser *CreateUser) (uint, error) {
+ if m.err != nil {
+ return 0, m.err
+ }
+
+ m.index++
+
+ m.users = append(m.users, &mockUser{
+ id: m.index,
+ username: createUser.Username,
+ password: createUser.Password,
+ })
+
+ return m.index, nil
+}
+
+func (m *MockUserRepository) Update(ctx context.Context, id uint, update *UpdateUser) error {
+ if m.err != nil {
+ return m.err
+ }
+
+ for _, m := range m.users {
+ if m.id == id {
+ m.username = update.Username
+ }
+ }
+ return nil
+}
+
+func remove[T any](slice []T, s int) []T {
+ return append(slice[:s], slice[s+1:]...)
+}
+
+func (r *MockUserRepository) Delete(ctx context.Context, id uint) error {
+ if r.err != nil {
+ return r.err
+ }
+
+ for i, m := range r.users {
+ if m.id == id {
+ r.users = remove(r.users, i)
+ }
+ }
+ return nil
+}
diff --git a/pkg/components/auth/model.go b/pkg/components/auth/model.go
new file mode 100644
index 0000000..e46ef49
--- /dev/null
+++ b/pkg/components/auth/model.go
@@ -0,0 +1,32 @@
+package auth
+
+import "context"
+
+type (
+ // TODO: move to user later
+ User struct {
+ ID uint
+ Username string
+ Name string
+ }
+
+ // TODO: move to user later
+ UpdateUser struct {
+ Username string
+ Name string
+ }
+
+ // TODO: move to user later
+ CreateUser struct {
+ Username string
+ Name string
+ Password []byte
+ }
+
+ Repository interface {
+ GetIDByUsername(ctx context.Context, username string) (uint, error)
+ GetPassword(ctx context.Context, id uint) ([]byte, error)
+ // TODO: move to user later
+ Create(ctx context.Context, createUser *CreateUser) (uint, error)
+ }
+)
diff --git a/pkg/components/filesystem/controller.go b/pkg/components/filesystem/controller.go
new file mode 100644
index 0000000..6b478a5
--- /dev/null
+++ b/pkg/components/filesystem/controller.go
@@ -0,0 +1,89 @@
+package filesystem
+
+import (
+ "io/fs"
+ "net/url"
+ "path"
+ "strings"
+)
+
+type (
+ Controller struct {
+ repository Repository
+ }
+
+ DirectoryParam struct {
+ Name string
+ UrlEncodedPath string
+ }
+
+ FileParam struct {
+ UrlEncodedPath string
+ Info fs.FileInfo
+ }
+
+ Page struct {
+ History []*DirectoryParam
+ Files []*FileParam
+ }
+)
+
+func NewController(repository Repository) *Controller {
+ return &Controller{
+ repository: repository,
+ }
+}
+
+func getHistory(filepath string) []*DirectoryParam {
+ var (
+ paths = strings.Split(filepath, "/")
+ result = make([]*DirectoryParam, 0, len(paths))
+ acc = ""
+ )
+
+ // add root folder
+ result = append(result, &DirectoryParam{
+ Name: "...",
+ UrlEncodedPath: "",
+ })
+
+ if len(paths) == 1 && paths[0] == "" {
+ return result
+ }
+
+ for _, p := range paths {
+ acc = path.Join(acc, p)
+ result = append(result, &DirectoryParam{
+ Name: p,
+ UrlEncodedPath: url.QueryEscape(acc),
+ })
+ }
+ return result
+}
+
+func (self *Controller) GetPage(filepath string) (*Page, error) {
+ decodedPath, err := url.QueryUnescape(filepath)
+ if err != nil {
+ return nil, err
+ }
+
+ files, err := self.repository.List(decodedPath)
+ if err != nil {
+ return nil, err
+ }
+
+ params := make([]*FileParam, 0, len(files))
+ for _, info := range files {
+ fullPath := path.Join(decodedPath, info.Name())
+ scapedFullPath := url.QueryEscape(fullPath)
+ params = append(params, &FileParam{
+ Info: info,
+ UrlEncodedPath: scapedFullPath,
+ })
+ }
+
+ return &Page{
+ Files: params,
+ History: getHistory(decodedPath),
+ }, nil
+}
diff --git a/pkg/components/filesystem/model.go b/pkg/components/filesystem/model.go
new file mode 100644
index 0000000..2caed82
--- /dev/null
+++ b/pkg/components/filesystem/model.go
@@ -0,0 +1,10 @@
+package filesystem
+
+import "io/fs"
+
+type (
+ Repository interface {
+ List(path string) ([]fs.FileInfo, error)
+ Stat(path string) (fs.FileInfo, error)
+ }
+)
diff --git a/pkg/components/media/model.go b/pkg/components/media/model.go
new file mode 100644
index 0000000..f5c9ff6
--- /dev/null
+++ b/pkg/components/media/model.go
@@ -0,0 +1,57 @@
+package media
+
+import (
+ "context"
+ "time"
+)
+
+type (
+ Media struct {
+ ID uint
+ Name string
+ Path string
+ PathHash string
+ MIMEType string
+ }
+
+ MediaEXIF struct {
+ Description *string
+ Camera *string
+ Maker *string
+ Lens *string
+ DateShot *time.Time
+ Exposure *float64
+ Aperture *float64
+ Iso *int64
+ FocalLength *float64
+ Flash *int64
+ Orientation *int64
+ ExposureProgram *int64
+ GPSLatitude *float64
+ GPSLongitude *float64
+ }
+
+ Pagination struct {
+ Page int
+ Size int
+ }
+
+ CreateMedia struct {
+ Name string
+ Path string
+ PathHash string
+ MIMEType string
+ }
+
+ Repository interface {
+ Create(context.Context, *CreateMedia) error
+ Exists(context.Context, string) (bool, error)
+ List(context.Context, *Pagination) ([]*Media, error)
+ Get(context.Context, string) (*Media, error)
+ GetPath(context.Context, string) (string, error)
+
+ GetEmptyEXIF(context.Context, *Pagination) ([]*Media, error)
+ GetEXIF(context.Context, uint) (*MediaEXIF, error)
+ CreateEXIF(context.Context, uint, *MediaEXIF) error
+ }
+)
diff --git a/pkg/components/settings/model.go b/pkg/components/settings/model.go
new file mode 100644
index 0000000..da07f2c
--- /dev/null
+++ b/pkg/components/settings/model.go
@@ -0,0 +1,15 @@
+package settings
+
+import "context"
+
+type (
+ Settings struct {
+ ShowMode bool
+ ShowOwner bool
+ }
+
+ Repository interface {
+ Save(context.Context, *Settings) error
+ Load(context.Context) (*Settings, error)
+ }
+)
diff --git a/pkg/database/localfs/filesystem.go b/pkg/database/localfs/filesystem.go
new file mode 100644
index 0000000..c7c6458
--- /dev/null
+++ b/pkg/database/localfs/filesystem.go
@@ -0,0 +1,49 @@
+package localfs
+
+import (
+ "io/fs"
+ "os"
+ "path"
+ "strings"
+)
+
+type FileSystemRepository struct {
+ root string
+}
+
+func NewFileSystemRepository(root string) *FileSystemRepository {
+ return &FileSystemRepository{
+ root: root,
+ }
+}
+
+func (self *FileSystemRepository) getFilesFromPath(filepath string) ([]fs.FileInfo, error) {
+ dirs, err := os.ReadDir(filepath)
+ if err != nil {
+ return nil, err
+ }
+
+ infos := make([]fs.FileInfo, 0, len(dirs))
+ for _, dir := range dirs {
+ if strings.HasPrefix(dir.Name(), ".") {
+ continue
+ }
+ info, err := dir.Info()
+ if err != nil {
+ return nil, err
+ }
+ infos = append(infos, info)
+ }
+
+ return infos, nil
+}
+
+func (self *FileSystemRepository) List(filepath string) ([]fs.FileInfo, error) {
+ workingPath := path.Join(self.root, filepath)
+ return self.getFilesFromPath(workingPath)
+}
+
+func (self *FileSystemRepository) Stat(filepath string) (fs.FileInfo, error) {
+ workingPath := path.Join(self.root, filepath)
+ return os.Stat(workingPath)
+}
diff --git a/pkg/database/sql/media.go b/pkg/database/sql/media.go
new file mode 100644
index 0000000..835e262
--- /dev/null
+++ b/pkg/database/sql/media.go
@@ -0,0 +1,238 @@
+package sql
+
+import (
+ "context"
+ "time"
+
+ "gorm.io/gorm"
+
+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
+ "git.sr.ht/~gabrielgio/img/pkg/list"
+)
+
+type (
+ Media struct {
+ gorm.Model
+ Name string `gorm:"not null"`
+ Path string `gorm:"not null;unique"`
+ PathHash string `gorm:"not null;unique"`
+ MIMEType string `gorm:"not null"`
+ }
+
+ MediaEXIF struct {
+ gorm.Model
+ MediaID uint
+ Media Media
+ Description *string
+ Camera *string
+ Maker *string
+ Lens *string
+ DateShot *time.Time
+ Exposure *float64
+ Aperture *float64
+ Iso *int64
+ FocalLength *float64
+ Flash *int64
+ Orientation *int64
+ ExposureProgram *int64
+ GPSLatitude *float64
+ GPSLongitude *float64
+ }
+
+ MediaRepository struct {
+ db *gorm.DB
+ }
+)
+
+var _ media.Repository = &MediaRepository{}
+
+func (self *Media) ToModel() *media.Media {
+ return &media.Media{
+ ID: self.ID,
+ Path: self.Path,
+ PathHash: self.PathHash,
+ Name: self.Name,
+ MIMEType: self.MIMEType,
+ }
+}
+
+func (m *MediaEXIF) ToModel() *media.MediaEXIF {
+ return &media.MediaEXIF{
+ Description: m.Description,
+ Camera: m.Camera,
+ Maker: m.Maker,
+ Lens: m.Lens,
+ DateShot: m.DateShot,
+ Exposure: m.Exposure,
+ Aperture: m.Aperture,
+ Iso: m.Iso,
+ FocalLength: m.FocalLength,
+ Flash: m.Flash,
+ Orientation: m.Orientation,
+ ExposureProgram: m.ExposureProgram,
+ GPSLatitude: m.GPSLatitude,
+ GPSLongitude: m.GPSLongitude,
+ }
+}
+
+func NewMediaRepository(db *gorm.DB) *MediaRepository {
+ return &MediaRepository{
+ db: db,
+ }
+}
+
+func (self *MediaRepository) Create(ctx context.Context, createMedia *media.CreateMedia) error {
+ media := &Media{
+ Name: createMedia.Name,
+ Path: createMedia.Path,
+ PathHash: createMedia.PathHash,
+ MIMEType: createMedia.MIMEType,
+ }
+
+ result := self.db.
+ WithContext(ctx).
+ Create(media)
+ if result.Error != nil {
+ return result.Error
+ }
+
+ return nil
+}
+
+func (self *MediaRepository) Exists(ctx context.Context, path string) (bool, error) {
+ var exists bool
+ result := self.db.
+ WithContext(ctx).
+ Model(&Media{}).
+ Select("count(id) > 0").
+ Where("path_hash = ?", path).
+ Find(&exists)
+
+ if result.Error != nil {
+ return false, result.Error
+ }
+
+ return exists, nil
+}
+
+func (self *MediaRepository) List(ctx context.Context, pagination *media.Pagination) ([]*media.Media, error) {
+ medias := make([]*Media, 0)
+ result := self.db.
+ WithContext(ctx).
+ Model(&Media{}).
+ Offset(pagination.Page * pagination.Size).
+ Limit(pagination.Size).
+ Order("created_at DESC").
+ Find(&medias)
+
+ if result.Error != nil {
+ return nil, result.Error
+ }
+
+ m := list.Map(medias, func(s *Media) *media.Media {
+ return s.ToModel()
+ })
+
+ return m, nil
+}
+
+func (self *MediaRepository) Get(ctx context.Context, pathHash string) (*media.Media, error) {
+ m := &Media{}
+ result := self.db.
+ WithContext(ctx).
+ Model(&Media{}).
+ Where("path_hash = ?", pathHash).
+ Limit(1).
+ Take(m)
+
+ if result.Error != nil {
+ return nil, result.Error
+ }
+
+ return m.ToModel(), nil
+}
+
+func (self *MediaRepository) GetPath(ctx context.Context, pathHash string) (string, error) {
+ var path string
+ result := self.db.
+ WithContext(ctx).
+ Model(&Media{}).
+ Select("path").
+ Where("path_hash = ?", pathHash).
+ Limit(1).
+ Find(&path)
+
+ if result.Error != nil {
+ return "", result.Error
+ }
+
+ return path, nil
+}
+
+func (m *MediaRepository) GetEXIF(ctx context.Context, mediaID uint) (*media.MediaEXIF, error) {
+ exif := &MediaEXIF{}
+ result := m.db.
+ WithContext(ctx).
+ Model(&Media{}).
+ Where("media_id = ?", mediaID).
+ Limit(1).
+ Take(m)
+
+ if result.Error != nil {
+ return nil, result.Error
+ }
+
+ return exif.ToModel(), nil
+}
+
+func (s *MediaRepository) CreateEXIF(ctx context.Context, id uint, info *media.MediaEXIF) error {
+ media := &MediaEXIF{
+ MediaID: id,
+ Description: info.Description,
+ Camera: info.Camera,
+ Maker: info.Maker,
+ Lens: info.Lens,
+ DateShot: info.DateShot,
+ Exposure: info.Exposure,
+ Aperture: info.Aperture,
+ Iso: info.Iso,
+ FocalLength: info.FocalLength,
+ Flash: info.Flash,
+ Orientation: info.Orientation,
+ ExposureProgram: info.ExposureProgram,
+ GPSLatitude: info.GPSLatitude,
+ GPSLongitude: info.GPSLongitude,
+ }
+
+ result := s.db.
+ WithContext(ctx).
+ Create(media)
+ if result.Error != nil {
+ return result.Error
+ }
+
+ return nil
+}
+
+func (r *MediaRepository) GetEmptyEXIF(ctx context.Context, pagination *media.Pagination) ([]*media.Media, error) {
+ medias := make([]*Media, 0)
+ result := r.db.
+ WithContext(ctx).
+ Model(&Media{}).
+ Joins("left join media_exifs on media.id = media_exifs.media_id").
+ Where("media_exifs.media_id IS NULL").
+ Offset(pagination.Page * pagination.Size).
+ Limit(pagination.Size).
+ Order("created_at DESC").
+ Find(&medias)
+
+ if result.Error != nil {
+ return nil, result.Error
+ }
+
+ m := list.Map(medias, func(s *Media) *media.Media {
+ return s.ToModel()
+ })
+
+ return m, nil
+}
diff --git a/pkg/database/sql/migration.go b/pkg/database/sql/migration.go
new file mode 100644
index 0000000..019eb91
--- /dev/null
+++ b/pkg/database/sql/migration.go
@@ -0,0 +1,17 @@
+package sql
+
+import "gorm.io/gorm"
+
+func Migrate(db *gorm.DB) error {
+ for _, m := range []any{
+ &User{},
+ &Settings{},
+ &Media{},
+ &MediaEXIF{},
+ } {
+ if err := db.AutoMigrate(m); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/pkg/database/sql/settings.go b/pkg/database/sql/settings.go
new file mode 100644
index 0000000..7ad718b
--- /dev/null
+++ b/pkg/database/sql/settings.go
@@ -0,0 +1,69 @@
+package sql
+
+import (
+ "context"
+
+ "gorm.io/gorm"
+
+ "git.sr.ht/~gabrielgio/img/pkg/components/settings"
+)
+
+type (
+ Settings struct {
+ gorm.Model
+ ShowMode bool
+ ShowOwner bool
+ }
+
+ SettingsRepository struct {
+ db *gorm.DB
+ }
+)
+
+var _ settings.Repository = &SettingsRepository{}
+
+func NewSettingsRespository(db *gorm.DB) *SettingsRepository {
+ return &SettingsRepository{
+ db: db,
+ }
+}
+
+func (self *SettingsRepository) ensureSettings(ctx context.Context) (*Settings, error) {
+ var (
+ db = self.db.WithContext(ctx)
+ s = &Settings{}
+ )
+ result := db.Limit(1).Find(s)
+ if result.Error != nil {
+ return nil, result.Error
+ }
+
+ return s, nil
+}
+
+func (self *SettingsRepository) Save(ctx context.Context, toSaveSettings *settings.Settings) error {
+ db := self.db.WithContext(ctx)
+
+ s, err := self.ensureSettings(ctx)
+ if err != nil {
+ return err
+ }
+
+ s.ShowMode = toSaveSettings.ShowMode
+ s.ShowOwner = toSaveSettings.ShowOwner
+
+ result := db.Save(s)
+ return result.Error
+}
+
+func (self *SettingsRepository) Load(ctx context.Context) (*settings.Settings, error) {
+ s, err := self.ensureSettings(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ return &settings.Settings{
+ ShowMode: s.ShowMode,
+ ShowOwner: s.ShowOwner,
+ }, nil
+}
diff --git a/pkg/database/sql/user.go b/pkg/database/sql/user.go
new file mode 100644
index 0000000..d449b05
--- /dev/null
+++ b/pkg/database/sql/user.go
@@ -0,0 +1,182 @@
+package sql
+
+import (
+ "context"
+
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+
+ "git.sr.ht/~gabrielgio/img/pkg/components/auth"
+ user "git.sr.ht/~gabrielgio/img/pkg/components/auth"
+)
+
+type (
+ User struct {
+ gorm.Model
+ Username string
+ Name string
+ Password string
+ }
+
+ Users []*User
+
+ UserRepository struct {
+ db *gorm.DB
+ }
+)
+
+var _ auth.Repository = &UserRepository{}
+
+func NewUserRepository(db *gorm.DB) *UserRepository {
+ return &UserRepository{
+ db: db,
+ }
+}
+
+func (self *User) ToModel() *user.User {
+ return &user.User{
+ ID: self.Model.ID,
+ Name: self.Name,
+ Username: self.Username,
+ }
+}
+
+func (self Users) ToModel() (users []*user.User) {
+ for _, user := range self {
+ users = append(users, user.ToModel())
+ }
+ return
+}
+
+// Testing function, will remove later
+// TODO: remove later
+func (self *UserRepository) EnsureAdmin(ctx context.Context) {
+ var exists bool
+ self.db.
+ WithContext(ctx).
+ Model(&User{}).
+ Select("count(*) > 0").
+ Where("username = ?", "admin").
+ Find(&exists)
+
+ if !exists {
+ hash, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.MinCost)
+ self.db.Save(&User{
+ Username: "admin",
+ Password: string(hash),
+ })
+ }
+}
+
+func (self *UserRepository) List(ctx context.Context) ([]*user.User, error) {
+ users := Users{}
+ result := self.db.
+ WithContext(ctx).
+ Find(&users)
+
+ if result.Error != nil {
+ return nil, result.Error
+ }
+
+ return users.ToModel(), nil
+}
+
+func (self *UserRepository) Get(ctx context.Context, id uint) (*user.User, error) {
+ var user = &user.User{ID: id}
+ result := self.db.
+ WithContext(ctx).
+ First(user)
+
+ if result.Error != nil {
+ return nil, result.Error
+ }
+
+ return user, nil
+}
+
+func (self *UserRepository) GetIDByUsername(ctx context.Context, username string) (uint, error) {
+ userID := struct {
+ ID uint
+ }{}
+
+ result := self.db.
+ WithContext(ctx).
+ Model(&User{}).
+ Where("username = ?", username).
+ First(&userID)
+
+ if result.Error != nil {
+ return 0, result.Error
+ }
+
+ return userID.ID, nil
+}
+
+func (self *UserRepository) GetPassword(ctx context.Context, id uint) ([]byte, error) {
+ userPassword := struct {
+ Password []byte
+ }{}
+
+ result := self.db.
+ WithContext(ctx).
+ Model(&User{}).
+ Where("id = ?", id).
+ First(&userPassword)
+
+ if result.Error != nil {
+ return nil, result.Error
+ }
+
+ return userPassword.Password, nil
+}
+
+func (self *UserRepository) Create(ctx context.Context, createUser *user.CreateUser) (uint, error) {
+ user := &User{
+ Username: createUser.Username,
+ Name: createUser.Name,
+ Password: string(createUser.Password),
+ }
+
+ result := self.db.
+ WithContext(ctx).
+ Create(user)
+ if result.Error != nil {
+ return 0, result.Error
+ }
+
+ return user.Model.ID, nil
+}
+
+func (self *UserRepository) Update(ctx context.Context, id uint, update *user.UpdateUser) error {
+ user := &User{
+ Model: gorm.Model{
+ ID: id,
+ },
+ Username: update.Username,
+ Name: update.Name,
+ }
+
+ result := self.db.
+ WithContext(ctx).
+ Save(user)
+ if result.Error != nil {
+ return result.Error
+ }
+
+ return nil
+}
+
+func (self *UserRepository) Delete(ctx context.Context, id uint) error {
+ userID := struct {
+ ID uint
+ }{
+ ID: id,
+ }
+ result := self.db.
+ WithContext(ctx).
+ Delete(userID)
+ if result.Error != nil {
+ return result.Error
+ }
+ return nil
+}
diff --git a/pkg/database/sql/user_test.go b/pkg/database/sql/user_test.go
new file mode 100644
index 0000000..875b8e6
--- /dev/null
+++ b/pkg/database/sql/user_test.go
@@ -0,0 +1,110 @@
+//go:build integration
+
+package sql
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+
+ "git.sr.ht/~gabrielgio/img/pkg/components/auth"
+)
+
+func setup(t *testing.T) (*gorm.DB, func()) {
+ t.Helper()
+
+ file, err := os.CreateTemp("", "img_user_*.db")
+ if err != nil {
+ t.Fatalf("Error creating tmp error: %s", err.Error())
+ }
+
+ db, err := gorm.Open(sqlite.Open(file.Name()), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Info),
+ })
+ if err != nil {
+ t.Fatalf("Error openning db, error %s", err.Error())
+ }
+
+ err = Migrate(db)
+ if err != nil {
+ t.Fatalf("Error migrating db, error %s", err.Error())
+ }
+
+ return db, func() {
+ //nolint:errcheck
+ os.Remove(file.Name())
+ }
+}
+
+func TestCreate(t *testing.T) {
+ t.Parallel()
+ db, tearDown := setup(t)
+ defer tearDown()
+
+ repository := NewUserRepository(db)
+
+ id, err := repository.Create(context.Background(), &auth.CreateUser{
+ Username: "new_username",
+ Name: "new_name",
+ })
+ if err != nil {
+ t.Fatalf("Error creating: %s", err.Error())
+ }
+
+ got, err := repository.Get(context.Background(), id)
+ if err != nil {
+ t.Fatalf("Error getting: %s", err.Error())
+ }
+ want := &auth.User{
+ ID: id,
+ Username: "new_username",
+ Name: "new_name",
+ }
+
+ if diff := cmp.Diff(want, got); diff != "" {
+ t.Errorf("%s() mismatch (-want +got):\n%s", "Update", diff)
+ }
+}
+
+func TestUpdate(t *testing.T) {
+ t.Parallel()
+ db, tearDown := setup(t)
+ defer tearDown()
+
+ repository := NewUserRepository(db)
+
+ id, err := repository.Create(context.Background(), &auth.CreateUser{
+ Username: "username",
+ Name: "name",
+ })
+ if err != nil {
+ t.Fatalf("Error creating user: %s", err.Error())
+ }
+
+ err = repository.Update(context.Background(), id, &auth.UpdateUser{
+ Username: "new_username",
+ Name: "new_name",
+ })
+ if err != nil {
+ t.Fatalf("Error update user: %s", err.Error())
+ }
+
+ got, err := repository.Get(context.Background(), id)
+ if err != nil {
+ t.Fatalf("Error getting user: %s", err.Error())
+ }
+ want := &auth.User{
+ ID: id,
+ Username: "new_username",
+ Name: "new_name",
+ }
+
+ if diff := cmp.Diff(want, got); diff != "" {
+ t.Errorf("%s() mismatch (-want +got):\n%s", "Update", diff)
+ }
+}
diff --git a/pkg/ext/auth.go b/pkg/ext/auth.go
new file mode 100644
index 0000000..d9fbfba
--- /dev/null
+++ b/pkg/ext/auth.go
@@ -0,0 +1,72 @@
+package ext
+
+import (
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/gob"
+ "fmt"
+ "io"
+)
+
+type Token struct {
+ UserID uint
+ Username string
+}
+
+var nonce []byte
+
+func init() {
+ nonce = make([]byte, 12)
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ fmt.Println("Erro while generating nonce " + err.Error())
+ panic(1)
+ }
+}
+
+func ReadToken(data []byte, key []byte) (*Token, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+
+ aesgcm, err := cipher.NewGCM(block)
+ if err != nil {
+ panic(err.Error())
+ }
+
+ plaintext, err := aesgcm.Open(nil, nonce, data, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ r := bytes.NewReader(plaintext)
+ var token Token
+ dec := gob.NewDecoder(r)
+ if err = dec.Decode(&token); err != nil {
+ return nil, err
+ }
+ return &token, nil
+}
+
+func WriteToken(token *Token, key []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+
+ aesgcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+
+ var buffer bytes.Buffer
+ enc := gob.NewEncoder(&buffer)
+ if err := enc.Encode(token); err != nil {
+ return nil, err
+ }
+
+ ciphertext := aesgcm.Seal(nil, nonce, buffer.Bytes(), nil)
+ return ciphertext, nil
+}
diff --git a/pkg/ext/auth_test.go b/pkg/ext/auth_test.go
new file mode 100644
index 0000000..dc72a0c
--- /dev/null
+++ b/pkg/ext/auth_test.go
@@ -0,0 +1,40 @@
+//go:build unit
+
+package ext
+
+import (
+ "testing"
+
+ "git.sr.ht/~gabrielgio/img/pkg/testkit"
+)
+
+func TestReadWriteToken(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ key []byte
+ token *Token
+ }{
+ {
+ name: "Normal write",
+ key: []byte("AES256Key-32Characters1234567890"),
+ token: &Token{
+ UserID: 3,
+ Username: "username",
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ data, err := WriteToken(tc.token, tc.key)
+ testkit.TestFatalError(t, "WriteToken", err)
+
+ token, err := ReadToken(data, tc.key)
+ testkit.TestFatalError(t, "ReadToken", err)
+
+ testkit.TestValue(t, "ReadWriteToken", token, tc.token)
+ })
+ }
+}
diff --git a/pkg/ext/gorm_logger.go b/pkg/ext/gorm_logger.go
new file mode 100644
index 0000000..bfb26d2
--- /dev/null
+++ b/pkg/ext/gorm_logger.go
@@ -0,0 +1,58 @@
+package ext
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/sirupsen/logrus"
+ "gorm.io/gorm/logger"
+ "gorm.io/gorm/utils"
+)
+
+type Log struct {
+ logrus *logrus.Entry
+}
+
+func getFullMsg(msg string, data ...interface{}) string {
+ return fmt.Sprintf(msg, append([]interface{}{utils.FileWithLineNum()}, data...)...)
+}
+
+func (self *Log) LogMode(log logger.LogLevel) logger.Interface {
+ return self
+}
+
+func (self *Log) Info(ctx context.Context, msg string, data ...interface{}) {
+ fullMsg := getFullMsg(msg, data)
+ self.logrus.
+ WithContext(ctx).
+ Info(fullMsg)
+}
+
+func (self *Log) Warn(ctx context.Context, msg string, data ...interface{}) {
+ fullMsg := getFullMsg(msg, data)
+ self.logrus.
+ WithContext(ctx).
+ Warn(fullMsg)
+}
+func (self *Log) Error(ctx context.Context, msg string, data ...interface{}) {
+ fullMsg := getFullMsg(msg, data)
+ self.logrus.
+ WithContext(ctx).
+ Error(fullMsg)
+}
+
+func (self *Log) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
+ elapsed := time.Since(begin)
+ sql, _ := fc()
+ self.logrus.
+ WithContext(ctx).
+ WithField("time", elapsed).
+ Printf(sql)
+}
+
+func Wraplog(log *logrus.Entry) *Log {
+ return &Log{
+ logrus: log,
+ }
+}
diff --git a/pkg/ext/middleware.go b/pkg/ext/middleware.go
new file mode 100644
index 0000000..771c0ac
--- /dev/null
+++ b/pkg/ext/middleware.go
@@ -0,0 +1,89 @@
+package ext
+
+import (
+ "encoding/base64"
+ "time"
+
+ "github.com/sirupsen/logrus"
+ "github.com/valyala/fasthttp"
+)
+
+func HTML(next fasthttp.RequestHandler) fasthttp.RequestHandler {
+ return func(ctx *fasthttp.RequestCtx) {
+ ctx.Response.Header.SetContentType("text/html")
+ next(ctx)
+ }
+}
+
+type LogMiddleware struct {
+ entry *logrus.Entry
+}
+
+func NewLogMiddleare(log *logrus.Entry) *LogMiddleware {
+ return &LogMiddleware{
+ entry: log,
+ }
+}
+
+func (l *LogMiddleware) HTTP(next fasthttp.RequestHandler) fasthttp.RequestHandler {
+ return func(ctx *fasthttp.RequestCtx) {
+ start := time.Now()
+ next(ctx)
+ elapsed := time.Since(start)
+ l.entry.
+ WithField("time", elapsed).
+ WithField("code", ctx.Response.StatusCode()).
+ WithField("path", string(ctx.Path())).
+ WithField("bytes", len(ctx.Response.Body())).
+ Info(string(ctx.Request.Header.Method()))
+ }
+}
+
+type AuthMiddleware struct {
+ key []byte
+ entry *logrus.Entry
+}
+
+func NewAuthMiddleware(key []byte, log *logrus.Entry) *AuthMiddleware {
+ return &AuthMiddleware{
+ key: key,
+ entry: log.WithField("context", "auth"),
+ }
+}
+
+func (a *AuthMiddleware) LoggedIn(next fasthttp.RequestHandler) fasthttp.RequestHandler {
+ return func(ctx *fasthttp.RequestCtx) {
+ path := string(ctx.Path())
+ if path == "/login" {
+ next(ctx)
+ return
+ }
+
+ redirectLogin := "/login?redirect=" + path
+ authBase64 := ctx.Request.Header.Cookie("auth")
+ if authBase64 == nil {
+ a.entry.Info("No auth provided")
+ ctx.Redirect(redirectLogin, 307)
+ return
+ }
+
+ auth, err := base64.StdEncoding.DecodeString(string(authBase64))
+ if err != nil {
+ a.entry.Error(err)
+ return
+ }
+
+ token, err := ReadToken(auth, a.key)
+ if err != nil {
+ a.entry.Error(err)
+ ctx.Redirect(redirectLogin, 307)
+ return
+ }
+ ctx.SetUserValue("token", token)
+ a.entry.
+ WithField("userID", token.UserID).
+ WithField("username", token.Username).
+ Info("user recognized")
+ next(ctx)
+ }
+}
diff --git a/pkg/ext/responses.go b/pkg/ext/responses.go
new file mode 100644
index 0000000..7354395
--- /dev/null
+++ b/pkg/ext/responses.go
@@ -0,0 +1,50 @@
+package ext
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/valyala/fasthttp"
+
+ "git.sr.ht/~gabrielgio/img"
+)
+
+var (
+ ContentTypeJSON = []byte("application/json")
+ ContentTypeHTML = []byte("text/html")
+ ContentTypeMARKDOWN = []byte("text/markdown")
+ ContentTypeJPEG = []byte("image/jpeg")
+)
+
+func NotFoundHTML(ctx *fasthttp.RequestCtx) {
+ ctx.Response.Header.SetContentType("text/html")
+ //nolint:errcheck
+ img.Render(ctx, "error.html", &img.HTMLView[string]{
+ Data: "NotFound",
+ })
+}
+
+func NotFound(ctx *fasthttp.RequestCtx) {
+ ctx.Response.SetStatusCode(404)
+ ct := ctx.Response.Header.ContentType()
+ if bytes.Equal(ct, ContentTypeHTML) {
+ NotFoundHTML(ctx)
+ }
+}
+
+func InternalServerError(ctx *fasthttp.RequestCtx, err error) {
+ ctx.Response.Header.SetContentType("text/html")
+ message := fmt.Sprintf("Internal Server Error:\n%+v", err)
+ //nolint:errcheck
+ respErr := img.Render(ctx, "error.html", &img.HTMLView[string]{
+ Data: message,
+ })
+
+ if respErr != nil {
+ fmt.Println(respErr.Error())
+ }
+}
+
+func NoContent(ctx *fasthttp.RequestCtx) {
+ ctx.Response.SetStatusCode(204)
+}
diff --git a/pkg/ext/router.go b/pkg/ext/router.go
new file mode 100644
index 0000000..74f0a95
--- /dev/null
+++ b/pkg/ext/router.go
@@ -0,0 +1,51 @@
+package ext
+
+import (
+ "github.com/fasthttp/router"
+ "github.com/valyala/fasthttp"
+)
+
+type (
+ Router struct {
+ middlewares []Middleware
+ fastRouter *router.Router
+ }
+ Middleware func(next fasthttp.RequestHandler) fasthttp.RequestHandler
+ ErrorRequestHandler func(ctx *fasthttp.RequestCtx) error
+)
+
+func NewRouter(nestedRouter *router.Router) *Router {
+ return &Router{
+ fastRouter: nestedRouter,
+ }
+}
+
+func (self *Router) AddMiddleware(middleware Middleware) {
+ self.middlewares = append(self.middlewares, middleware)
+}
+
+func wrapError(next ErrorRequestHandler) fasthttp.RequestHandler {
+ return func(ctx *fasthttp.RequestCtx) {
+ if err := next(ctx); err != nil {
+ ctx.Response.SetStatusCode(500)
+ InternalServerError(ctx, err)
+ }
+ }
+}
+
+func (self *Router) run(next ErrorRequestHandler) fasthttp.RequestHandler {
+ return func(ctx *fasthttp.RequestCtx) {
+ req := wrapError(next)
+ for _, r := range self.middlewares {
+ req = r(req)
+ }
+ req(ctx)
+ }
+}
+
+func (self *Router) GET(path string, handler ErrorRequestHandler) {
+ self.fastRouter.GET(path, self.run(handler))
+}
+func (self *Router) POST(path string, handler ErrorRequestHandler) {
+ self.fastRouter.POST(path, self.run(handler))
+}
diff --git a/pkg/fileop/exif.go b/pkg/fileop/exif.go
new file mode 100644
index 0000000..48e495c
--- /dev/null
+++ b/pkg/fileop/exif.go
@@ -0,0 +1,165 @@
+package fileop
+
+import (
+ "math"
+ "time"
+
+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
+ "github.com/barasher/go-exiftool"
+)
+
+func ReadExif(path string) (*media.MediaEXIF, error) {
+ et, err := exiftool.NewExiftool()
+ if err != nil {
+ return nil, err
+ }
+ defer et.Close()
+
+ newExif := &media.MediaEXIF{}
+ fileInfo := et.ExtractMetadata(path)[0]
+
+ // Get description
+ description, err := fileInfo.GetString("ImageDescription")
+ if err == nil {
+ newExif.Description = &description
+ }
+
+ // Get camera model
+ model, err := fileInfo.GetString("Model")
+ if err == nil {
+ newExif.Camera = &model
+ }
+
+ // Get Camera make
+ make, err := fileInfo.GetString("Make")
+ if err == nil {
+ newExif.Maker = &make
+ }
+
+ // Get lens
+ lens, err := fileInfo.GetString("LensModel")
+ if err == nil {
+ newExif.Lens = &lens
+ }
+
+ //Get time of photo
+ createDateKeys := []string{
+ "CreationDate",
+ "DateTimeOriginal",
+ "CreateDate",
+ "TrackCreateDate",
+ "MediaCreateDate",
+ "FileCreateDate",
+ "ModifyDate",
+ "TrackModifyDate",
+ "MediaModifyDate",
+ "FileModifyDate",
+ }
+ for _, createDateKey := range createDateKeys {
+ date, err := fileInfo.GetString(createDateKey)
+ if err == nil {
+ layout := "2006:01:02 15:04:05"
+ dateTime, err := time.Parse(layout, date)
+ if err == nil {
+ newExif.DateShot = &dateTime
+ } else {
+ layoutWithOffset := "2006:01:02 15:04:05+02:00"
+ dateTime, err = time.Parse(layoutWithOffset, date)
+ if err == nil {
+ newExif.DateShot = &dateTime
+ }
+ }
+ break
+ }
+ }
+
+ // Get exposure time
+ exposureTime, err := fileInfo.GetFloat("ExposureTime")
+ if err == nil {
+ newExif.Exposure = &exposureTime
+ }
+
+ // Get aperture
+ aperture, err := fileInfo.GetFloat("Aperture")
+ if err == nil {
+ newExif.Aperture = &aperture
+ }
+
+ // Get ISO
+ iso, err := fileInfo.GetInt("ISO")
+ if err == nil {
+ newExif.Iso = &iso
+ }
+
+ // Get focal length
+ focalLen, err := fileInfo.GetFloat("FocalLength")
+ if err == nil {
+ newExif.FocalLength = &focalLen
+ }
+
+ // Get flash info
+ flash, err := fileInfo.GetInt("Flash")
+ if err == nil {
+ newExif.Flash = &flash
+ }
+
+ // Get orientation
+ orientation, err := fileInfo.GetInt("Orientation")
+ if err == nil {
+ newExif.Orientation = &orientation
+ }
+
+ // Get exposure program
+ expProgram, err := fileInfo.GetInt("ExposureProgram")
+ if err == nil {
+ newExif.ExposureProgram = &expProgram
+ }
+
+ // GPS coordinates - longitude
+ longitudeRaw, err := fileInfo.GetFloat("GPSLongitude")
+ if err == nil {
+ newExif.GPSLongitude = &longitudeRaw
+ }
+
+ // GPS coordinates - latitude
+ latitudeRaw, err := fileInfo.GetFloat("GPSLatitude")
+ if err == nil {
+ newExif.GPSLatitude = &latitudeRaw
+ }
+
+ sanitizeEXIF(newExif)
+
+ return newExif, nil
+}
+
+// isFloatReal returns true when the float value represents a real number
+// (different than +Inf, -Inf or NaN)
+func isFloatReal(v float64) bool {
+ if math.IsInf(v, 1) {
+ return false
+ } else if math.IsInf(v, -1) {
+ return false
+ } else if math.IsNaN(v) {
+ return false
+ }
+ return true
+}
+
+// sanitizeEXIF removes any EXIF float64 field that is not a real number (+Inf,
+// -Inf or Nan)
+func sanitizeEXIF(exif *media.MediaEXIF) {
+ if exif.Exposure != nil && !isFloatReal(*exif.Exposure) {
+ exif.Exposure = nil
+ }
+ if exif.Aperture != nil && !isFloatReal(*exif.Aperture) {
+ exif.Aperture = nil
+ }
+ if exif.FocalLength != nil && !isFloatReal(*exif.FocalLength) {
+ exif.FocalLength = nil
+ }
+ if (exif.GPSLatitude != nil && !isFloatReal(*exif.GPSLatitude)) ||
+ (exif.GPSLongitude != nil && !isFloatReal(*exif.GPSLongitude)) {
+ exif.GPSLatitude = nil
+ exif.GPSLongitude = nil
+ }
+}
diff --git a/pkg/list/list.go b/pkg/list/list.go
new file mode 100644
index 0000000..ff259f7
--- /dev/null
+++ b/pkg/list/list.go
@@ -0,0 +1,9 @@
+package list
+
+func Map[V any, T any](source []V, fun func(V) T) []T {
+ result := make([]T, 0, len(source))
+ for _, s := range source {
+ result = append(result, fun(s))
+ }
+ return result
+}
diff --git a/pkg/testkit/testkit.go b/pkg/testkit/testkit.go
new file mode 100644
index 0000000..526e1b3
--- /dev/null
+++ b/pkg/testkit/testkit.go
@@ -0,0 +1,31 @@
+//go:build unit || integration
+
+package testkit
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestValue[T any](t *testing.T, method string, want, got T) {
+ if diff := cmp.Diff(want, got); diff != "" {
+ t.Errorf("%s() mismatch (-want +got):\n%s", method, diff)
+ }
+}
+
+func TestFatalError(t *testing.T, method string, err error) {
+ if err != nil {
+ t.Fatalf("%s() fatal error : %+v", method, err)
+ }
+}
+
+func TestError(t *testing.T, method string, want, got error) {
+ if !equalError(want, got) {
+ t.Errorf("%s() err mismatch want: %+v got %+v", method, want, got)
+ }
+}
+
+func equalError(a, b error) bool {
+ return a == nil && b == nil || a != nil && b != nil && a.Error() == b.Error()
+}
diff --git a/pkg/view/auth.go b/pkg/view/auth.go
new file mode 100644
index 0000000..5c83eba
--- /dev/null
+++ b/pkg/view/auth.go
@@ -0,0 +1,97 @@
+package view
+
+import (
+ "encoding/base64"
+
+ "github.com/valyala/fasthttp"
+
+ "git.sr.ht/~gabrielgio/img"
+ "git.sr.ht/~gabrielgio/img/pkg/components/auth"
+ "git.sr.ht/~gabrielgio/img/pkg/ext"
+)
+
+type AuthView struct {
+ userController *auth.Controller
+}
+
+func NewAuthView(userController *auth.Controller) *AuthView {
+ return &AuthView{
+ userController: userController,
+ }
+}
+
+func (v *AuthView) LoginView(ctx *fasthttp.RequestCtx) error {
+ return img.Render[interface{}](ctx, "login.html", nil)
+}
+
+func (v *AuthView) Logout(ctx *fasthttp.RequestCtx) error {
+ cook := fasthttp.Cookie{}
+ cook.SetKey("auth")
+ cook.SetValue("")
+ cook.SetMaxAge(-1)
+ cook.SetHTTPOnly(true)
+ cook.SetSameSite(fasthttp.CookieSameSiteDefaultMode)
+ ctx.Response.Header.SetCookie(&cook)
+
+ ctx.Redirect("/", 307)
+ return nil
+}
+
+func (v *AuthView) Login(ctx *fasthttp.RequestCtx) error {
+ username := ctx.FormValue("username")
+ password := ctx.FormValue("password")
+
+ auth, err := v.userController.Login(ctx, username, password)
+ if err != nil {
+ return err
+ }
+
+ base64Auth := base64.StdEncoding.EncodeToString(auth)
+
+ cook := fasthttp.Cookie{}
+ cook.SetKey("auth")
+ cook.SetValue(base64Auth)
+ cook.SetHTTPOnly(true)
+ cook.SetSameSite(fasthttp.CookieSameSiteDefaultMode)
+ ctx.Response.Header.SetCookie(&cook)
+
+ redirect := string(ctx.FormValue("redirect"))
+ if redirect == "" {
+ ctx.Redirect("/", 307)
+ } else {
+ ctx.Redirect(redirect, 307)
+ }
+ return nil
+}
+
+func (v *AuthView) RegisterView(ctx *fasthttp.RequestCtx) error {
+ return img.Render[interface{}](ctx, "register.html", nil)
+}
+
+func (v *AuthView) Register(ctx *fasthttp.RequestCtx) error {
+ username := ctx.FormValue("username")
+ password := ctx.FormValue("password")
+
+ err := v.userController.Register(ctx, username, password)
+ if err != nil {
+ return err
+ }
+
+ ctx.Redirect("/login", 307)
+ return nil
+}
+
+func Index(ctx *fasthttp.RequestCtx) {
+ ctx.Redirect("/login", 307)
+}
+
+func (v *AuthView) SetMyselfIn(r *ext.Router) {
+ r.GET("/login", v.LoginView)
+ r.POST("/login", v.Login)
+
+ r.GET("/register", v.RegisterView)
+ r.POST("/register", v.Register)
+
+ r.GET("/logout", v.Logout)
+ r.POST("/logout", v.Logout)
+}
diff --git a/pkg/view/filesystem.go b/pkg/view/filesystem.go
new file mode 100644
index 0000000..f10d788
--- /dev/null
+++ b/pkg/view/filesystem.go
@@ -0,0 +1,66 @@
+package view
+
+import (
+ "github.com/valyala/fasthttp"
+
+ "git.sr.ht/~gabrielgio/img"
+ "git.sr.ht/~gabrielgio/img/pkg/components/filesystem"
+ "git.sr.ht/~gabrielgio/img/pkg/components/settings"
+ "git.sr.ht/~gabrielgio/img/pkg/ext"
+)
+
+type (
+ FileSystemView struct {
+ controller filesystem.Controller
+ settings settings.Repository
+ }
+ FilePage struct {
+ Page *filesystem.Page
+ ShowMode bool
+ ShowOwner bool
+ }
+)
+
+func NewFileSystemView(
+ controller filesystem.Controller,
+ settingsRepository settings.Repository,
+) *FileSystemView {
+ return &FileSystemView{
+ controller: controller,
+ settings: settingsRepository,
+ }
+}
+
+func (self *FileSystemView) Index(ctx *fasthttp.RequestCtx) error {
+ pathValue := string(ctx.FormValue("path"))
+
+ page, err := self.controller.GetPage(pathValue)
+ if err != nil {
+ return err
+ }
+
+ settings, err := self.settings.Load(ctx)
+ if err != nil {
+ return err
+ }
+
+ err = img.Render(ctx, "fs.html", &img.HTMLView[*FilePage]{
+ Title: pathValue,
+ Data: &FilePage{
+ Page: page,
+ ShowMode: settings.ShowMode,
+ ShowOwner: settings.ShowOwner,
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (self *FileSystemView) SetMyselfIn(r *ext.Router) {
+ r.GET("/", self.Index)
+ r.POST("/", self.Index)
+ r.GET("/fs/", self.Index)
+ r.POST("/fs/", self.Index)
+}
diff --git a/pkg/view/media.go b/pkg/view/media.go
new file mode 100644
index 0000000..22f950d
--- /dev/null
+++ b/pkg/view/media.go
@@ -0,0 +1,101 @@
+package view
+
+import (
+ "strconv"
+
+ "github.com/valyala/fasthttp"
+
+ "git.sr.ht/~gabrielgio/img"
+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
+ "git.sr.ht/~gabrielgio/img/pkg/ext"
+)
+
+type (
+ MediaView struct {
+ mediaRepository media.Repository
+ }
+
+ Page struct {
+ Medias []*media.Media
+ Next *media.Pagination
+ }
+)
+
+func getPagination(ctx *fasthttp.RequestCtx) *media.Pagination {
+ var (
+ size int
+ page int
+ sizeStr = string(ctx.FormValue("size"))
+ pageStr = string(ctx.FormValue("page"))
+ )
+
+ if sizeStr == "" {
+ size = 100
+ } else if s, err := strconv.Atoi(sizeStr); err != nil {
+ size = 100
+ } else {
+ size = s
+ }
+
+ if pageStr == "" {
+ page = 0
+ } else if p, err := strconv.Atoi(pageStr); err != nil {
+ page = 0
+ } else {
+ page = p
+ }
+
+ return &media.Pagination{
+ Page: page,
+ Size: size,
+ }
+}
+
+func NewMediaView(mediaRepository media.Repository) *MediaView {
+ return &MediaView{
+ mediaRepository: mediaRepository,
+ }
+}
+
+func (self *MediaView) Index(ctx *fasthttp.RequestCtx) error {
+ p := getPagination(ctx)
+ medias, err := self.mediaRepository.List(ctx, p)
+ if err != nil {
+ return err
+ }
+
+ err = img.Render(ctx, "media.html", &img.HTMLView[*Page]{
+ Title: "Media",
+ Data: &Page{
+ Medias: medias,
+ Next: &media.Pagination{
+ Size: p.Size,
+ Page: p.Page + 1,
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (self *MediaView) GetImage(ctx *fasthttp.RequestCtx) error {
+ pathHash := string(ctx.FormValue("path_hash"))
+
+ media, err := self.mediaRepository.Get(ctx, pathHash)
+ if err != nil {
+ return err
+ }
+
+ ctx.Response.Header.SetContentType(media.MIMEType)
+ ctx.SendFile(media.Path)
+ return nil
+}
+
+func (self *MediaView) SetMyselfIn(r *ext.Router) {
+ r.GET("/media", self.Index)
+ r.POST("/media", self.Index)
+
+ r.GET("/media/image", self.GetImage)
+}
diff --git a/pkg/view/settings.go b/pkg/view/settings.go
new file mode 100644
index 0000000..746dee4
--- /dev/null
+++ b/pkg/view/settings.go
@@ -0,0 +1,53 @@
+package view
+
+import (
+ "github.com/valyala/fasthttp"
+
+ "git.sr.ht/~gabrielgio/img"
+ "git.sr.ht/~gabrielgio/img/pkg/components/settings"
+ "git.sr.ht/~gabrielgio/img/pkg/ext"
+)
+
+type SettingsView struct {
+ // there is not need to create a controller for this
+ repository settings.Repository
+}
+
+func NewSettingsView(respository settings.Repository) *SettingsView {
+ return &SettingsView{
+ repository: respository,
+ }
+}
+
+func (self *SettingsView) Index(ctx *fasthttp.RequestCtx) error {
+ s, err := self.repository.Load(ctx)
+ if err != nil {
+ return err
+ }
+ return img.Render(ctx, "settings.html", &img.HTMLView[*settings.Settings]{
+ Title: "Settings",
+ Data: s,
+ })
+}
+
+func (self *SettingsView) Save(ctx *fasthttp.RequestCtx) error {
+ var (
+ showMode = string(ctx.FormValue("showMode")) == "on"
+ showOwner = string(ctx.FormValue("showOwner")) == "on"
+ )
+
+ err := self.repository.Save(ctx, &settings.Settings{
+ ShowMode: showMode,
+ ShowOwner: showOwner,
+ })
+ if err != nil {
+ return err
+ }
+
+ return self.Index(ctx)
+}
+
+func (self *SettingsView) SetMyselfIn(r *ext.Router) {
+ r.GET("/settings/", self.Index)
+ r.POST("/settings/", self.Save)
+}
diff --git a/pkg/view/view.go b/pkg/view/view.go
new file mode 100644
index 0000000..663738b
--- /dev/null
+++ b/pkg/view/view.go
@@ -0,0 +1,7 @@
+package view
+
+import "git.sr.ht/~gabrielgio/img/pkg/ext"
+
+type View interface {
+ SetMyselfIn(r *ext.Router)
+}
diff --git a/pkg/worker/exif_scanner.go b/pkg/worker/exif_scanner.go
new file mode 100644
index 0000000..66091cd
--- /dev/null
+++ b/pkg/worker/exif_scanner.go
@@ -0,0 +1,43 @@
+package worker
+
+import (
+ "context"
+
+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
+ "git.sr.ht/~gabrielgio/img/pkg/fileop"
+)
+
+type (
+ EXIFScanner struct {
+ repository media.Repository
+ }
+)
+
+var _ ListProcessor[*media.Media] = &EXIFScanner{}
+
+func NewEXIFScanner(root string, repository media.Repository) *EXIFScanner {
+ return &EXIFScanner{
+ repository: repository,
+ }
+}
+
+func (e *EXIFScanner) Query(ctx context.Context) ([]*media.Media, error) {
+ medias, err := e.repository.GetEmptyEXIF(ctx, &media.Pagination{
+ Page: 0,
+ Size: 100,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return medias, nil
+}
+
+func (e *EXIFScanner) Process(ctx context.Context, m *media.Media) error {
+ newExif, err := fileop.ReadExif(m.Path)
+ if err != nil {
+ return err
+ }
+
+ return e.repository.CreateEXIF(ctx, m.ID, newExif)
+}
diff --git a/pkg/worker/file_scanner.go b/pkg/worker/file_scanner.go
new file mode 100644
index 0000000..321fbca
--- /dev/null
+++ b/pkg/worker/file_scanner.go
@@ -0,0 +1,81 @@
+package worker
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "io/fs"
+ "path/filepath"
+
+ "github.com/gabriel-vasile/mimetype"
+
+ "git.sr.ht/~gabrielgio/img/pkg/components/media"
+)
+
+type (
+ FileScanner struct {
+ root string
+ repository media.Repository
+ }
+)
+
+var _ ChanProcessor[string] = &FileScanner{}
+
+func NewFileScanner(root string, repository media.Repository) *FileScanner {
+ return &FileScanner{
+ root: root,
+ repository: repository,
+ }
+}
+
+func (f *FileScanner) Query(ctx context.Context) (<-chan string, error) {
+ c := make(chan string)
+ go func() {
+ defer close(c)
+ _ = filepath.Walk(f.root, func(path string, info fs.FileInfo, err error) error {
+ if info.IsDir() && filepath.Base(info.Name())[0] == '.' {
+ return filepath.SkipDir
+ }
+
+ if info.IsDir() {
+ return nil
+ }
+
+ if filepath.Ext(info.Name()) != ".jpg" &&
+ filepath.Ext(info.Name()) != ".jpeg" &&
+ filepath.Ext(info.Name()) != ".png" {
+ return nil
+ }
+ c <- path
+ return nil
+ })
+ }()
+ return c, nil
+}
+
+func (f *FileScanner) Process(ctx context.Context, path string) error {
+ hash := md5.Sum([]byte(path))
+ str := hex.EncodeToString(hash[:])
+ name := filepath.Base(path)
+
+ exists, errResp := f.repository.Exists(ctx, str)
+ if errResp != nil {
+ return errResp
+ }
+
+ if exists {
+ return nil
+ }
+
+ mime, errResp := mimetype.DetectFile(path)
+ if errResp != nil {
+ return errResp
+ }
+
+ return f.repository.Create(ctx, &media.CreateMedia{
+ Name: name,
+ Path: path,
+ PathHash: str,
+ MIMEType: mime.String(),
+ })
+}
diff --git a/pkg/worker/httpserver.go b/pkg/worker/httpserver.go
new file mode 100644
index 0000000..181cf73
--- /dev/null
+++ b/pkg/worker/httpserver.go
@@ -0,0 +1,31 @@
+package worker
+
+import (
+ "context"
+
+ "github.com/valyala/fasthttp"
+)
+
+type ServerWorker struct {
+ server *fasthttp.Server
+}
+
+func (self *ServerWorker) Start(ctx context.Context) error {
+ go func() {
+ // nolint: errcheck
+ self.server.ListenAndServe("0.0.0.0:8080")
+ }()
+
+ <-ctx.Done()
+ return self.Shutdown()
+}
+
+func (self *ServerWorker) Shutdown() error {
+ return self.server.Shutdown()
+}
+
+func NewServerWorker(server *fasthttp.Server) *ServerWorker {
+ return &ServerWorker{
+ server: server,
+ }
+}
diff --git a/pkg/worker/list_processor.go b/pkg/worker/list_processor.go
new file mode 100644
index 0000000..d53b7ea
--- /dev/null
+++ b/pkg/worker/list_processor.go
@@ -0,0 +1,102 @@
+package worker
+
+import (
+ "context"
+)
+
+type (
+
+ // A simple worker to deal with list.
+ ChanProcessor[T any] interface {
+ Query(context.Context) (<-chan T, error)
+ Process(context.Context, T) error
+ }
+
+ ListProcessor[T any] interface {
+ Query(context.Context) ([]T, error)
+ Process(context.Context, T) error
+ }
+
+ chanProcessorWorker[T any] struct {
+ chanProcessor ChanProcessor[T]
+ scheduler *Scheduler
+ }
+
+ listProcessorWorker[T any] struct {
+ listProcessor ListProcessor[T]
+ scheduler *Scheduler
+ }
+)
+
+func NewWorkerFromListProcessor[T any](
+ listProcessor ListProcessor[T],
+ scheduler *Scheduler,
+) Worker {
+ return &listProcessorWorker[T]{
+ listProcessor: listProcessor,
+ scheduler: scheduler,
+ }
+}
+
+func NewWorkerFromChanProcessor[T any](
+ listProcessor ChanProcessor[T],
+ scheduler *Scheduler,
+) Worker {
+ return &chanProcessorWorker[T]{
+ chanProcessor: listProcessor,
+ scheduler: scheduler,
+ }
+}
+
+func (l *listProcessorWorker[T]) Start(ctx context.Context) error {
+ for {
+ values, err := l.listProcessor.Query(ctx)
+ if err != nil {
+ return err
+ }
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ if len(values) == 0 {
+ return nil
+ }
+
+ for _, v := range values {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ if err := l.listProcessor.Process(ctx, v); err != nil {
+ return err
+ }
+ }
+ }
+}
+
+func (l *chanProcessorWorker[T]) Start(ctx context.Context) error {
+ c, err := l.chanProcessor.Query(ctx)
+ if err != nil {
+ return err
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case v, ok := <-c:
+ if !ok {
+ return nil
+ }
+
+ if err := l.chanProcessor.Process(ctx, v); err != nil {
+ return err
+ }
+ }
+ }
+}
diff --git a/pkg/worker/list_processor_test.go b/pkg/worker/list_processor_test.go
new file mode 100644
index 0000000..b7373d1
--- /dev/null
+++ b/pkg/worker/list_processor_test.go
@@ -0,0 +1,90 @@
+// go:build unit
+
+package worker
+
+import (
+ "context"
+ "errors"
+ "math/rand"
+ "sync"
+ "testing"
+
+ "git.sr.ht/~gabrielgio/img/pkg/testkit"
+)
+
+type (
+ mockCounterListProcessor struct {
+ done bool
+ countTo int
+ counter int
+ }
+
+ mockContextListProcessor struct {
+ }
+)
+
+func TestListProcessorLimit(t *testing.T) {
+ mock := &mockCounterListProcessor{
+ countTo: 10000,
+ }
+ worker := NewWorkerFromListProcessor[int](mock, nil)
+
+ err := worker.Start(context.Background())
+ testkit.TestFatalError(t, "Start", err)
+
+ testkit.TestValue(t, "Start", mock.countTo, mock.counter)
+}
+
+func TestListProcessorContextCancelQuery(t *testing.T) {
+ mock := &mockContextListProcessor{}
+ worker := NewWorkerFromListProcessor[int](mock, nil)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ var wg sync.WaitGroup
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ err := worker.Start(ctx)
+ if errors.Is(err, context.Canceled) {
+ return
+ }
+ testkit.TestFatalError(t, "Start", err)
+ }()
+
+ cancel()
+ // this rely on timeout to test
+ wg.Wait()
+}
+
+func (m *mockCounterListProcessor) Query(_ context.Context) ([]int, error) {
+ if m.done {
+ return make([]int, 0), nil
+ }
+ values := make([]int, 0, m.countTo)
+ for i := 0; i < m.countTo; i++ {
+ values = append(values, rand.Int())
+ }
+
+ m.done = true
+ return values, nil
+}
+
+func (m *mockCounterListProcessor) Process(_ context.Context, _ int) error {
+ m.counter++
+ return nil
+}
+
+func (m *mockContextListProcessor) Query(_ context.Context) ([]int, error) {
+ // keeps returning the query so it can run in infinity loop
+ values := make([]int, 0, 10)
+ for i := 0; i < 10; i++ {
+ values = append(values, rand.Int())
+ }
+ return values, nil
+}
+
+func (m *mockContextListProcessor) Process(_ context.Context, _ int) error {
+ // do nothing
+ return nil
+}
diff --git a/pkg/worker/scheduler.go b/pkg/worker/scheduler.go
new file mode 100644
index 0000000..b410b33
--- /dev/null
+++ b/pkg/worker/scheduler.go
@@ -0,0 +1,29 @@
+package worker
+
+import (
+ "fmt"
+ "sync/atomic"
+)
+
+type Scheduler struct {
+ pool chan any
+ count atomic.Int64
+}
+
+func NewScheduler(count uint) *Scheduler {
+ return &Scheduler{
+ pool: make(chan any, count),
+ }
+}
+
+func (self *Scheduler) Take() {
+ self.pool <- nil
+ self.count.Add(1)
+ fmt.Printf("<- %d\n", self.count.Load())
+}
+
+func (self *Scheduler) Return() {
+ <-self.pool
+ self.count.Add(-1)
+ fmt.Printf("-> %d\n", self.count.Load())
+}
diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go
new file mode 100644
index 0000000..c52f0be
--- /dev/null
+++ b/pkg/worker/worker.go
@@ -0,0 +1,54 @@
+package worker
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+)
+
+type (
+ // Worker should watch for context
+ Worker interface {
+ Start(context.Context) error
+ }
+
+ Work struct {
+ Name string
+ Worker Worker
+ }
+
+ WorkerPool struct {
+ workers []*Work
+ wg sync.WaitGroup
+ }
+)
+
+func NewWorkerPool() *WorkerPool {
+ return &WorkerPool{}
+}
+
+func (self *WorkerPool) AddWorker(name string, worker Worker) {
+ self.workers = append(self.workers, &Work{
+ Name: name,
+ Worker: worker,
+ })
+}
+
+func (self *WorkerPool) Start(ctx context.Context) {
+ for _, w := range self.workers {
+ self.wg.Add(1)
+ go func(w *Work) {
+ defer self.wg.Done()
+ if err := w.Worker.Start(ctx); err != nil && !errors.Is(err, context.Canceled) {
+ fmt.Println("Error ", w.Name, err.Error())
+ } else {
+ fmt.Println(w.Name, "done")
+ }
+ }(w)
+ }
+}
+
+func (self *WorkerPool) Wait() {
+ self.wg.Wait()
+}