diff options
Diffstat (limited to 'pkg')
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() +} |