diff options
| author | Bobby <[email protected]> | 2026-03-08 02:27:15 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-08 02:27:15 +0530 |
| commit | cca905d35412f1549400fc3d1aca6dc704d8cae6 (patch) | |
| tree | 0c0231f5c2ebaeb7700e08a2c1f07373d3251658 | |
| parent | 547384c41181c034a5eaf340c5e569d36eb013be (diff) | |
| download | dove-cca905d35412f1549400fc3d1aca6dc704d8cae6.tar.xz dove-cca905d35412f1549400fc3d1aca6dc704d8cae6.zip | |
feat(domains): add new TLD creation page and update sidebar
- Introduced a new HTMX template for creating TLDs.
- Created a new Django template for the new TLD page.
- Updated the sidebar to include a link to the domains section.
refactor(types): remove unused types and consolidate request handling
- Deleted unused type definitions related to authentication, errors, mailboxes, overview, requests, responses, and users.
- Introduced a new collections package for generic data structures.
- Refactored request handling to use a more streamlined approach with RequestInfo and Param types.
fix(meta): improve pagination and sorting functionality
- Updated pagination logic to handle default values and edge cases.
- Introduced a new Sorting type for better sorting management in queries.
chore(urls): refactor URL handling and registry
- Replaced enums with string constants for HTTP methods.
- Consolidated route registration logic and improved type safety with RegisteredRoute.
style(shortcuts): clean up error handling and rendering functions
- Enhanced error handling functions for better readability and maintainability.
- Removed deprecated functions and improved the structure of rendering logic.
95 files changed, 1460 insertions, 684 deletions
diff --git a/controllers/domain.go b/controllers/domain.go new file mode 100644 index 0000000..90a5727 --- /dev/null +++ b/controllers/domain.go @@ -0,0 +1,46 @@ +package controllers + +import ( + domainService "dove/services/domain" + "dove/utils/meta" + "dove/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +func CreateDomain(context *fiber.Ctx) error { + body, parseError := meta.Body[domainService.CreateDomainRequest](context) + if parseError != nil { + return shortcuts.BadRequest(context, parseError) + } + + serviceError := domainService.CreateDomain(body) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return shortcuts.Redirect(context, "domains.index") +} + +func CreateTLD(context *fiber.Ctx) error { + body, parseError := meta.Body[domainService.CreateTLDRequest](context) + if parseError != nil { + return shortcuts.BadRequest(context, parseError) + } + + serviceError := domainService.CreateTLD(body) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return shortcuts.Redirect(context, "domains.index") +} + +func DeleteTLD(context *fiber.Ctx) error { + serviceError := domainService.DeleteTLD(meta.Request(context).Param("name")) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return shortcuts.Redirect(context, "domains.index") +}
\ No newline at end of file diff --git a/database/constants.go b/database/constants.go deleted file mode 100644 index e16dcde..0000000 --- a/database/constants.go +++ /dev/null @@ -1,11 +0,0 @@ -package database - -import "time" - -const ( - DATABASE_FILE_NAME = "dove.db" - LOG_PREFIX = "Database" - MAX_IDLE_CONNECTIONS = 5 - MAX_OPEN_CONNECTIONS = 25 - MAX_CONNECTION_LIFETIME = time.Hour -) diff --git a/database/database.go b/database/database.go index ac06c98..888388c 100644 --- a/database/database.go +++ b/database/database.go @@ -1,7 +1,6 @@ package database import ( - "dove/messages" "dove/utils/logger" "gorm.io/driver/sqlite" @@ -20,19 +19,19 @@ func init() { }) if connectionError != nil { - logger.Fatalf(LOG_PREFIX, messages.DatabaseConnectionFailed, connectionError) + logger.Fatalf(LogPrefix, ConnectionFailed, connectionError) } sqlDB, poolError := DB.DB() if poolError != nil { - logger.Fatalf(LOG_PREFIX, messages.DatabasePoolConfigFailed, poolError) + logger.Fatalf(LogPrefix, PoolConfigFailed, poolError) } - sqlDB.SetMaxOpenConns(MAX_OPEN_CONNECTIONS) - sqlDB.SetMaxIdleConns(MAX_IDLE_CONNECTIONS) - sqlDB.SetConnMaxLifetime(MAX_CONNECTION_LIFETIME) + sqlDB.SetMaxOpenConns(MaxOpenConnections) + sqlDB.SetMaxIdleConns(MaxIdleConnections) + sqlDB.SetConnMaxLifetime(MaxConnectionLifetime) - logger.Successf(LOG_PREFIX, messages.DatabaseConnected, databaseDSN) + logger.Successf(LogPrefix, Connected, databaseDSN) migrate() } diff --git a/database/defaults.go b/database/defaults.go new file mode 100644 index 0000000..821509b --- /dev/null +++ b/database/defaults.go @@ -0,0 +1,11 @@ +package database + +import "time" + +const ( + FileName = "dove.db" + LogPrefix = "Database" + MaxConnectionLifetime = time.Hour + MaxIdleConnections = 5 + MaxOpenConnections = 25 +) diff --git a/database/functions.go b/database/functions.go index 71723ca..09d6ae4 100644 --- a/database/functions.go +++ b/database/functions.go @@ -9,7 +9,7 @@ import ( ) func resolveDatabasePath() string { - return filepath.Join(config.DataDir, DATABASE_FILE_NAME) + return filepath.Join(config.DataDir, FileName) } func resolveGORMLogLevel() logger.Interface { diff --git a/database/messages.go b/database/messages.go new file mode 100644 index 0000000..2c51076 --- /dev/null +++ b/database/messages.go @@ -0,0 +1,8 @@ +package database + +const ( + Connected = "Connected to %s." + ConnectionFailed = "Failed to connect to database: %v" + MigrationFailed = "Failed to run database migrations: %v" + PoolConfigFailed = "Failed to configure connection pool: %v" +) diff --git a/database/migration.go b/database/migration.go index 4d8363c..e18f2d6 100644 --- a/database/migration.go +++ b/database/migration.go @@ -1,13 +1,15 @@ package database import ( - "dove/messages" "dove/models" + "dove/models/domain" "dove/utils/logger" ) func migrate() { migrationError := DB.AutoMigrate( + &domain.TLD{}, + &domain.Domain{}, &models.User{}, &models.Mailbox{}, &models.Alias{}, @@ -17,6 +19,6 @@ func migrate() { ) if migrationError != nil { - logger.Fatalf(LOG_PREFIX, messages.DatabaseMigrationFailed, migrationError) + logger.Fatalf(LogPrefix, MigrationFailed, migrationError) } } diff --git a/enums/error.go b/enums/error.go deleted file mode 100644 index b3dd10a..0000000 --- a/enums/error.go +++ /dev/null @@ -1,12 +0,0 @@ -package enums - -type ErrorKind string - -const ( - BadRequest ErrorKind = "bad_request" - Forbidden ErrorKind = "forbidden" - Internal ErrorKind = "internal" - NotFound ErrorKind = "not_found" - Unauthorized ErrorKind = "unauthorized" - Unprocessable ErrorKind = "unprocessable" -) diff --git a/enums/http.go b/enums/http.go deleted file mode 100644 index fbd7591..0000000 --- a/enums/http.go +++ /dev/null @@ -1,13 +0,0 @@ -package enums - -type HTTPMethod string - -const ( - Delete HTTPMethod = "DELETE" - Get HTTPMethod = "GET" - Head HTTPMethod = "HEAD" - Options HTTPMethod = "OPTIONS" - Patch HTTPMethod = "PATCH" - Post HTTPMethod = "POST" - Put HTTPMethod = "PUT" -) diff --git a/enums/mailbox.go b/enums/mailbox.go deleted file mode 100644 index 24b2f4b..0000000 --- a/enums/mailbox.go +++ /dev/null @@ -1,8 +0,0 @@ -package enums - -type MailboxMode string - -const ( - Registered MailboxMode = "registered" - Catchall MailboxMode = "catchall" -) diff --git a/messages/auth.go b/messages/auth.go deleted file mode 100644 index 0cad598..0000000 --- a/messages/auth.go +++ /dev/null @@ -1,7 +0,0 @@ -package messages - -const ( - AuthAuthenticated = "Authenticated successfully." - AuthInvalidCredentials = "Invalid username or password." - AuthLoggedOut = "Logged out successfully." -) diff --git a/messages/config.go b/messages/config.go deleted file mode 100644 index 43b40a6..0000000 --- a/messages/config.go +++ /dev/null @@ -1,13 +0,0 @@ -package messages - -const ( - ConfigCreated = "Created default config at %s." - ConfigCreateFailed = "Failed to create config file: %v" - ConfigFileLoadFailed = "Failed to load config: %v" - ConfigFileReadFailed = "Failed to read config file %s: %s." - ConfigLoaded = "Configuration loaded successfully." - ConfigPortCollision = "Port collision: %s and %s both configured on %s." - ConfigPortValidFailed = "Port validation failed: %v" - ConfigSectionInvalid = "Config section '%s' has invalid data." - ConfigSectionParseFailed = "Failed to parse config section: %v" -) diff --git a/messages/database.go b/messages/database.go deleted file mode 100644 index 36ba275..0000000 --- a/messages/database.go +++ /dev/null @@ -1,8 +0,0 @@ -package messages - -const ( - DatabaseConnected = "Connected to %s." - DatabaseConnectionFailed = "Failed to connect to database: %v" - DatabaseMigrationFailed = "Failed to run database migrations: %v" - DatabasePoolConfigFailed = "Failed to configure connection pool: %v" -) diff --git a/messages/email.go b/messages/email.go deleted file mode 100644 index 28d6795..0000000 --- a/messages/email.go +++ /dev/null @@ -1,10 +0,0 @@ -package messages - -const ( - EmailDirectoryCreateFailed = "Failed to create email directory: %v" - EmailFileSaveFailed = "Failed to save email file: %v" - EmailIndexFailed = "Failed to index email: %v" - EmailParseFailed = "Failed to parse email: %v" - EmailProcessed = "Email processed for %d recipient(s)." - EmailStoreFailed = "Failed to store email: %v" -) diff --git a/messages/logger.go b/messages/logger.go deleted file mode 100644 index 7f6e99b..0000000 --- a/messages/logger.go +++ /dev/null @@ -1,5 +0,0 @@ -package messages - -const ( - LoggerNotInitialized = "Logger was not initialized." -) diff --git a/messages/mailbox.go b/messages/mailbox.go deleted file mode 100644 index 9ead4f0..0000000 --- a/messages/mailbox.go +++ /dev/null @@ -1,11 +0,0 @@ -package messages - -const ( - MailboxAutoCreated = "Auto-created mailbox for %s." - MailboxNotRegistered = "No registered mailbox or alias for address: %s" - MailboxAddressRequired = "Mailbox address is required." - MailboxUserRequired = "A user must be selected for the mailbox." - MailboxAlreadyExists = "A mailbox with this address already exists." - MailboxCreationFailed = "Failed to create mailbox." - MailboxUserNotFound = "The selected user does not exist." -) diff --git a/messages/meta.go b/messages/meta.go deleted file mode 100644 index d0349eb..0000000 --- a/messages/meta.go +++ /dev/null @@ -1,6 +0,0 @@ -package messages - -const ( - MetaRequestContextMissing = "Request context missing in fiber locals." - MetaRequiredValueMissing = "Required value not found." -) diff --git a/messages/server.go b/messages/server.go deleted file mode 100644 index 9f2b086..0000000 --- a/messages/server.go +++ /dev/null @@ -1,9 +0,0 @@ -package messages - -const ( - ServerListenFailed = "Failed to start server: %v" - ServerShutdownComplete = "Shutdown complete." - ServerShutdownFailed = "Error during server shutdown: %v" - ServerShuttingDown = "Shutting down gracefully..." - ServerStarting = "Server started on %s." -) diff --git a/messages/session.go b/messages/session.go deleted file mode 100644 index b35b85f..0000000 --- a/messages/session.go +++ /dev/null @@ -1,5 +0,0 @@ -package messages - -const ( - SessionInitialized = "Session store initialized." -) diff --git a/messages/shortcuts.go b/messages/shortcuts.go deleted file mode 100644 index b0c0d91..0000000 --- a/messages/shortcuts.go +++ /dev/null @@ -1,5 +0,0 @@ -package messages - -const ( - ShortcutUnsupportedBindType = "Bind data must be a struct, *struct, fiber.Map, or map[string]any." -)
\ No newline at end of file diff --git a/messages/smtp.go b/messages/smtp.go deleted file mode 100644 index eef071c..0000000 --- a/messages/smtp.go +++ /dev/null @@ -1,15 +0,0 @@ -package messages - -const ( - SMTPListenFailed = "Failed to start %s listener: %v" - SMTPSessionStarted = "New session from %s." - SMTPMailFrom = "Mail from: %s" - SMTPRecipient = "Recipient: %s" - SMTPMessageReceived = "Message received (%d bytes)." - SMTPMessageStoreFailed = "Failed to store message: %v" - SMTPServerStarting = "%s listener started on %s." - SMTPShutdownFailed = "Failed to shutdown %s listener: %v" - SMTPShutdownComplete = "All listeners stopped." - SMTPAuthFailed = "Authentication failed for user: %s" - SMTPInvalidCredentials = "Invalid credentials." -) diff --git a/messages/tags.go b/messages/tags.go deleted file mode 100644 index b54090a..0000000 --- a/messages/tags.go +++ /dev/null @@ -1,11 +0,0 @@ -package messages - -const ( - TagExpectedEquals = "Expected '=' after parameter key." - TagExpectedParamKey = "Expected parameter key identifier." - TagExpectedRouteName = "Expected route name string." - TagExpectedVariableName = "Expected variable name after 'as'." - TagRegistrationFailed = "Failed to register tag: %s." - TagRouteNotFound = "Route not found: %s." - TagTemplateWriteFailed = "Failed to write template output." -) diff --git a/messages/toml.go b/messages/toml.go deleted file mode 100644 index 9ecc069..0000000 --- a/messages/toml.go +++ /dev/null @@ -1,5 +0,0 @@ -package messages - -const ( - ParseTargetMustBeStructPointer = "Parse target must be a pointer to a struct." -) diff --git a/messages/user.go b/messages/user.go deleted file mode 100644 index 5c3fe93..0000000 --- a/messages/user.go +++ /dev/null @@ -1,8 +0,0 @@ -package messages - -const ( - UserUsernameRequired = "Username is required." - UserDisplayNameRequired = "Display name is required." - UserAlreadyExists = "A user with this username already exists." - UserCreationFailed = "Failed to create user." -) diff --git a/middleware/request.go b/middleware/request.go index 21f8357..da3aec1 100644 --- a/middleware/request.go +++ b/middleware/request.go @@ -7,6 +7,6 @@ import ( ) func requestBuilder(context *fiber.Ctx) error { - context.Locals(meta.REQUEST_KEY, meta.BuildRequest(context)) + context.Locals(meta.RequestKey, meta.BuildRequest(context)) return context.Next() } diff --git a/models/domain/domain.go b/models/domain/domain.go new file mode 100644 index 0000000..9a1a0ef --- /dev/null +++ b/models/domain/domain.go @@ -0,0 +1,10 @@ +package domain + +import "gorm.io/gorm" + +type Domain struct { + gorm.Model + Name string `gorm:"not null;uniqueIndex:idx_domain_tld" json:"name"` + TLDID uint `gorm:"not null;uniqueIndex:idx_domain_tld" json:"tld_id"` + TLD TLD `gorm:"foreignKey:TLDID" json:"tld"` +} diff --git a/models/domain/tld.go b/models/domain/tld.go new file mode 100644 index 0000000..5a47f32 --- /dev/null +++ b/models/domain/tld.go @@ -0,0 +1,10 @@ +package domain + +import "gorm.io/gorm" + +type TLD struct { + gorm.Model + Name string `gorm:"uniqueIndex;not null" json:"name"` + IsDefault bool `gorm:"default:false" json:"is_default"` + Domains []Domain `gorm:"foreignKey:TLDID" json:"domains"` +} diff --git a/models/mail/alias.go b/models/mail/alias.go new file mode 100644 index 0000000..c2cd752 --- /dev/null +++ b/models/mail/alias.go @@ -0,0 +1,10 @@ +package mail + +import "gorm.io/gorm" + +type Alias struct { + gorm.Model + SourceAddress string `gorm:"uniqueIndex;not null" json:"source_address"` + MailboxID uint `gorm:"not null" json:"mailbox_id"` + Mailbox Mailbox `gorm:"foreignKey:MailboxID" json:"mailbox"` +} diff --git a/models/mail/attachment.go b/models/mail/attachment.go new file mode 100644 index 0000000..fcff37e --- /dev/null +++ b/models/mail/attachment.go @@ -0,0 +1,14 @@ +package mail + +import "gorm.io/gorm" + +type Attachment struct { + gorm.Model + EmailID uint `gorm:"not null;index" json:"email_id"` + Email Email `gorm:"foreignKey:EmailID" json:"email"` + Filename string `gorm:"not null" json:"filename"` + ContentType string `gorm:"not null" json:"content_type"` + ContentID string `json:"content_id"` + Size int64 `json:"size"` + IsInline bool `gorm:"default:false" json:"is_inline"` +} diff --git a/models/mail/email.go b/models/mail/email.go new file mode 100644 index 0000000..4b8b55e --- /dev/null +++ b/models/mail/email.go @@ -0,0 +1,25 @@ +package mail + +import "gorm.io/gorm" + +type Email struct { + gorm.Model + MailboxID uint `gorm:"not null;index" json:"mailbox_id"` + Mailbox Mailbox `gorm:"foreignKey:MailboxID" json:"mailbox"` + MessageID string `gorm:"index" json:"message_id"` + Filename string `gorm:"uniqueIndex;not null" json:"filename"` + FromAddress string `gorm:"not null" json:"from_address"` + FromName string `json:"from_name"` + ToAddresses string `gorm:"not null" json:"to_addresses"` + CcAddresses string `json:"cc_addresses"` + BccAddresses string `json:"bcc_addresses"` + ReplyToAddress string `json:"reply_to_address"` + ReturnPath string `json:"return_path"` + Subject string `json:"subject"` + Snippet string `json:"snippet"` + Size int64 `json:"size"` + IsRead bool `gorm:"default:false;index" json:"is_read"` + AttachmentCount int `gorm:"default:0" json:"attachment_count"` + InlineCount int `gorm:"default:0" json:"inline_count"` + Tags []Tag `gorm:"many2many:email_tags" json:"tags"` +} diff --git a/models/mail/mailbox.go b/models/mail/mailbox.go new file mode 100644 index 0000000..62e3f1d --- /dev/null +++ b/models/mail/mailbox.go @@ -0,0 +1,12 @@ +package mail + +import "gorm.io/gorm" + +type Mailbox struct { + gorm.Model + Address string `gorm:"uniqueIndex;not null" json:"address"` + UserID uint `gorm:"not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user"` + Aliases []Alias `gorm:"foreignKey:MailboxID" json:"aliases"` + Emails []Email `gorm:"foreignKey:MailboxID" json:"emails"` +} diff --git a/models/mail/tag.go b/models/mail/tag.go new file mode 100644 index 0000000..65d2bd3 --- /dev/null +++ b/models/mail/tag.go @@ -0,0 +1,9 @@ +package mail + +import "gorm.io/gorm" + +type Tag struct { + gorm.Model + Name string `gorm:"uniqueIndex;not null" json:"name"` + Emails []Email `gorm:"many2many:email_tags" json:"emails"` +} diff --git a/models/mail/user.go b/models/mail/user.go new file mode 100644 index 0000000..68241f6 --- /dev/null +++ b/models/mail/user.go @@ -0,0 +1,10 @@ +package mail + +import "gorm.io/gorm" + +type User struct { + gorm.Model + Username string `gorm:"uniqueIndex;not null" json:"username"` + DisplayName string `json:"display_name"` + Mailboxes []Mailbox `gorm:"foreignKey:UserID" json:"mailboxes"` +} diff --git a/pages/domain.go b/pages/domain.go new file mode 100644 index 0000000..d46af9c --- /dev/null +++ b/pages/domain.go @@ -0,0 +1,24 @@ +package pages + +import ( + domainService "dove/services/domain" + "dove/utils/meta" + "dove/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +func Domains(context *fiber.Ctx) error { + meta.SetPageTitle(context, "Domains") + return shortcuts.Render(context, "domains/domains", domainService.ListDomains()) +} + +func NewDomain(context *fiber.Ctx) error { + meta.SetPageTitle(context, "New Domain") + return shortcuts.Render(context, "domains/newdomain", domainService.DomainFormData()) +} + +func NewTLD(context *fiber.Ctx) error { + meta.SetPageTitle(context, "New TLD") + return shortcuts.Render(context, "domains/newtld", nil) +}
\ No newline at end of file diff --git a/repositories/domain/domain.go b/repositories/domain/domain.go new file mode 100644 index 0000000..406e272 --- /dev/null +++ b/repositories/domain/domain.go @@ -0,0 +1,38 @@ +package domain + +import ( + "dove/database" + "dove/models/domain" + + "gorm.io/gorm" +) + +func AllDomains() []domain.Domain { + var domains []domain.Domain + database.DB.Preload("TLD").Order("name ASC").Find(&domains) + return domains +} + +func FindDomainByFullName(name string, tldName string) *domain.Domain { + var foundDomain domain.Domain + result := database.DB. + Joins("TLD"). + Where("domains.name = ? AND TLD.name = ?", name, tldName). + First(&foundDomain) + if result.Error == gorm.ErrRecordNotFound { + return nil + } + return &foundDomain +} + +func CreateDomain(newDomain *domain.Domain) error { + return database.DB.Create(newDomain).Error +} + +func UpdateDomain(updatedDomain *domain.Domain) error { + return database.DB.Save(updatedDomain).Error +} + +func DeleteDomain(targetDomain *domain.Domain) error { + return database.DB.Delete(targetDomain).Error +} diff --git a/repositories/domain/tld.go b/repositories/domain/tld.go new file mode 100644 index 0000000..c48e213 --- /dev/null +++ b/repositories/domain/tld.go @@ -0,0 +1,35 @@ +package domain + +import ( + "dove/database" + "dove/models/domain" + + "gorm.io/gorm" +) + +func AllTLDs() []domain.TLD { + var tlds []domain.TLD + database.DB.Order("name ASC").Find(&tlds) + return tlds +} + +func FindTLDByName(name string) *domain.TLD { + var tld domain.TLD + result := database.DB.Where("name = ?", name).First(&tld) + if result.Error == gorm.ErrRecordNotFound { + return nil + } + return &tld +} + +func CreateTLD(tld *domain.TLD) error { + return database.DB.Create(tld).Error +} + +func UpdateTLD(tld *domain.TLD) error { + return database.DB.Save(tld).Error +} + +func DeleteTLD(tld *domain.TLD) error { + return database.DB.Delete(tld).Error +} diff --git a/repositories/init/init.go b/repositories/init/init.go new file mode 100644 index 0000000..bdfe0fa --- /dev/null +++ b/repositories/init/init.go @@ -0,0 +1,7 @@ +package init + +import "dove/repositories/seed" + +func init() { + seed.Run() +} diff --git a/repositories/mail/alias.go b/repositories/mail/alias.go new file mode 100644 index 0000000..49a5396 --- /dev/null +++ b/repositories/mail/alias.go @@ -0,0 +1,18 @@ +package mail + +import ( + "dove/database" + "dove/models/mail" + + "gorm.io/gorm" +) + +func FindAliasByAddress(address string) *mail.Alias { + var alias mail.Alias + result := database.DB.Preload("Mailbox").Where("source_address = ?", address).First(&alias) + if result.Error == gorm.ErrRecordNotFound { + return nil + } + + return &alias +} diff --git a/repositories/mail/email.go b/repositories/mail/email.go new file mode 100644 index 0000000..3a80b92 --- /dev/null +++ b/repositories/mail/email.go @@ -0,0 +1,50 @@ +package mail + +import ( + "dove/database" + "dove/models/mail" + "dove/utils/meta" +) + +func CreateEmail(email *mail.Email) error { + return database.DB.Create(email).Error +} + +func CreateAttachment(attachment *mail.Attachment) error { + return database.DB.Create(attachment).Error +} + +func ListEmails(pagination meta.Pagination, sorting meta.Sorting, search string) ([]mail.Email, int64) { + var emails []mail.Email + var total int64 + + query := database.DB.Model(&mail.Email{}) + + if search != "" { + like := "%" + search + "%" + query = query.Where("from_address LIKE ? OR to_addresses LIKE ? OR subject LIKE ?", like, like, like) + } + + query.Count(&total) + pagination.Apply(sorting.Apply(query)).Preload("Tags").Find(&emails) + + return emails, total +} + +func CountEmails() int64 { + var count int64 + database.DB.Model(&mail.Email{}).Count(&count) + return count +} + +func ListEmailsByMailbox(mailboxID uint, pagination meta.Pagination, sorting meta.Sorting) ([]mail.Email, int64) { + var emails []mail.Email + var total int64 + + query := database.DB.Model(&mail.Email{}).Where("mailbox_id = ?", mailboxID) + + query.Count(&total) + pagination.Apply(sorting.Apply(query)).Preload("Tags").Find(&emails) + + return emails, total +} diff --git a/repositories/mail/mailbox.go b/repositories/mail/mailbox.go new file mode 100644 index 0000000..56025a6 --- /dev/null +++ b/repositories/mail/mailbox.go @@ -0,0 +1,46 @@ +package mail + +import ( + "dove/database" + "dove/models/mail" + "dove/utils/meta" + + "gorm.io/gorm" +) + +func FindMailboxByAddress(address string) *mail.Mailbox { + var mailbox mail.Mailbox + result := database.DB.Where("address = ?", address).First(&mailbox) + if result.Error == gorm.ErrRecordNotFound { + return nil + } + + return &mailbox +} + +func CreateMailbox(mailbox *mail.Mailbox) error { + return database.DB.Create(mailbox).Error +} + +func ListMailboxes(pagination meta.Pagination, sorting meta.Sorting, search string) ([]mail.Mailbox, int64) { + var mailboxes []mail.Mailbox + var total int64 + + query := database.DB.Model(&mail.Mailbox{}) + + if search != "" { + like := "%" + search + "%" + query = query.Where("address LIKE ?", like) + } + + query.Count(&total) + pagination.Apply(sorting.Apply(query)).Preload("User").Find(&mailboxes) + + return mailboxes, total +} + +func CountMailboxes() int64 { + var count int64 + database.DB.Model(&mail.Mailbox{}).Count(&count) + return count +} diff --git a/repositories/mail/user.go b/repositories/mail/user.go new file mode 100644 index 0000000..a045dc5 --- /dev/null +++ b/repositories/mail/user.go @@ -0,0 +1,62 @@ +package mail + +import ( + "dove/database" + "dove/models/mail" + "dove/utils/meta" + + "gorm.io/gorm" +) + +func FindUserByID(userID uint) *mail.User { + var user mail.User + result := database.DB.First(&user, userID) + if result.Error == gorm.ErrRecordNotFound { + return nil + } + + return &user +} + +func FindUserByUsername(username string) *mail.User { + var user mail.User + result := database.DB.Where("username = ?", username).First(&user) + if result.Error == gorm.ErrRecordNotFound { + return nil + } + + return &user +} + +func CreateUser(user *mail.User) error { + return database.DB.Create(user).Error +} + +func ListUsers(pagination meta.Pagination, sorting meta.Sorting, search string) ([]mail.User, int64) { + var users []mail.User + var total int64 + + query := database.DB.Model(&mail.User{}) + + if search != "" { + like := "%" + search + "%" + query = query.Where("username LIKE ? OR display_name LIKE ?", like, like) + } + + query.Count(&total) + pagination.Apply(sorting.Apply(query)).Preload("Mailboxes").Find(&users) + + return users, total +} + +func AllUsers() []mail.User { + var users []mail.User + database.DB.Order("username ASC").Find(&users) + return users +} + +func CountUsers() int64 { + var count int64 + database.DB.Model(&mail.User{}).Count(&count) + return count +} diff --git a/repositories/seed/defaults.go b/repositories/seed/defaults.go new file mode 100644 index 0000000..6055c9e --- /dev/null +++ b/repositories/seed/defaults.go @@ -0,0 +1,5 @@ +package seed + +import "dove/utils/collections" + +var DefaultTLDs = collections.SetOf("dove", "local", "nest", "test") diff --git a/repositories/seed/messages.go b/repositories/seed/messages.go new file mode 100644 index 0000000..d57e101 --- /dev/null +++ b/repositories/seed/messages.go @@ -0,0 +1,7 @@ +package seed + +const ( + LogPrefix = "Seed" + SeedComplete = "Seeded %d default TLDs." + SeedFailed = "Failed to seed TLD %s: %v" +) diff --git a/repositories/seed/seed.go b/repositories/seed/seed.go new file mode 100644 index 0000000..c180bb6 --- /dev/null +++ b/repositories/seed/seed.go @@ -0,0 +1,45 @@ +package seed + +import ( + "dove/database" + "dove/models/domain" + "dove/utils/logger" +) + +func Run() { + seededCount := 0 + + for _, tldName := range DefaultTLDs.All() { + if tldExists(tldName) { + continue + } + + if seedTLD(tldName) { + seededCount++ + } + } + + if seededCount > 0 { + logger.Successf(LogPrefix, SeedComplete, seededCount) + } +} + +func tldExists(name string) bool { + var count int64 + database.DB.Model(&domain.TLD{}).Where("name = ?", name).Count(&count) + return count > 0 +} + +func seedTLD(name string) bool { + tld := &domain.TLD{ + Name: name, + IsDefault: true, + } + + if createError := database.DB.Create(tld).Error; createError != nil { + logger.Errorf(LogPrefix, SeedFailed, name, createError) + return false + } + + return true +} diff --git a/router/auth.go b/router/auth.go index f8f41fc..a0261f1 100644 --- a/router/auth.go +++ b/router/auth.go @@ -2,13 +2,12 @@ package router import ( "dove/controllers" - "dove/enums" "dove/utils/urls" ) func init() { urls.SetNamespace("auth") - urls.Path(enums.Post, "/login", controllers.Login, "login") - urls.Path(enums.Get, "/logout", controllers.Logout, "logout") + urls.Path(urls.Post, "/login", controllers.Login, "login") + urls.Path(urls.Get, "/logout", controllers.Logout, "logout") } diff --git a/router/base.go b/router/base.go index 3a5e7ee..c70c685 100644 --- a/router/base.go +++ b/router/base.go @@ -1,7 +1,6 @@ package router import ( - "dove/enums" "dove/pages" "dove/utils/urls" ) @@ -9,5 +8,5 @@ import ( func init() { urls.SetNamespace("") - urls.Path(enums.Get, "/", pages.Home, "home") + urls.Path(urls.Get, "/", pages.Home, "home") } diff --git a/router/dashboard.go b/router/dashboard.go index ff7aa52..8baa4b8 100644 --- a/router/dashboard.go +++ b/router/dashboard.go @@ -2,7 +2,6 @@ package router import ( "dove/controllers" - "dove/enums" "dove/pages" "dove/utils/auth" "dove/utils/urls" @@ -11,12 +10,12 @@ import ( func init() { urls.SetNamespace("dashboard") - urls.Path(enums.Get, "/", auth.RequireAuthentication(pages.Dashboard), "index") - urls.Path(enums.Get, "/mailboxes", auth.RequireAuthentication(pages.Mailboxes), "mailboxes") - urls.Path(enums.Get, "/mailboxes/new", auth.RequireAuthentication(pages.NewMailbox), "mailboxes.new") - urls.Path(enums.Post, "/mailboxes", auth.RequireAuthentication(controllers.CreateMailbox), "mailboxes.create") - urls.Path(enums.Get, "/mailboxes/:address", auth.RequireAuthentication(pages.Mailbox), "mailbox") - urls.Path(enums.Get, "/users", auth.RequireAuthentication(pages.Users), "users") - urls.Path(enums.Get, "/users/new", auth.RequireAuthentication(pages.NewUser), "users.new") - urls.Path(enums.Post, "/users", auth.RequireAuthentication(controllers.CreateUser), "users.create") -}
\ No newline at end of file + urls.Path(urls.Get, "/", auth.RequireAuthentication(pages.Dashboard), "index") + urls.Path(urls.Get, "/mailboxes", auth.RequireAuthentication(pages.Mailboxes), "mailboxes") + urls.Path(urls.Get, "/mailboxes/new", auth.RequireAuthentication(pages.NewMailbox), "mailboxes.new") + urls.Path(urls.Post, "/mailboxes", auth.RequireAuthentication(controllers.CreateMailbox), "mailboxes.create") + urls.Path(urls.Get, "/mailboxes/:address", auth.RequireAuthentication(pages.Mailbox), "mailbox") + urls.Path(urls.Get, "/users", auth.RequireAuthentication(pages.Users), "users") + urls.Path(urls.Get, "/users/new", auth.RequireAuthentication(pages.NewUser), "users.new") + urls.Path(urls.Post, "/users", auth.RequireAuthentication(controllers.CreateUser), "users.create") +} diff --git a/router/domain.go b/router/domain.go new file mode 100644 index 0000000..00ecf36 --- /dev/null +++ b/router/domain.go @@ -0,0 +1,19 @@ +package router + +import ( + "dove/controllers" + "dove/pages" + "dove/utils/auth" + "dove/utils/urls" +) + +func init() { + urls.SetNamespace("domains") + + urls.Path(urls.Get, "/domains", auth.RequireAuthentication(pages.Domains), "index") + urls.Path(urls.Get, "/domains/new", auth.RequireAuthentication(pages.NewDomain), "new") + urls.Path(urls.Post, "/domains", auth.RequireAuthentication(controllers.CreateDomain), "create") + urls.Path(urls.Get, "/domains/tlds/new", auth.RequireAuthentication(pages.NewTLD), "tlds.new") + urls.Path(urls.Post, "/domains/tlds", auth.RequireAuthentication(controllers.CreateTLD), "tlds.create") + urls.Path(urls.Delete, "/domains/tlds/:name", auth.RequireAuthentication(controllers.DeleteTLD), "tlds.delete") +} diff --git a/services/auth/auth.go b/services/auth/auth.go new file mode 100644 index 0000000..89ef205 --- /dev/null +++ b/services/auth/auth.go @@ -0,0 +1,43 @@ +package auth + +import ( + "dove/config" + authUtils "dove/utils/auth" + "dove/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +type LoginRequest struct { + Username string `form:"username"` + Password string `form:"password"` +} + +type MessageResponse struct { + Message string +} + +func Authenticate(context *fiber.Ctx, request LoginRequest) (*MessageResponse, *shortcuts.Error) { + switch request.Username == config.Server.Username && request.Password == config.Server.Password { + case true: + if sessionError := authUtils.Authenticate(context); sessionError != nil { + return nil, shortcuts.ServiceError(shortcuts.Internal, sessionError.Error()) + } + + return &MessageResponse{ + Message: Authenticated, + }, nil + default: + return nil, shortcuts.ServiceError(shortcuts.Unauthorized, InvalidCredentials) + } +} + +func Deauthenticate(context *fiber.Ctx) (*MessageResponse, *shortcuts.Error) { + if sessionError := authUtils.Deauthenticate(context); sessionError != nil { + return nil, shortcuts.ServiceError(shortcuts.Internal, sessionError.Error()) + } + + return &MessageResponse{ + Message: LoggedOut, + }, nil +}
\ No newline at end of file diff --git a/services/auth/messages.go b/services/auth/messages.go new file mode 100644 index 0000000..f295dce --- /dev/null +++ b/services/auth/messages.go @@ -0,0 +1,7 @@ +package auth + +const ( + Authenticated = "Authenticated successfully." + InvalidCredentials = "Invalid username or password." + LoggedOut = "Logged out successfully." +) diff --git a/services/domain/domain.go b/services/domain/domain.go new file mode 100644 index 0000000..39d5894 --- /dev/null +++ b/services/domain/domain.go @@ -0,0 +1,68 @@ +package domain + +import ( + "strings" + + domainModel "dove/models/domain" + domainRepo "dove/repositories/domain" + "dove/utils/shortcuts" +) + +type CreateDomainRequest struct { + Name string `form:"name"` + TLDName string `form:"tld_name"` +} + +type DomainListResponse struct { + Domains []domainModel.Domain `json:"domains"` + TLDs []domainModel.TLD `json:"tlds"` +} + +type DomainFormResponse struct { + TLDs []domainModel.TLD `json:"tlds"` +} + +func ListDomains() DomainListResponse { + return DomainListResponse{ + Domains: domainRepo.AllDomains(), + TLDs: domainRepo.AllTLDs(), + } +} + +func DomainFormData() DomainFormResponse { + return DomainFormResponse{ + TLDs: domainRepo.AllTLDs(), + } +} + +func CreateDomain(request CreateDomainRequest) *shortcuts.Error { + name := strings.TrimSpace(strings.ToLower(request.Name)) + tldName := strings.TrimSpace(strings.ToLower(request.TLDName)) + + switch { + case name == "": + return shortcuts.ServiceError(shortcuts.BadRequest, DomainNameRequired) + case tldName == "": + return shortcuts.ServiceError(shortcuts.BadRequest, DomainTLDRequired) + } + + tld := domainRepo.FindTLDByName(tldName) + + switch { + case tld == nil: + return shortcuts.ServiceError(shortcuts.Unprocessable, TLDNotFound) + case domainRepo.FindDomainByFullName(name, tldName) != nil: + return shortcuts.ServiceError(shortcuts.Unprocessable, DomainAlreadyExists) + } + + newDomain := &domainModel.Domain{ + Name: name, + TLDID: tld.ID, + } + + if createError := domainRepo.CreateDomain(newDomain); createError != nil { + return shortcuts.ServiceError(shortcuts.Internal, DomainCreationFailed) + } + + return nil +} diff --git a/services/domain/messages.go b/services/domain/messages.go new file mode 100644 index 0000000..5e42a3a --- /dev/null +++ b/services/domain/messages.go @@ -0,0 +1,16 @@ +package domain + +const ( + DomainAlreadyExists = "A domain with this name already exists under this TLD." + DomainCreationFailed = "Failed to create domain." + DomainNameRequired = "Domain name is required." + DomainNotFound = "Domain not found." + DomainTLDRequired = "A TLD must be selected for the domain." + TLDAlreadyExists = "A TLD with this name already exists." + TLDCreationFailed = "Failed to create TLD." + TLDDeletionFailed = "Failed to delete TLD." + TLDNameRequired = "TLD name is required." + TLDNotFound = "TLD not found." + TLDProtected = "Default TLDs cannot be deleted." + TLDUpdateFailed = "Failed to update TLD." +) diff --git a/services/domain/tld.go b/services/domain/tld.go new file mode 100644 index 0000000..eb80c14 --- /dev/null +++ b/services/domain/tld.go @@ -0,0 +1,56 @@ +package domain + +import ( + "strings" + + domainModel "dove/models/domain" + domainRepo "dove/repositories/domain" + "dove/utils/shortcuts" +) + +type CreateTLDRequest struct { + Name string `form:"name"` +} + +func AllTLDs() []domainModel.TLD { + return domainRepo.AllTLDs() +} + +func CreateTLD(request CreateTLDRequest) *shortcuts.Error { + name := strings.TrimSpace(strings.ToLower(request.Name)) + + switch { + case name == "": + return shortcuts.ServiceError(shortcuts.BadRequest, TLDNameRequired) + case domainRepo.FindTLDByName(name) != nil: + return shortcuts.ServiceError(shortcuts.Unprocessable, TLDAlreadyExists) + } + + tld := &domainModel.TLD{ + Name: name, + IsDefault: false, + } + + if createError := domainRepo.CreateTLD(tld); createError != nil { + return shortcuts.ServiceError(shortcuts.Internal, TLDCreationFailed) + } + + return nil +} + +func DeleteTLD(name string) *shortcuts.Error { + tld := domainRepo.FindTLDByName(name) + + switch { + case tld == nil: + return shortcuts.ServiceError(shortcuts.NotFound, TLDNotFound) + case tld.IsDefault: + return shortcuts.ServiceError(shortcuts.Forbidden, TLDProtected) + } + + if deleteError := domainRepo.DeleteTLD(tld); deleteError != nil { + return shortcuts.ServiceError(shortcuts.Internal, TLDDeletionFailed) + } + + return nil +} diff --git a/services/mail/mailboxes.go b/services/mail/mailboxes.go new file mode 100644 index 0000000..b124dc9 --- /dev/null +++ b/services/mail/mailboxes.go @@ -0,0 +1,64 @@ +package mail + +import ( + "strings" + + "dove/models" + "dove/repositories" + "dove/utils/meta" + "dove/utils/shortcuts" +) + +type CreateMailboxRequest struct { + Address string `form:"address"` + UserID uint `form:"user_id"` +} + +type MailboxFormResponse struct { + Users []models.User `json:"users"` +} + +type MailboxView struct { + Address string +} + +func ListMailboxes(pagination meta.Pagination, sorting meta.Sorting, search string) meta.PaginatedResponse { + mailboxes, total := repositories.ListMailboxes(pagination, sorting, search) + return pagination.Response(mailboxes, total) +} + +func MailboxFormData() MailboxFormResponse { + return MailboxFormResponse{ + Users: repositories.AllUsers(), + } +} + +func CreateMailbox(request CreateMailboxRequest) *shortcuts.Error { + address := strings.TrimSpace(request.Address) + + switch { + case address == "": + return shortcuts.ServiceError(shortcuts.BadRequest, AddressRequired) + case request.UserID == 0: + return shortcuts.ServiceError(shortcuts.BadRequest, UserRequired) + } + + if repositories.FindUserByID(request.UserID) == nil { + return shortcuts.ServiceError(shortcuts.Unprocessable, UserNotFound) + } + + if repositories.FindMailboxByAddress(address) != nil { + return shortcuts.ServiceError(shortcuts.Unprocessable, AlreadyExists) + } + + mailbox := &models.Mailbox{ + Address: address, + UserID: request.UserID, + } + + if createError := repositories.CreateMailbox(mailbox); createError != nil { + return shortcuts.ServiceError(shortcuts.Internal, CreationFailed) + } + + return nil +} diff --git a/services/mail/messages.go b/services/mail/messages.go new file mode 100644 index 0000000..300f716 --- /dev/null +++ b/services/mail/messages.go @@ -0,0 +1,14 @@ +package mail + +const ( + AddressRequired = "Mailbox address is required." + AlreadyExists = "A mailbox with this address already exists." + CreationFailed = "Failed to create mailbox." + UserNotFound = "The selected user does not exist." + UserRequired = "A user must be selected for the mailbox." + + DisplayNameRequired = "Display name is required." + UserAlreadyExists = "A user with this username already exists." + UserCreationFailed = "Failed to create user." + UsernameRequired = "Username is required." +) diff --git a/services/mail/users.go b/services/mail/users.go new file mode 100644 index 0000000..9520219 --- /dev/null +++ b/services/mail/users.go @@ -0,0 +1,52 @@ +package mail + +import ( + "strings" + + "dove/models" + "dove/repositories" + "dove/utils/meta" + "dove/utils/shortcuts" +) + +type CreateUserRequest struct { + Username string `form:"username"` + DisplayName string `form:"display_name"` +} + +func ListUsers(pagination meta.Pagination, sorting meta.Sorting, search string) meta.PaginatedResponse { + users, total := repositories.ListUsers(pagination, sorting, search) + return pagination.Response(users, total) +} + +func CreateUser(request CreateUserRequest) *shortcuts.Error { + username := strings.TrimSpace(request.Username) + displayName := strings.TrimSpace(request.DisplayName) + + if username == "" { + return shortcuts.ServiceError(shortcuts.BadRequest, UsernameRequired) + } + + if displayName == "" { + return shortcuts.ServiceError(shortcuts.BadRequest, DisplayNameRequired) + } + + if repositories.FindUserByUsername(username) != nil { + return shortcuts.ServiceError(shortcuts.Unprocessable, UserAlreadyExists) + } + + newUser := &models.User{ + Username: username, + DisplayName: displayName, + } + + if createError := repositories.CreateUser(newUser); createError != nil { + return shortcuts.ServiceError(shortcuts.Internal, UserCreationFailed) + } + + return nil +} + +func AllUsers() []models.User { + return repositories.AllUsers() +} diff --git a/tags/types.go b/tags/types.go index 144e05a..8e44dbe 100644 --- a/tags/types.go +++ b/tags/types.go @@ -13,6 +13,6 @@ type templateTag struct { type urlNode struct { routeName string - params collections.Record[pongo2.IEvaluator] + params collections.Record[string, pongo2.IEvaluator] variableName string } diff --git a/tags/url.go b/tags/url.go index 0085679..10aa6be 100644 --- a/tags/url.go +++ b/tags/url.go @@ -18,7 +18,7 @@ func url(document *pongo2.Parser, start *pongo2.Token, arguments *pongo2.Parser) return nil, arguments.Error(messages.TagExpectedRouteName, nil) } - params := make(collections.Record[pongo2.IEvaluator]) + params := make(collections.Record[string, pongo2.IEvaluator]) var variableName string diff --git a/templates/domains/domains.django b/templates/domains/domains.django new file mode 100644 index 0000000..8152eb4 --- /dev/null +++ b/templates/domains/domains.django @@ -0,0 +1,5 @@ +{% extends "layouts/dashboard.django" %} + +{% block dashboard %} +{% include "domains/htmx/domains.htmx.django" %} +{% endblock %}
\ No newline at end of file diff --git a/templates/domains/htmx/domains.htmx.django b/templates/domains/htmx/domains.htmx.django new file mode 100644 index 0000000..972bbf5 --- /dev/null +++ b/templates/domains/htmx/domains.htmx.django @@ -0,0 +1,79 @@ +<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +<div class="slide-up space-y-6"> + <div class="glass rounded-xl glow-border"> + <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> + <h2 class="text-sm font-medium text-zinc-200">TLDs</h2> + <div class="flex items-center gap-3"> + <span class="text-xs text-zinc-600">{{ tlds|length }} total</span> + {% url "domains.tlds.new" as new_tld_path %} + <a href="{{ new_tld_path }}" hx-get="{{ new_tld_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="btn-small">New TLD</a> + </div> + </div> + {% if tlds %} + <div class="divide-y divide-white/[0.04]"> + {% for tld in tlds %} + <div class="flex items-center justify-between px-5 py-3"> + <div class="flex items-center gap-3"> + <div class="flex items-center justify-center w-8 h-8 rounded-lg bg-accent-500/10"> + <svg class="w-4 h-4 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /> + </svg> + </div> + <div> + <p class="text-sm text-zinc-200">.{{ tld.Name }}</p> + {% if tld.IsDefault %} + <p class="text-xs text-zinc-600">Default</p> + {% endif %} + </div> + </div> + {% if not tld.IsDefault %} + {% url "domains.tlds.delete" tld.Name as delete_tld_path %} + <button hx-delete="{{ delete_tld_path }}" hx-confirm="Delete .{{ tld.Name }}?" hx-target="#content" hx-swap="innerHTML" class="text-xs text-zinc-600 hover:text-red-400 transition-colors duration-150">Delete</button> + {% endif %} + </div> + {% endfor %} + </div> + {% else %} + <div class="flex flex-col items-center justify-center py-16 text-center"> + <p class="text-sm text-zinc-400">No TLDs configured</p> + </div> + {% endif %} + </div> + + <div class="glass rounded-xl glow-border"> + <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> + <h2 class="text-sm font-medium text-zinc-200">Domains</h2> + <div class="flex items-center gap-3"> + <span class="text-xs text-zinc-600">{{ domains|length }} total</span> + {% url "domains.new" as new_domain_path %} + <a href="{{ new_domain_path }}" hx-get="{{ new_domain_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="btn-small">New Domain</a> + </div> + </div> + {% if domains %} + <div class="divide-y divide-white/[0.04]"> + {% for domain in domains %} + <div class="flex items-center justify-between px-5 py-3"> + <div class="flex items-center gap-3"> + <div class="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-500/10"> + <svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z" /> + </svg> + </div> + <p class="text-sm text-zinc-200">{{ domain.Name }}.{{ domain.TLD.Name }}</p> + </div> + </div> + {% endfor %} + </div> + {% else %} + <div class="flex flex-col items-center justify-center py-16 text-center"> + <div class="flex items-center justify-center w-12 h-12 rounded-2xl bg-surface-800 mb-4"> + <svg class="w-6 h-6 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /> + </svg> + </div> + <p class="text-sm text-zinc-400">No domains registered</p> + <p class="mt-1 text-xs text-zinc-600">Register a domain to get started</p> + </div> + {% endif %} + </div> +</div>
\ No newline at end of file diff --git a/templates/domains/htmx/newdomain.htmx.django b/templates/domains/htmx/newdomain.htmx.django new file mode 100644 index 0000000..5f188c1 --- /dev/null +++ b/templates/domains/htmx/newdomain.htmx.django @@ -0,0 +1,42 @@ +<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +<div class="slide-up flex items-start justify-center pt-12"> + <div class="glass rounded-xl glow-border w-full max-w-lg"> + <div class="px-5 py-4 border-b border-white/[0.04]"> + <h2 class="text-sm font-medium text-zinc-200">Register Domain</h2> + </div> + <div class="p-5"> + {% url "domains.create" as create_path %} + <form method="POST" action="{{ create_path }}" class="space-y-4"> + <div> + <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Domain Name</label> + <input type="text" name="name" required autocomplete="off" placeholder="myproject" class="input-field"> + </div> + <div> + <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">TLD</label> + <div data-dropdown> + <input type="hidden" name="tld_name" data-dropdown-value> + <button type="button" data-dropdown-trigger class="input-field text-left flex items-center justify-between"> + <span data-dropdown-label class="text-zinc-500">Select a TLD</span> + <svg class="w-4 h-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /> + </svg> + </button> + <div data-dropdown-menu class="dropdown-menu"> + <input type="text" data-dropdown-search class="dropdown-search" placeholder="Search TLDs..."> + <div data-dropdown-options class="dropdown-options"> + {% for tld in tlds %} + <div data-dropdown-option data-value="{{ tld.Name }}" class="dropdown-option">.{{ tld.Name }}</div> + {% endfor %} + </div> + </div> + </div> + </div> + <div class="flex items-center gap-3 pt-2"> + <button type="submit" class="btn-primary">Register Domain</button> + {% url "domains.index" as domains_path %} + <a href="{{ domains_path }}" hx-get="{{ domains_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-500 hover:text-zinc-300 transition-colors duration-150">Cancel</a> + </div> + </form> + </div> + </div> +</div>
\ No newline at end of file diff --git a/templates/domains/htmx/newtld.htmx.django b/templates/domains/htmx/newtld.htmx.django new file mode 100644 index 0000000..39d09d2 --- /dev/null +++ b/templates/domains/htmx/newtld.htmx.django @@ -0,0 +1,25 @@ +<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> +<div class="slide-up flex items-start justify-center pt-12"> + <div class="glass rounded-xl glow-border w-full max-w-lg"> + <div class="px-5 py-4 border-b border-white/[0.04]"> + <h2 class="text-sm font-medium text-zinc-200">Create TLD</h2> + </div> + <div class="p-5"> + {% url "domains.tlds.create" as create_path %} + <form method="POST" action="{{ create_path }}" class="space-y-4"> + <div> + <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">TLD Name</label> + <div class="flex items-center gap-2"> + <span class="text-sm text-zinc-500">.</span> + <input type="text" name="name" required autocomplete="off" placeholder="example" class="input-field"> + </div> + </div> + <div class="flex items-center gap-3 pt-2"> + <button type="submit" class="btn-primary">Create TLD</button> + {% url "domains.index" as domains_path %} + <a href="{{ domains_path }}" hx-get="{{ domains_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-500 hover:text-zinc-300 transition-colors duration-150">Cancel</a> + </div> + </form> + </div> + </div> +</div>
\ No newline at end of file diff --git a/templates/domains/newdomain.django b/templates/domains/newdomain.django new file mode 100644 index 0000000..1a3a413 --- /dev/null +++ b/templates/domains/newdomain.django @@ -0,0 +1,5 @@ +{% extends "layouts/dashboard.django" %} + +{% block dashboard %} +{% include "domains/htmx/newdomain.htmx.django" %} +{% endblock %}
\ No newline at end of file diff --git a/templates/domains/newtld.django b/templates/domains/newtld.django new file mode 100644 index 0000000..7737192 --- /dev/null +++ b/templates/domains/newtld.django @@ -0,0 +1,5 @@ +{% extends "layouts/dashboard.django" %} + +{% block dashboard %} +{% include "domains/htmx/newtld.htmx.django" %} +{% endblock %}
\ No newline at end of file diff --git a/templates/partials/sidebar.django b/templates/partials/sidebar.django index c1e9623..b3d017e 100644 --- a/templates/partials/sidebar.django +++ b/templates/partials/sidebar.django @@ -10,6 +10,7 @@ <nav class="flex flex-col gap-1 p-3" id="sidebar-nav" hx-target="#content" hx-swap="innerHTML" hx-push-url="true"> {% url "dashboard.index" as overview_path %} + {% url "domains.index" as domains_path %} {% url "dashboard.mailboxes" as mailboxes_path %} {% url "dashboard.users" as users_path %} <a href="{{ overview_path }}" hx-get="{{ overview_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150 {% if Request.Path == overview_path %}active{% endif %}"> @@ -18,6 +19,12 @@ </svg> Overview </a> + <a href="{{ domains_path }}" hx-get="{{ domains_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150 {% if Request.Path == domains_path %}active{% endif %}"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /> + </svg> + Domains + </a> <a href="{{ mailboxes_path }}" hx-get="{{ mailboxes_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150 {% if Request.Path == mailboxes_path %}active{% endif %}"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h2.21a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" /> diff --git a/types/auth.go b/types/auth.go deleted file mode 100644 index 1792011..0000000 --- a/types/auth.go +++ /dev/null @@ -1,6 +0,0 @@ -package types - -type LoginRequest struct { - Username string `form:"username"` - Password string `form:"password"` -} diff --git a/types/errors.go b/types/errors.go deleted file mode 100644 index 1e5a562..0000000 --- a/types/errors.go +++ /dev/null @@ -1,12 +0,0 @@ -package types - -import "dove/enums" - -type ServiceError struct { - Kind enums.ErrorKind - Message string -} - -func (self *ServiceError) Error() string { - return self.Message -} diff --git a/types/mailbox.go b/types/mailbox.go deleted file mode 100644 index adf1ce5..0000000 --- a/types/mailbox.go +++ /dev/null @@ -1,16 +0,0 @@ -package types - -import "dove/models" - -type Mailbox struct { - Address string -} - -type CreateMailboxRequest struct { - Address string `form:"address"` - UserID uint `form:"user_id"` -} - -type MailboxFormResponse struct { - Users []models.User `json:"users"` -} diff --git a/types/overview.go b/types/overview.go deleted file mode 100644 index ac983a8..0000000 --- a/types/overview.go +++ /dev/null @@ -1,7 +0,0 @@ -package types - -type Overview struct { - MailboxCount int64 `json:"MailboxCount"` - EmailCount int64 `json:"EmailCount"` - SMTPAddress string `json:"SMTPAddress"` -} diff --git a/types/request.go b/types/request.go deleted file mode 100644 index 5d6a1a1..0000000 --- a/types/request.go +++ /dev/null @@ -1,17 +0,0 @@ -package types - -type Param struct { - Key string - Value string -} - -type Request struct { - Path string - Method string - Query []Param - Params []Param - Headers []Param - QueryString string - IP string - URL string -} diff --git a/types/response.go b/types/response.go deleted file mode 100644 index e576142..0000000 --- a/types/response.go +++ /dev/null @@ -1,13 +0,0 @@ -package types - -type MessageResponse struct { - Message string -} - -type PaginatedResponse struct { - Items any `json:"items"` - Total int64 `json:"total"` - Page int `json:"page"` - PerPage int `json:"per_page"` - TotalPages int `json:"total_pages"` -} diff --git a/types/user.go b/types/user.go deleted file mode 100644 index 6686567..0000000 --- a/types/user.go +++ /dev/null @@ -1,6 +0,0 @@ -package types - -type CreateUserRequest struct { - Username string `form:"username"` - DisplayName string `form:"display_name"` -} diff --git a/utils/collections/record.go b/utils/collections/record.go new file mode 100644 index 0000000..a204436 --- /dev/null +++ b/utils/collections/record.go @@ -0,0 +1,3 @@ +package collections + +type Record[K comparable, V any] map[K]V diff --git a/utils/collections/set.go b/utils/collections/set.go new file mode 100644 index 0000000..79e1ae2 --- /dev/null +++ b/utils/collections/set.go @@ -0,0 +1,38 @@ +package collections + +type Set[T comparable] struct { + items []T + index map[T]bool +} + +func SetOf[T comparable](items ...T) Set[T] { + index := make(map[T]bool, len(items)) + uniqueItems := make([]T, 0, len(items)) + + for _, item := range items { + if index[item] { + continue + } + index[item] = true + uniqueItems = append(uniqueItems, item) + } + + return Set[T]{ + items: uniqueItems, + index: index, + } +} + +func (set Set[T]) Contains(item T) bool { + return set.index[item] +} + +func (set Set[T]) All() []T { + copied := make([]T, len(set.items)) + copy(copied, set.items) + return copied +} + +func (set Set[T]) Len() int { + return len(set.items) +} diff --git a/utils/collections/types.go b/utils/collections/types.go deleted file mode 100644 index ac84e76..0000000 --- a/utils/collections/types.go +++ /dev/null @@ -1,3 +0,0 @@ -package collections - -type Record[T any] map[string]T diff --git a/utils/meta/builder.go b/utils/meta/builder.go index 4ae2648..3e4de93 100644 --- a/utils/meta/builder.go +++ b/utils/meta/builder.go @@ -1,13 +1,9 @@ package meta -import ( - "dove/types" +import "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2" -) - -func BuildRequest(context *fiber.Ctx) types.Request { - return types.Request{ +func BuildRequest(context *fiber.Ctx) RequestInfo { + return RequestInfo{ Path: context.Path(), Method: context.Method(), Query: buildQueryParams(context), @@ -18,3 +14,39 @@ func BuildRequest(context *fiber.Ctx) types.Request { URL: context.OriginalURL(), } } + +func buildQueryParams(context *fiber.Ctx) []Param { + params := make([]Param, 0) + context.Request().URI().QueryArgs().VisitAll(func(name []byte, paramValue []byte) { + params = append(params, Param{ + Key: string(name), + Value: string(paramValue), + }) + }) + + return params +} + +func buildRouteParams(context *fiber.Ctx) []Param { + params := make([]Param, 0) + for name, routeValue := range context.AllParams() { + params = append(params, Param{ + Key: name, + Value: routeValue, + }) + } + + return params +} + +func buildHeaders(context *fiber.Ctx) []Param { + params := make([]Param, 0) + context.Request().Header.VisitAll(func(name []byte, headerValue []byte) { + params = append(params, Param{ + Key: string(name), + Value: string(headerValue), + }) + }) + + return params +} diff --git a/utils/meta/constants.go b/utils/meta/constants.go deleted file mode 100644 index 13a2fde..0000000 --- a/utils/meta/constants.go +++ /dev/null @@ -1,9 +0,0 @@ -package meta - -const ( - DEFAULT_PAGE = 1 - DEFAULT_PER_PAGE = 20 - LOG_PREFIX = "Meta" - MAX_PER_PAGE = 50 - REQUEST_KEY = "Request" -) diff --git a/utils/meta/defaults.go b/utils/meta/defaults.go new file mode 100644 index 0000000..9ba6dfe --- /dev/null +++ b/utils/meta/defaults.go @@ -0,0 +1,9 @@ +package meta + +const ( + DefaultPage = 1 + DefaultPerPage = 20 + LogPrefix = "Meta" + MaxPerPage = 50 + RequestKey = "Request" +) diff --git a/utils/meta/functions.go b/utils/meta/functions.go deleted file mode 100644 index 0b8f309..0000000 --- a/utils/meta/functions.go +++ /dev/null @@ -1,53 +0,0 @@ -package meta - -import ( - "dove/types" - - "github.com/gofiber/fiber/v2" -) - -func findParam(params []types.Param, key string) (string, bool) { - for _, param := range params { - if param.Key == key { - return param.Value, true - } - } - - return "", false -} - -func buildQueryParams(context *fiber.Ctx) []types.Param { - params := make([]types.Param, 0) - context.Request().URI().QueryArgs().VisitAll(func(name []byte, paramValue []byte) { - params = append(params, types.Param{ - Key: string(name), - Value: string(paramValue), - }) - }) - - return params -} - -func buildRouteParams(context *fiber.Ctx) []types.Param { - params := make([]types.Param, 0) - for name, routeValue := range context.AllParams() { - params = append(params, types.Param{ - Key: name, - Value: routeValue, - }) - } - - return params -} - -func buildHeaders(context *fiber.Ctx) []types.Param { - params := make([]types.Param, 0) - context.Request().Header.VisitAll(func(name []byte, headerValue []byte) { - params = append(params, types.Param{ - Key: string(name), - Value: string(headerValue), - }) - }) - - return params -} diff --git a/utils/meta/messages.go b/utils/meta/messages.go new file mode 100644 index 0000000..2814d7c --- /dev/null +++ b/utils/meta/messages.go @@ -0,0 +1,5 @@ +package meta + +const ( + RequestContextMissing = "Request context missing in fiber locals." +) diff --git a/utils/meta/pagination.go b/utils/meta/pagination.go index 9244294..d0dda08 100644 --- a/utils/meta/pagination.go +++ b/utils/meta/pagination.go @@ -1,80 +1,59 @@ package meta import ( - "dove/types" "strconv" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) -func Paginate(context *fiber.Ctx) Pagination { - requestData := Request(context) - - page := DEFAULT_PAGE - perPage := DEFAULT_PER_PAGE - - if requestData != nil { - if pageValue := requestData.Query("page"); pageValue != nil { - parsed, _ := strconv.Atoi(pageValue.String()) - if parsed >= DEFAULT_PAGE { - page = parsed - } - } - - if perPageValue := requestData.Query("per_page"); perPageValue != nil { - parsed, _ := strconv.Atoi(perPageValue.String()) - if parsed >= DEFAULT_PAGE && parsed <= MAX_PER_PAGE { - perPage = parsed - } - } - } +type Pagination struct { + Page int + PerPage int +} - return Pagination{Page: page, PerPage: perPage} +type PaginatedResponse struct { + Items any `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` } -func Sort(context *fiber.Ctx, allowedFields []string, fallbackField string) Sorting { +func Paginate(context *fiber.Ctx) Pagination { requestData := Request(context) - field := fallbackField - direction := "desc" + page := DefaultPage + perPage := DefaultPerPage - if requestData != nil { - if sortValue := requestData.Query("sort"); sortValue != nil { - for _, allowedField := range allowedFields { - if sortValue.String() == allowedField { - field = sortValue.String() - break - } - } + if pageValue := requestData.Query("page"); pageValue != "" { + parsed, _ := strconv.Atoi(pageValue) + if parsed >= DefaultPage { + page = parsed } + } - if orderValue := requestData.Query("order"); orderValue != nil { - switch orderValue.String() { - case "asc", "desc": - direction = orderValue.String() - } + if perPageValue := requestData.Query("per_page"); perPageValue != "" { + parsed, _ := strconv.Atoi(perPageValue) + if parsed >= DefaultPage && parsed <= MaxPerPage { + perPage = parsed } } - return Sorting{Field: field, Direction: direction} + return Pagination{Page: page, PerPage: perPage} } func (self Pagination) Apply(query *gorm.DB) *gorm.DB { return query.Offset((self.Page - 1) * self.PerPage).Limit(self.PerPage) } -func (self Sorting) Apply(query *gorm.DB) *gorm.DB { - return query.Order(self.Field + " " + self.Direction) -} - -func (self Pagination) Response(items any, total int64) types.PaginatedResponse { +func (self Pagination) Response(items any, total int64) PaginatedResponse { totalPages := int(total) / self.PerPage if int(total)%self.PerPage > 0 { totalPages++ } - return types.PaginatedResponse{ + return PaginatedResponse{ Items: items, Total: total, Page: self.Page, diff --git a/utils/meta/request.go b/utils/meta/request.go index 34a659a..e9a3720 100644 --- a/utils/meta/request.go +++ b/utils/meta/request.go @@ -1,63 +1,75 @@ package meta import ( - "dove/messages" - "dove/types" "dove/utils/logger" "github.com/gofiber/fiber/v2" ) -func Request(context *fiber.Ctx) *request { - data, ok := context.Locals(REQUEST_KEY).(types.Request) +type Param struct { + Key string + Value string +} + +type RequestInfo struct { + Path string + Method string + Query []Param + Params []Param + Headers []Param + QueryString string + IP string + URL string +} + +type RequestData struct { + RequestInfo + Context *fiber.Ctx +} + +func Request(context *fiber.Ctx) *RequestData { + data, ok := context.Locals(RequestKey).(RequestInfo) if !ok { - logger.Errorf(LOG_PREFIX, messages.MetaRequestContextMissing) + logger.Errorf(LogPrefix, RequestContextMissing) return nil } - return &request{ - Request: data, - context: context, + return &RequestData{ + RequestInfo: data, + Context: context, } } -func (self *request) Param(key string) *value { - if self == nil { - return nil - } - - if self.context != nil { - result := self.context.Params(key) - if result != "" { - return &value{data: result} - } +func (self *RequestData) Param(key string) string { + if self == nil || self.Context == nil { + return "" } - return nil + return self.Context.Params(key) } -func (self *request) Query(key string) *value { +func (self *RequestData) Query(key string) string { if self == nil { - return nil - } - - result, found := findParam(self.Request.Query, key) - if found { - return &value{data: result} + return "" } - return nil + return findParam(self.RequestInfo.Query, key) } -func (self *request) Header(key string) *value { +func (self *RequestData) Header(key string) string { if self == nil { - return nil + return "" } - result, found := findParam(self.Request.Headers, key) - if found { - return &value{data: result} + return findParam(self.RequestInfo.Headers, key) +} + +func findParam(params []Param, key string) string { + for _, param := range params { + if param.Key == key { + return param.Value + } } - return nil + return "" } diff --git a/utils/meta/sorting.go b/utils/meta/sorting.go new file mode 100644 index 0000000..93788f0 --- /dev/null +++ b/utils/meta/sorting.go @@ -0,0 +1,39 @@ +package meta + +import ( + "slices" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type Sorting struct { + Field string + Direction string +} + +func Sort(context *fiber.Ctx, allowedFields []string, fallbackField string) Sorting { + requestData := Request(context) + + field := fallbackField + direction := "desc" + + if sortValue := requestData.Query("sort"); sortValue != "" { + if slices.Contains(allowedFields, sortValue) { + field = sortValue + } + } + + if orderValue := requestData.Query("order"); orderValue != "" { + switch orderValue { + case "asc", "desc": + direction = orderValue + } + } + + return Sorting{Field: field, Direction: direction} +} + +func (self Sorting) Apply(query *gorm.DB) *gorm.DB { + return query.Order(self.Field + " " + self.Direction) +} diff --git a/utils/meta/types.go b/utils/meta/types.go deleted file mode 100644 index c7b8e4d..0000000 --- a/utils/meta/types.go +++ /dev/null @@ -1,26 +0,0 @@ -package meta - -import ( - "dove/types" - - "github.com/gofiber/fiber/v2" -) - -type request struct { - types.Request - context *fiber.Ctx -} - -type value struct { - data string -} - -type Pagination struct { - Page int - PerPage int -} - -type Sorting struct { - Field string - Direction string -} diff --git a/utils/meta/value.go b/utils/meta/value.go deleted file mode 100644 index 889ba0e..0000000 --- a/utils/meta/value.go +++ /dev/null @@ -1,35 +0,0 @@ -package meta - -import ( - "dove/messages" - "dove/utils/logger" -) - -func (self *value) String() string { - if self == nil { - return "" - } - - return self.data -} - -func (self *value) Exists() bool { - return self != nil -} - -func (self *value) Or(fallback string) string { - if self == nil { - return fallback - } - - return self.data -} - -func (self *value) Required() string { - if self == nil { - logger.Errorf(LOG_PREFIX, messages.MetaRequiredValueMissing) - return "" - } - - return self.data -} diff --git a/utils/shortcuts/error.go b/utils/shortcuts/error.go index 71fa4bd..c22e6b2 100644 --- a/utils/shortcuts/error.go +++ b/utils/shortcuts/error.go @@ -1,29 +1,44 @@ package shortcuts -import ( - "dove/enums" - "dove/types" +import "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2" +type ErrorKind string + +const ( + BadRequest ErrorKind = "bad_request" + Forbidden ErrorKind = "forbidden" + Internal ErrorKind = "internal" + NotFound ErrorKind = "not_found" + Unauthorized ErrorKind = "unauthorized" + Unprocessable ErrorKind = "unprocessable" ) -var statusMap = map[enums.ErrorKind]int{ - enums.BadRequest: fiber.StatusBadRequest, - enums.Forbidden: fiber.StatusForbidden, - enums.Internal: fiber.StatusInternalServerError, - enums.NotFound: fiber.StatusNotFound, - enums.Unauthorized: fiber.StatusUnauthorized, - enums.Unprocessable: fiber.StatusUnprocessableEntity, +type Error struct { + Kind ErrorKind + Message string +} + +func (self *Error) Error() string { + return self.Message +} + +var statusMap = map[ErrorKind]int{ + BadRequest: fiber.StatusBadRequest, + Forbidden: fiber.StatusForbidden, + Internal: fiber.StatusInternalServerError, + NotFound: fiber.StatusNotFound, + Unauthorized: fiber.StatusUnauthorized, + Unprocessable: fiber.StatusUnprocessableEntity, } -func ServiceError(kind enums.ErrorKind, message string) *types.ServiceError { - return &types.ServiceError{ +func ServiceError(kind ErrorKind, message string) *Error { + return &Error{ Kind: kind, Message: message, } } -func HandleError(context *fiber.Ctx, serviceError *types.ServiceError) error { +func HandleError(context *fiber.Ctx, serviceError *Error) error { statusCode, exists := statusMap[serviceError.Kind] if !exists { statusCode = fiber.StatusInternalServerError @@ -34,13 +49,13 @@ func HandleError(context *fiber.Ctx, serviceError *types.ServiceError) error { }, statusCode) } -func BadRequest(context *fiber.Ctx, err error) error { +func BadRequestError(context *fiber.Ctx, err error) error { return RenderWithStatus(context, "error", fiber.Map{ "ErrorMessage": err.Error(), }, fiber.StatusBadRequest) } -func Forbidden(context *fiber.Ctx, err error) error { +func ForbiddenError(context *fiber.Ctx, err error) error { return RenderWithStatus(context, "error", fiber.Map{ "ErrorMessage": err.Error(), }, fiber.StatusForbidden) @@ -52,13 +67,13 @@ func InternalServerError(context *fiber.Ctx, err error) error { }, fiber.StatusInternalServerError) } -func NotFound(context *fiber.Ctx, err error) error { +func NotFoundError(context *fiber.Ctx, err error) error { return RenderWithStatus(context, "error", fiber.Map{ "ErrorMessage": err.Error(), }, fiber.StatusNotFound) } -func Unauthorized(context *fiber.Ctx, err error) error { +func UnauthorizedError(context *fiber.Ctx, err error) error { return RenderWithStatus(context, "error", fiber.Map{ "ErrorMessage": err.Error(), }, fiber.StatusUnauthorized) diff --git a/utils/shortcuts/functions.go b/utils/shortcuts/functions.go deleted file mode 100644 index 3e76a9a..0000000 --- a/utils/shortcuts/functions.go +++ /dev/null @@ -1,110 +0,0 @@ -package shortcuts - -import ( - "fmt" - "maps" - "path" - "reflect" - "strings" - - "dove/messages" - "dove/utils/errors" - - "github.com/gofiber/fiber/v2" -) - -func resolveTemplate(context *fiber.Ctx, templateName string) string { - switch { - case context.Get("HX-Request") == "true" && context.Get("HX-Boosted") != "true": - directory := path.Dir(templateName) - filename := path.Base(templateName) - return fmt.Sprintf("%s/htmx/%s.htmx", directory, filename) - default: - return templateName - } -} - -func mergeContextValues(context *fiber.Ctx, targetMap fiber.Map) { - context.Context().VisitUserValues(func(key []byte, value any) { - targetMap[string(key)] = value - }) -} - -func mergeBindData(targetMap fiber.Map, data any) error { - normalizedData, normalizeError := normalizeToMap(data) - if normalizeError != nil { - return normalizeError - } - - maps.Copy(targetMap, normalizedData) - return nil -} - -func normalizeToMap(data any) (fiber.Map, error) { - switch typedData := data.(type) { - case fiber.Map: - return typedData, nil - case map[string]any: - return fiber.Map(typedData), nil - default: - return convertStructToMap(data) - } -} - -func convertStructToMap(data any) (fiber.Map, error) { - structValue := reflect.ValueOf(data) - - switch structValue.Kind() { - case reflect.Pointer: - structValue = structValue.Elem() - } - - switch structValue.Kind() { - case reflect.Struct: - return extractStructFields(structValue), nil - default: - return nil, errors.Error(messages.ShortcutUnsupportedBindType) - } -} - -func extractStructFields(structValue reflect.Value) fiber.Map { - structType := structValue.Type() - fieldMap := make(fiber.Map, structValue.NumField()) - - for fieldIndex := range structType.NumField() { - fieldDescriptor := structType.Field(fieldIndex) - - if !fieldDescriptor.IsExported() { - continue - } - - fieldKey := resolveFieldKey(fieldDescriptor) - fieldMap[fieldKey] = structValue.Field(fieldIndex).Interface() - } - - return fieldMap -} - -func resolveFieldKey(fieldDescriptor reflect.StructField) string { - jsonTag := fieldDescriptor.Tag.Get("json") - - switch { - case jsonTag == "" || jsonTag == "-": - return fieldDescriptor.Name - default: - return extractTagName(jsonTag, fieldDescriptor.Name) - } -} - -func extractTagName(jsonTag string, fallbackName string) string { - separatorIndex := strings.IndexByte(jsonTag, ',') - - switch { - case separatorIndex < 0: - return jsonTag - case separatorIndex > 0: - return jsonTag[:separatorIndex] - default: - return fallbackName - } -}
\ No newline at end of file diff --git a/utils/shortcuts/messages.go b/utils/shortcuts/messages.go new file mode 100644 index 0000000..92df139 --- /dev/null +++ b/utils/shortcuts/messages.go @@ -0,0 +1,5 @@ +package shortcuts + +const ( + UnsupportedBindType = "Bind data must be a struct, *struct, fiber.Map, or map[string]any." +) diff --git a/utils/shortcuts/render.go b/utils/shortcuts/render.go index e91ccfa..89779cf 100644 --- a/utils/shortcuts/render.go +++ b/utils/shortcuts/render.go @@ -1,6 +1,16 @@ package shortcuts -import "github.com/gofiber/fiber/v2" +import ( + "fmt" + "maps" + "path" + "reflect" + "strings" + + "dove/utils/errors" + + "github.com/gofiber/fiber/v2" +) func Render(context *fiber.Ctx, templateName string, data any) error { templateData := make(fiber.Map) @@ -20,3 +30,99 @@ func RenderWithStatus(context *fiber.Ctx, templateName string, data any, statusC context.Status(statusCode) return Render(context, templateName, data) } + +func resolveTemplate(context *fiber.Ctx, templateName string) string { + switch { + case context.Get("HX-Request") == "true" && context.Get("HX-Boosted") != "true": + directory := path.Dir(templateName) + filename := path.Base(templateName) + return fmt.Sprintf("%s/htmx/%s.htmx", directory, filename) + default: + return templateName + } +} + +func mergeContextValues(context *fiber.Ctx, targetMap fiber.Map) { + context.Context().VisitUserValues(func(key []byte, value any) { + targetMap[string(key)] = value + }) +} + +func mergeBindData(targetMap fiber.Map, data any) error { + normalizedData, normalizeError := normalizeToMap(data) + if normalizeError != nil { + return normalizeError + } + + maps.Copy(targetMap, normalizedData) + return nil +} + +func normalizeToMap(data any) (fiber.Map, error) { + switch typedData := data.(type) { + case fiber.Map: + return typedData, nil + case map[string]any: + return fiber.Map(typedData), nil + default: + return convertStructToMap(data) + } +} + +func convertStructToMap(data any) (fiber.Map, error) { + structValue := reflect.ValueOf(data) + + switch structValue.Kind() { + case reflect.Pointer: + structValue = structValue.Elem() + } + + switch structValue.Kind() { + case reflect.Struct: + return extractStructFields(structValue), nil + default: + return nil, errors.Error(UnsupportedBindType) + } +} + +func extractStructFields(structValue reflect.Value) fiber.Map { + structType := structValue.Type() + fieldMap := make(fiber.Map, structValue.NumField()) + + for fieldIndex := range structType.NumField() { + fieldDescriptor := structType.Field(fieldIndex) + + if !fieldDescriptor.IsExported() { + continue + } + + fieldKey := resolveFieldKey(fieldDescriptor) + fieldMap[fieldKey] = structValue.Field(fieldIndex).Interface() + } + + return fieldMap +} + +func resolveFieldKey(fieldDescriptor reflect.StructField) string { + jsonTag := fieldDescriptor.Tag.Get("json") + + switch { + case jsonTag == "" || jsonTag == "-": + return fieldDescriptor.Name + default: + return extractTagName(jsonTag, fieldDescriptor.Name) + } +} + +func extractTagName(jsonTag string, fallbackName string) string { + separatorIndex := strings.IndexByte(jsonTag, ',') + + switch { + case separatorIndex < 0: + return jsonTag + case separatorIndex > 0: + return jsonTag[:separatorIndex] + default: + return fallbackName + } +} diff --git a/utils/toml/load.go b/utils/toml/load.go index 7b3c31a..71fd0c0 100644 --- a/utils/toml/load.go +++ b/utils/toml/load.go @@ -8,7 +8,7 @@ import ( "dove/utils/errors" ) -var loadedData collections.Record[any] +var loadedData collections.Record[string, any] func LoadFile(filePath string) error { fileContent, readError := os.ReadFile(filePath) @@ -16,6 +16,6 @@ func LoadFile(filePath string) error { return errors.Error(messages.ConfigFileReadFailed, filePath, readError.Error()) } - loadedData = make(collections.Record[any]) + loadedData = make(collections.Record[string, any]) return unmarshalContent(fileContent, &loadedData) } diff --git a/utils/urls/attach.go b/utils/urls/attach.go index df8a11a..baf9929 100644 --- a/utils/urls/attach.go +++ b/utils/urls/attach.go @@ -1,10 +1,6 @@ package urls -import ( - "dove/enums" - - "github.com/gofiber/fiber/v2" -) +import "github.com/gofiber/fiber/v2" func Attach(application *fiber.App) { registry.Mutex.Lock() @@ -15,21 +11,21 @@ func Attach(application *fiber.App) { } } -func bindRoute(application *fiber.App, route registeredRoute) { +func bindRoute(application *fiber.App, route RegisteredRoute) { switch route.Method { - case enums.Delete: + case Delete: application.Delete(route.FullPath, route.Handler) - case enums.Get: + case Get: application.Get(route.FullPath, route.Handler) - case enums.Head: + case Head: application.Head(route.FullPath, route.Handler) - case enums.Options: + case Options: application.Options(route.FullPath, route.Handler) - case enums.Patch: + case Patch: application.Patch(route.FullPath, route.Handler) - case enums.Post: + case Post: application.Post(route.FullPath, route.Handler) - case enums.Put: + case Put: application.Put(route.FullPath, route.Handler) } } diff --git a/utils/urls/functions.go b/utils/urls/functions.go deleted file mode 100644 index 6561025..0000000 --- a/utils/urls/functions.go +++ /dev/null @@ -1,30 +0,0 @@ -package urls - -import "strings" - -func resolveFullName(namespace string, name string) string { - switch namespace { - case "": - return name - default: - return namespace + "." + name - } -} - -func resolveFullPath(namespace string, path string) string { - switch namespace { - case "": - return ensureLeadingSlash(path) - default: - return "/" + namespace + ensureLeadingSlash(path) - } -} - -func ensureLeadingSlash(path string) string { - switch strings.HasPrefix(path, "/") { - case true: - return path - default: - return "/" + path - } -} diff --git a/utils/urls/path.go b/utils/urls/path.go index b865676..a3b3ec9 100644 --- a/utils/urls/path.go +++ b/utils/urls/path.go @@ -1,12 +1,24 @@ package urls import ( - "dove/enums" + "strings" "github.com/gofiber/fiber/v2" ) -func Path(method enums.HTTPMethod, path string, handler fiber.Handler, name string) { +type HTTPMethod string + +const ( + Delete HTTPMethod = "DELETE" + Get HTTPMethod = "GET" + Head HTTPMethod = "HEAD" + Options HTTPMethod = "OPTIONS" + Patch HTTPMethod = "PATCH" + Post HTTPMethod = "POST" + Put HTTPMethod = "PUT" +) + +func Path(method HTTPMethod, path string, handler fiber.Handler, name string) { registry.Mutex.Lock() defer registry.Mutex.Unlock() @@ -14,7 +26,7 @@ func Path(method enums.HTTPMethod, path string, handler fiber.Handler, name stri fullName := resolveFullName(namespace, name) fullPath := resolveFullPath(namespace, path) - registry.Routes[fullName] = registeredRoute{ + registry.Routes[fullName] = RegisteredRoute{ Method: method, Path: path, Handler: handler, @@ -35,3 +47,30 @@ func GetFullPath(routeName string) (string, bool) { return route.FullPath, true } + +func resolveFullName(namespace string, name string) string { + switch namespace { + case "": + return name + default: + return namespace + "." + name + } +} + +func resolveFullPath(namespace string, path string) string { + switch namespace { + case "": + return ensureLeadingSlash(path) + default: + return "/" + namespace + ensureLeadingSlash(path) + } +} + +func ensureLeadingSlash(path string) string { + switch strings.HasPrefix(path, "/") { + case true: + return path + default: + return "/" + path + } +} diff --git a/utils/urls/registry.go b/utils/urls/registry.go index a713315..e95d30c 100644 --- a/utils/urls/registry.go +++ b/utils/urls/registry.go @@ -1,9 +1,30 @@ package urls -import "dove/utils/collections" +import ( + "sync" -var registry = &routeRegistry{ - Routes: make(collections.Record[registeredRoute]), + "dove/utils/collections" + + "github.com/gofiber/fiber/v2" +) + +type RegisteredRoute struct { + Method HTTPMethod + Path string + Handler fiber.Handler + Namespace string + Name string + FullPath string +} + +type RouteRegistry struct { + Mutex sync.Mutex + CurrentNamespace string + Routes collections.Record[string, RegisteredRoute] +} + +var registry = &RouteRegistry{ + Routes: make(collections.Record[string, RegisteredRoute]), } func SetNamespace(namespace string) { diff --git a/utils/urls/types.go b/utils/urls/types.go deleted file mode 100644 index 499635b..0000000 --- a/utils/urls/types.go +++ /dev/null @@ -1,25 +0,0 @@ -package urls - -import ( - "sync" - - "dove/enums" - "dove/utils/collections" - - "github.com/gofiber/fiber/v2" -) - -type registeredRoute struct { - Method enums.HTTPMethod - Path string - Handler fiber.Handler - Namespace string - Name string - FullPath string -} - -type routeRegistry struct { - Mutex sync.Mutex - CurrentNamespace string - Routes collections.Record[registeredRoute] -} |
