diff options
| author | Bobby <[email protected]> | 2026-03-07 08:52:35 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-07 08:52:35 +0530 |
| commit | 82409d6b83de1baab69c166af8f04c6e9fe9069f (patch) | |
| tree | 678b3ee2242b20da49c8cf1ff0ec509d0c8ef1e1 | |
| parent | a97d1ad37463107b462958d92f596ebb80254b77 (diff) | |
| download | pagoda-82409d6b83de1baab69c166af8f04c6e9fe9069f.tar.xz pagoda-82409d6b83de1baab69c166af8f04c6e9fe9069f.zip | |
feat: Implement letter service with CRUD operations and message handling
- Added letter service to manage letters, including listing, creating, and editing letters and messages.
- Implemented functionality for sending and receiving messages within letters.
- Introduced pagination for letter listings and message retrieval.
- Added attachment upload capabilities for letters.
- Created detailed responses for letter and message retrieval.
feat: Introduce stats service for user statistics
- Added a service to retrieve user statistics, including newest and online citizens.
feat: Create ticket service for user support tickets
- Implemented ticket management service with functionalities to create, reply, and update tickets.
- Added support for ticket categories and their management.
feat: Add verification service for user account verification
- Implemented functionality to send verification emails for account activation.
feat: Develop warning service for user warnings
- Added service to issue warnings to users, deactivate warnings, and list user warnings.
feat: Create email templates for account status notifications
- Added HTML templates for account ban and disable notifications.
feat: Define request and response types for account, ticket, letter, and warning services
- Created structured request and response types for various services to ensure consistent data handling.
feat: Implement utility functions for authentication and sanitization
- Added functions for validating user hierarchy and sanitizing HTML input.
- Implemented token generation and hashing utilities for secure operations.
82 files changed, 3596 insertions, 653 deletions
diff --git a/shrine/config/env.go b/shrine/config/env.go index e711493..8057e7a 100644 --- a/shrine/config/env.go +++ b/shrine/config/env.go @@ -26,10 +26,12 @@ type smtp struct { } type storage struct { - Endpoint string `env:"MINIO_ENDPOINT" default:"localhost:9000"` - AccessKey string `env:"MINIO_ACCESS_KEY" default:""` - SecretKey string `env:"MINIO_SECRET_KEY" default:""` - Bucket string `env:"MINIO_BUCKET" default:"pagoda"` - UseSSL bool `env:"MINIO_USE_SSL" default:"false"` - CDN string `env:"CDN_URL" default:""` + Endpoint string `env:"MINIO_ENDPOINT" default:"localhost:9000"` + AccessKey string `env:"MINIO_ACCESS_KEY" default:""` + SecretKey string `env:"MINIO_SECRET_KEY" default:""` + Bucket string `env:"MINIO_BUCKET" default:"pagoda"` + UseSSL bool `env:"MINIO_USE_SSL" default:"false"` + CDN string `env:"CDN_URL" default:""` + MaxFileSize int64 `env:"MAX_FILE_SIZE" default:"33554432"` + MaxAttachments int `env:"MAX_ATTACHMENTS" default:"8"` } diff --git a/shrine/controllers/audit.go b/shrine/controllers/audit.go new file mode 100644 index 0000000..ecc23dc --- /dev/null +++ b/shrine/controllers/audit.go @@ -0,0 +1,29 @@ +package controllers + +import ( + "shrine/services" + "shrine/utils/meta" + "shrine/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +func ListAuditLogsController(context *fiber.Ctx) error { + pagination := meta.Paginate(context) + action, _ := meta.Request(context).Query("action") + targetType, _ := meta.Request(context).Query("target_type") + + items, total := services.ListAuditLogs(pagination, action, targetType) + return shortcuts.Success(context, pagination.Response(items, total)) +} + +func GetAuditLogController(context *fiber.Ctx) error { + ref := meta.Request(context).MustHave().Param("ref") + + result, serviceErr := services.GetAuditLog(ref) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +}
\ No newline at end of file diff --git a/shrine/controllers/auth.go b/shrine/controllers/auth.go index ff81ebe..8d27f3b 100644 --- a/shrine/controllers/auth.go +++ b/shrine/controllers/auth.go @@ -1,187 +1,95 @@ package controllers import ( - "errors" - "shrine/enums" - "strings" - "shrine/models" - "shrine/repositories" - "shrine/types" + "shrine/services" + "shrine/types/account" "shrine/utils/auth" - "shrine/utils/emails" - "shrine/utils/logger" "shrine/utils/meta" - "time" + "shrine/utils/shortcuts" "github.com/gofiber/fiber/v2" ) func RegisterController(context *fiber.Ctx) error { - body, err := meta.Body[types.RegisterRequest](context) + body, err := meta.Body[account.RegisterRequest](context) if err != nil { - return BadRequest(context, errors.New("Invalid request body.")) + return shortcuts.BadRequest(context, err) } - user := models.User{ - Username: body.Username, - Email: body.Email, - DisplayName: body.DisplayName, + result, serviceErr := services.Register(body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - if err := user.SetPassword(body.Password); err != nil { - return BadRequest(context, err) - } - - if err := repositories.CreateUser(&user); err != nil { - if strings.Contains(err.Error(), "users.username") { - return BadRequest(context, errors.New("An account with that username already exists.")) - } - if strings.Contains(err.Error(), "users.email") { - return BadRequest(context, errors.New("An account with that email address already exists.")) - } - return BadRequest(context, err) - } - - token, err := auth.GenerateToken() - if err != nil { - return InternalServerError(context, errors.New("Failed to generate verification token.")) - } - - user.SetVerification(auth.HashToken(token), time.Now().Add(24*time.Hour), enums.Activation) - - if err := repositories.UpdateUser(&user); err != nil { - return InternalServerError(context, errors.New("Failed to store verification token.")) - } - - if err := emails.SendActivation(user.Email, user.Username, token); err != nil { - logger.Errorf("Auth", "Failed to send verification email to %s: %v", user.Email, err) - } - - return Created(context, types.MessageResponse{ - Message: "Your account has been created. Please check your email to verify your account.", - }) + return shortcuts.Created(context, result) } func LoginController(context *fiber.Ctx) error { - body, err := meta.Body[types.LoginRequest](context) - if err != nil { - return BadRequest(context, errors.New("Invalid request body.")) - } - - user, err := repositories.FindUserByUsername(body.Username) + body, err := meta.Body[account.LoginRequest](context) if err != nil { - return Unauthorized(context, errors.New("Invalid username or password.")) + return shortcuts.BadRequest(context, err) } - if !user.CanAuthenticate() { - return Forbidden(context, errors.New("Your account has been banned or disabled.")) + citizen, serviceErr := services.Authenticate(body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - if !user.CheckPassword(body.Password) { - return Unauthorized(context, errors.New("Invalid username or password.")) - } - - if !user.IsVerified() { - return Forbidden(context, errors.New("Your email address has not been verified. Please check your inbox.")) - } - - token, err := auth.IssueToken(context, user.ID) + token, err := auth.IssueToken(context, citizen.ID) if err != nil { - return InternalServerError(context, errors.New("Failed to create session.")) + return shortcuts.InternalServerError(context, err) } - return Success(context, types.AuthResponse{ + return shortcuts.Success(context, account.AuthResponse{ Token: token, - User: user.ToResponse(), + User: citizen.ToResponse(), }) } func VerifyController(context *fiber.Ctx) error { - body, err := meta.Body[types.VerifyRequest](context) - if err != nil { - return BadRequest(context, errors.New("Invalid request body.")) - } - - if body.Token == "" { - return BadRequest(context, errors.New("Verification token is required.")) - } - - verificationType := enums.VerificationType(body.Type) - - tokenHash := auth.HashToken(body.Token) - - user, err := repositories.FindUserByVerification(tokenHash, verificationType) + body, err := meta.Body[account.VerifyRequest](context) if err != nil { - return BadRequest(context, errors.New("Your verification link is invalid or has expired.")) + return shortcuts.BadRequest(context, err) } - switch verificationType { - case enums.Activation: - user.VerifyEmail() - default: - return BadRequest(context, errors.New("Invalid verification type.")) + result, serviceErr := services.VerifyAccount(body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - if err := repositories.UpdateUser(user); err != nil { - return InternalServerError(context, errors.New("Failed to verify your account.")) - } - - return Success(context, types.MessageResponse{ - Message: "Your email has been verified successfully. You can now log in.", - }) + return shortcuts.Success(context, result) } func ResendActivationController(context *fiber.Ctx) error { - body, err := meta.Body[types.ResendActivationRequest](context) - if err != nil { - return BadRequest(context, errors.New("Invalid request body.")) - } - - user, err := repositories.FindUserByEmail(body.Email) + body, err := meta.Body[account.ResendActivationRequest](context) if err != nil { - return BadRequest(context, errors.New("No account exists with that email address.")) + return shortcuts.BadRequest(context, err) } - if user.IsVerified() { - return BadRequest(context, errors.New("This account has already been verified.")) + result, serviceErr := services.ResendActivation(body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - token, err := auth.GenerateToken() - if err != nil { - return InternalServerError(context, errors.New("Failed to generate verification token.")) - } - - user.SetVerification(auth.HashToken(token), time.Now().Add(24*time.Hour), enums.Activation) - - if err := repositories.UpdateUser(user); err != nil { - return InternalServerError(context, errors.New("Failed to store verification token.")) - } - - if err := emails.SendActivation(user.Email, user.Username, token); err != nil { - logger.Errorf("Auth", "Failed to send verification email to %s: %v", user.Email, err) - } - - return Success(context, types.MessageResponse{ - Message: "A new verification email has been sent. Please check your inbox.", - }) + return shortcuts.Success(context, result) } func LogoutController(context *fiber.Ctx) error { tokenHash := auth.GetTokenHash(context) - if err := repositories.DeleteToken(tokenHash); err != nil { - return InternalServerError(context, errors.New("Failed to end your session.")) + + result, serviceErr := services.RevokeToken(tokenHash) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - return Success(context, types.MessageResponse{ - Message: "You have been logged out successfully.", - }) + return shortcuts.Success(context, result) } func MeController(context *fiber.Ctx) error { - user := auth.GetUser(context) - return Success(context, user.ToResponse()) + citizen := auth.GetUser(context) + return shortcuts.Success(context, citizen.ToResponse()) } func HeartbeatController(context *fiber.Ctx) error { - return NoContent(context) + return shortcuts.NoContent(context) }
\ No newline at end of file diff --git a/shrine/controllers/council.go b/shrine/controllers/council.go index fd374e0..970ae73 100644 --- a/shrine/controllers/council.go +++ b/shrine/controllers/council.go @@ -1,189 +1,134 @@ package controllers import ( - "errors" - "shrine/enums" - "shrine/models" - "shrine/repositories" - "shrine/types" + "shrine/services" + "shrine/types/council" "shrine/utils/auth" "shrine/utils/meta" - "time" + "shrine/utils/shortcuts" "github.com/gofiber/fiber/v2" ) -func findTargetUser(context *fiber.Ctx) (*models.User, error) { - username := meta.Request(context).MustHave().Param("username") - return repositories.FindUserByUsername(username) -} - func ListUsersController(context *fiber.Ctx) error { - p := meta.Paginate(context) + pagination := meta.Paginate(context) search, _ := meta.Request(context).Query("search") - users, total := repositories.ListUsers(p, search) - - items := make([]types.AdminUserResponse, len(users)) - for i, u := range users { - items[i] = u.ToAdminResponse() - } - - return Success(context, p.Response(items, total)) + items, total := services.ListUsers(pagination, search) + return shortcuts.Success(context, pagination.Response(items, total)) } func GetUserController(context *fiber.Ctx) error { - user, err := findTargetUser(context) - if err != nil { - return NotFound(context, errors.New("User not found.")) + username := meta.Request(context).MustHave().Param("username") + + result, serviceErr := services.GetUser(username) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - return Success(context, user.ToAdminResponse()) + return shortcuts.Success(context, result) } func BanUserController(context *fiber.Ctx) error { admin := auth.GetUser(context) - - user, err := findTargetUser(context) - if err != nil { - return NotFound(context, errors.New("User not found.")) - } - - if user.ID == admin.ID { - return BadRequest(context, errors.New("You cannot ban yourself.")) - } - - if user.IsOwner() { - return BadRequest(context, errors.New("You cannot ban the owner.")) + target, serviceErr := services.ResolveUser(meta.Request(context).MustHave().Param("username")) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - if user.IsAdmin() && !admin.IsOwner() { - return BadRequest(context, errors.New("Only the owner can ban an administrator.")) - } - - body, _ := meta.Body[types.BanUserRequest](context) - - now := time.Now() - user.AccountBanned = true - user.BannedAt = &now - user.BannedReason = body.Reason - user.BannedBy = &admin.ID + body, _ := meta.Body[council.BanRequest](context) - if err := repositories.UpdateUser(user); err != nil { - return InternalServerError(context, errors.New("Failed to ban user.")) + result, serviceErr := services.BanUser(admin, target, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - return Success(context, user.ToAdminResponse()) + return shortcuts.Success(context, result) } func UnbanUserController(context *fiber.Ctx) error { - user, err := findTargetUser(context) - if err != nil { - return NotFound(context, errors.New("User not found.")) + admin := auth.GetUser(context) + target, serviceErr := services.ResolveUser(meta.Request(context).MustHave().Param("username")) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - user.AccountBanned = false - user.BannedAt = nil - user.BannedReason = "" - user.BannedBy = nil - - if err := repositories.UpdateUser(user); err != nil { - return InternalServerError(context, errors.New("Failed to unban user.")) + result, serviceErr := services.UnbanUser(admin, target) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - return Success(context, user.ToAdminResponse()) + return shortcuts.Success(context, result) } func DisableUserController(context *fiber.Ctx) error { admin := auth.GetUser(context) - - user, err := findTargetUser(context) - if err != nil { - return NotFound(context, errors.New("User not found.")) - } - - if user.ID == admin.ID { - return BadRequest(context, errors.New("You cannot disable yourself.")) + target, serviceErr := services.ResolveUser(meta.Request(context).MustHave().Param("username")) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - if user.IsOwner() { - return BadRequest(context, errors.New("You cannot disable the owner.")) - } - - if user.IsAdmin() && !admin.IsOwner() { - return BadRequest(context, errors.New("Only the owner can disable an administrator.")) - } - - body, _ := meta.Body[types.DisableUserRequest](context) - - now := time.Now() - user.AccountDisabled = true - user.DisabledAt = &now - user.DisabledReason = body.Reason - user.DisabledBy = &admin.ID + body, _ := meta.Body[council.DisableRequest](context) - if err := repositories.UpdateUser(user); err != nil { - return InternalServerError(context, errors.New("Failed to disable user.")) + result, serviceErr := services.DisableUser(admin, target, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - return Success(context, user.ToAdminResponse()) + return shortcuts.Success(context, result) } func EnableUserController(context *fiber.Ctx) error { - user, err := findTargetUser(context) - if err != nil { - return NotFound(context, errors.New("User not found.")) + admin := auth.GetUser(context) + target, serviceErr := services.ResolveUser(meta.Request(context).MustHave().Param("username")) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - user.AccountDisabled = false - user.DisabledAt = nil - user.DisabledReason = "" - user.DisabledBy = nil - - if err := repositories.UpdateUser(user); err != nil { - return InternalServerError(context, errors.New("Failed to enable user.")) + result, serviceErr := services.EnableUser(admin, target) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - return Success(context, user.ToAdminResponse()) + return shortcuts.Success(context, result) } func ChangeRoleController(context *fiber.Ctx) error { admin := auth.GetUser(context) + target, serviceErr := services.ResolveUser(meta.Request(context).MustHave().Param("username")) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } - user, err := findTargetUser(context) + body, err := meta.Body[council.ChangeRoleRequest](context) if err != nil { - return NotFound(context, errors.New("User not found.")) + return shortcuts.BadRequest(context, err) } - if user.ID == admin.ID { - return BadRequest(context, errors.New("You cannot change your own role.")) + result, serviceErr := services.ChangeRole(admin, target, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - if user.IsOwner() { - return BadRequest(context, errors.New("You cannot change the owner's role.")) - } + return shortcuts.Success(context, result) +} - body, err := meta.Body[types.ChangeRoleRequest](context) - if err != nil { - return BadRequest(context, errors.New("Invalid request body.")) +func EditUserController(context *fiber.Ctx) error { + admin := auth.GetUser(context) + target, serviceErr := services.ResolveUser(meta.Request(context).MustHave().Param("username")) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - role := enums.UserRole(body.Role) - switch role { - case enums.Member, enums.Moderator: - user.Role = role - case enums.Admin: - if !admin.IsOwner() { - return BadRequest(context, errors.New("Only the owner can assign the admin role.")) - } - user.Role = role - default: - return BadRequest(context, errors.New("Invalid role.")) + body, err := meta.Body[council.EditUserRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) } - if err := repositories.UpdateUser(user); err != nil { - return InternalServerError(context, errors.New("Failed to change role.")) + result, serviceErr := services.EditUser(admin, target, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) } - return Success(context, user.ToAdminResponse()) + return shortcuts.Success(context, result) }
\ No newline at end of file diff --git a/shrine/controllers/letter.go b/shrine/controllers/letter.go new file mode 100644 index 0000000..10696f1 --- /dev/null +++ b/shrine/controllers/letter.go @@ -0,0 +1,165 @@ +package controllers + +import ( + "shrine/messages" + "shrine/services" + "shrine/types/letter" + "shrine/utils/auth" + "shrine/utils/meta" + "shrine/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +func ListLettersController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + pagination := meta.Paginate(context) + + items, total := services.ListLetters(citizen.ID, pagination) + return shortcuts.Success(context, pagination.Response(items, total)) +} + +func GetLetterController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + pagination := meta.Paginate(context) + + result, serviceErr := services.GetLetter(ref, citizen.ID, pagination) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +} + +func CreateLetterController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + + body, err := meta.Body[letter.CreateRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.CreateLetter(citizen.ID, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Created(context, result) +} + +func SendMessageController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + + body, err := meta.Body[letter.SendMessageRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.SendLetterMessage(ref, citizen.ID, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Created(context, result) +} + +func EditMessageController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + messageRef := meta.Request(context).MustHave().Param("messageRef") + + body, err := meta.Body[letter.EditMessageRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.EditLetterMessage(ref, messageRef, citizen.ID, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +} + +func DeleteMessageController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + messageRef := meta.Request(context).MustHave().Param("messageRef") + + serviceErr := services.DeleteLetterMessage(ref, messageRef, citizen.ID) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.NoContent(context) +} + +func RenameLetterController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + + body, err := meta.Body[letter.RenameRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.RenameLetter(ref, citizen.ID, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +} + +func LeaveLetterController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + + result, serviceErr := services.LeaveLetter(ref, citizen.ID) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +} + +func RemoveParticipantController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + + body, err := meta.Body[letter.RemoveParticipantRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.RemoveLetterParticipant(ref, citizen.ID, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +} + +func UploadAttachmentController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + + file, err := context.FormFile("file") + if err != nil { + return shortcuts.BadRequest(context, fiber.NewError(fiber.StatusBadRequest, messages.FileRequired)) + } + + source, err := file.Open() + if err != nil { + return shortcuts.InternalServerError(context, err) + } + defer source.Close() + + result, serviceErr := services.UploadLetterAttachment(citizen.ID, file.Filename, file.Size, file.Header.Get("Content-Type"), source) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Created(context, result) +}
\ No newline at end of file diff --git a/shrine/controllers/responses.go b/shrine/controllers/responses.go deleted file mode 100644 index 0e9e515..0000000 --- a/shrine/controllers/responses.go +++ /dev/null @@ -1,56 +0,0 @@ -package controllers - -import ( - "shrine/types" - "shrine/utils/shortcuts" - - "github.com/gofiber/fiber/v2" -) - -func BadRequest(context *fiber.Ctx, err error) error { - return shortcuts.Response(context, types.ErrorResponse{ - Error: err.Error(), - }).As(fiber.StatusBadRequest) -} - -func Unauthorized(context *fiber.Ctx, err error) error { - return shortcuts.Response(context, types.ErrorResponse{ - Error: err.Error(), - }).As(fiber.StatusUnauthorized) -} - -func Forbidden(context *fiber.Ctx, err error) error { - return shortcuts.Response(context, types.ErrorResponse{ - Error: err.Error(), - }).As(fiber.StatusForbidden) -} - -func NotFound(context *fiber.Ctx, err error) error { - return shortcuts.Response(context, types.ErrorResponse{ - Error: err.Error(), - }).As(fiber.StatusNotFound) -} - -func InternalServerError(context *fiber.Ctx, err error) error { - return shortcuts.Response(context, types.ErrorResponse{ - Error: err.Error(), - }).As(fiber.StatusInternalServerError) -} - -func DefaultError(context *fiber.Ctx, err error) error { - return shortcuts.Response(context, types.ErrorResponse{ - Error: err.Error(), - }).As(fiber.StatusInternalServerError) -} - -func Success(context *fiber.Ctx, data any) error { - return shortcuts.Response(context, data).As(fiber.StatusOK) -} - -func Created(context *fiber.Ctx, data any) error { - return shortcuts.Response(context, data).As(fiber.StatusCreated) -} - -func NoContent(context *fiber.Ctx) error { - return shortcuts.Response(context, nil).As(fiber.StatusNoContent) -} diff --git a/shrine/controllers/stats.go b/shrine/controllers/stats.go index 33aa991..bf31c2e 100644 --- a/shrine/controllers/stats.go +++ b/shrine/controllers/stats.go @@ -1,30 +1,12 @@ package controllers import ( - "shrine/repositories" - "shrine/types" + "shrine/services" + "shrine/utils/shortcuts" "github.com/gofiber/fiber/v2" ) func StatsController(context *fiber.Ctx) error { - newest := repositories.NewestCitizens(5) - online := repositories.OnlineCitizens(10) - - newestSummaries := make([]types.CitizenSummary, len(newest)) - for i, u := range newest { - newestSummaries[i] = u.ToSummary() - } - - onlineSummaries := make([]types.CitizenSummary, len(online)) - for i, u := range online { - onlineSummaries[i] = u.ToSummary() - } - - return Success(context, types.StatsResponse{ - Citizens: repositories.CountCitizens(), - Online: repositories.CountOnline(), - NewestCitizens: newestSummaries, - OnlineCitizens: onlineSummaries, - }) + return shortcuts.Success(context, services.GetStats()) }
\ No newline at end of file diff --git a/shrine/controllers/ticket.go b/shrine/controllers/ticket.go new file mode 100644 index 0000000..f22fc84 --- /dev/null +++ b/shrine/controllers/ticket.go @@ -0,0 +1,163 @@ +package controllers + +import ( + "shrine/services" + "shrine/types/ticket" + "shrine/utils/auth" + "shrine/utils/meta" + "shrine/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +func ListUserTicketsController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + pagination := meta.Paginate(context) + + items, total := services.ListUserTickets(citizen.ID, pagination) + return shortcuts.Success(context, pagination.Response(items, total)) +} + +func GetUserTicketController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + + result, serviceErr := services.GetUserTicket(ref, citizen.ID) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +} + +func CreateTicketController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + + body, err := meta.Body[ticket.CreateRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.CreateTicket(citizen.ID, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Created(context, result) +} + +func ReplyTicketController(context *fiber.Ctx) error { + citizen := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + + body, err := meta.Body[ticket.SendMessageRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.ReplyTicket(ref, citizen.ID, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Created(context, result) +} + +func ListAllTicketsController(context *fiber.Ctx) error { + pagination := meta.Paginate(context) + status, _ := meta.Request(context).Query("status") + priority, _ := meta.Request(context).Query("priority") + + items, total := services.ListAllTickets(pagination, status, priority) + return shortcuts.Success(context, pagination.Response(items, total)) +} + +func GetStaffTicketController(context *fiber.Ctx) error { + ref := meta.Request(context).MustHave().Param("ref") + + result, serviceErr := services.GetStaffTicket(ref) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +} + +func UpdateTicketController(context *fiber.Ctx) error { + admin := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + + body, err := meta.Body[ticket.UpdateRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.UpdateTicket(admin.ID, ref, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +} + +func StaffReplyTicketController(context *fiber.Ctx) error { + staff := auth.GetUser(context) + ref := meta.Request(context).MustHave().Param("ref") + + body, err := meta.Body[ticket.SendMessageRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.StaffReplyTicket(ref, staff.ID, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Created(context, result) +} + +func ListTicketCategoriesController(context *fiber.Ctx) error { + return shortcuts.Success(context, services.ListTicketCategories()) +} + +func CreateTicketCategoryController(context *fiber.Ctx) error { + body, err := meta.Body[ticket.CreateCategoryRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.CreateTicketCategory(body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Created(context, result) +} + +func UpdateTicketCategoryController(context *fiber.Ctx) error { + ref := meta.Request(context).MustHave().Param("ref") + + body, err := meta.Body[ticket.UpdateCategoryRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.UpdateTicketCategory(ref, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +} + +func DeleteTicketCategoryController(context *fiber.Ctx) error { + ref := meta.Request(context).MustHave().Param("ref") + + serviceErr := services.DeleteTicketCategory(ref) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.NoContent(context) +}
\ No newline at end of file diff --git a/shrine/controllers/warning.go b/shrine/controllers/warning.go new file mode 100644 index 0000000..90ed526 --- /dev/null +++ b/shrine/controllers/warning.go @@ -0,0 +1,55 @@ +package controllers + +import ( + "shrine/services" + "shrine/types/warning" + "shrine/utils/auth" + "shrine/utils/meta" + "shrine/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +func WarnUserController(context *fiber.Ctx) error { + admin := auth.GetUser(context) + target, serviceErr := services.ResolveUser(meta.Request(context).MustHave().Param("username")) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + body, err := meta.Body[warning.WarnRequest](context) + if err != nil { + return shortcuts.BadRequest(context, err) + } + + result, serviceErr := services.WarnUser(admin, target, body) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Created(context, result) +} + +func DeactivateWarningController(context *fiber.Ctx) error { + admin := auth.GetUser(context) + ref := context.Params("ref") + + result, serviceErr := services.DeactivateWarning(admin, ref) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, result) +} + +func ListWarningsController(context *fiber.Ctx) error { + username := meta.Request(context).MustHave().Param("username") + pagination := meta.Paginate(context) + + items, total, serviceErr := services.ListWarnings(username, pagination) + if serviceErr != nil { + return shortcuts.HandleError(context, serviceErr) + } + + return shortcuts.Success(context, pagination.Response(items, total)) +}
\ No newline at end of file diff --git a/shrine/database/migrate.go b/shrine/database/migrate.go index 5835a76..ca76520 100644 --- a/shrine/database/migrate.go +++ b/shrine/database/migrate.go @@ -9,6 +9,15 @@ func migrate() { err := DB.AutoMigrate( &models.User{}, &models.Token{}, + &models.Letter{}, + &models.LetterParticipant{}, + &models.LetterMessage{}, + &models.LetterAttachment{}, + &models.Warning{}, + &models.AuditLog{}, + &models.TicketCategory{}, + &models.Ticket{}, + &models.TicketMessage{}, ) if err != nil { logger.Fatalf("Database", "Error during database migration: %v", err) diff --git a/shrine/enums/error.go b/shrine/enums/error.go new file mode 100644 index 0000000..dcab365 --- /dev/null +++ b/shrine/enums/error.go @@ -0,0 +1,14 @@ +package enums + +type ErrorKind string + +const ( + BadRequest ErrorKind = "bad_request" + Unauthorized ErrorKind = "unauthorized" + Forbidden ErrorKind = "forbidden" + NotFound ErrorKind = "not_found" + Conflict ErrorKind = "conflict" + Unprocessable ErrorKind = "unprocessable" + TooManyRequest ErrorKind = "too_many_requests" + Internal ErrorKind = "internal" +)
\ No newline at end of file diff --git a/shrine/enums/http.go b/shrine/enums/http.go new file mode 100644 index 0000000..c7a4ab0 --- /dev/null +++ b/shrine/enums/http.go @@ -0,0 +1,13 @@ +package enums + +type HTTPMethod string + +const ( + GET HTTPMethod = "GET" + POST HTTPMethod = "POST" + PUT HTTPMethod = "PUT" + PATCH HTTPMethod = "PATCH" + DELETE HTTPMethod = "DELETE" + OPTIONS HTTPMethod = "OPTIONS" + HEAD HTTPMethod = "HEAD" +)
\ No newline at end of file diff --git a/shrine/enums/ticket.go b/shrine/enums/ticket.go new file mode 100644 index 0000000..acf9ace --- /dev/null +++ b/shrine/enums/ticket.go @@ -0,0 +1,19 @@ +package enums + +type TicketPriority string + +const ( + PriorityLow TicketPriority = "low" + PriorityMedium TicketPriority = "medium" + PriorityHigh TicketPriority = "high" + PriorityUrgent TicketPriority = "urgent" +) + +type TicketStatus string + +const ( + StatusOpen TicketStatus = "open" + StatusInProgress TicketStatus = "in_progress" + StatusResolved TicketStatus = "resolved" + StatusClosed TicketStatus = "closed" +)
\ No newline at end of file diff --git a/shrine/go.mod b/shrine/go.mod index 28dcfd4..84e757b 100644 --- a/shrine/go.mod +++ b/shrine/go.mod @@ -16,9 +16,11 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -32,6 +34,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/philhofer/fwd v1.2.0 // indirect diff --git a/shrine/go.sum b/shrine/go.sum index 7da097c..61ab6c4 100644 --- a/shrine/go.sum +++ b/shrine/go.sum @@ -1,5 +1,7 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -13,6 +15,8 @@ github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/ github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -47,6 +51,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= diff --git a/shrine/messages/auth.go b/shrine/messages/auth.go new file mode 100644 index 0000000..0586fdc --- /dev/null +++ b/shrine/messages/auth.go @@ -0,0 +1,24 @@ +package messages + +const ( + InvalidRequestBody = "Invalid request body." + InvalidUsernameOrPassword = "Invalid username or password." + AccountBannedOrDisabled = "Your account has been banned or disabled." + EmailNotVerified = "Your email address has not been verified. Please check your inbox." + VerificationTokenRequired = "Verification token is required." + VerificationLinkInvalid = "Your verification link is invalid or has expired." + InvalidVerificationType = "Invalid verification type." + AccountAlreadyVerified = "This account has already been verified." + NoAccountWithEmail = "No account exists with that email address." + UsernameAlreadyExists = "An account with that username already exists." + EmailAlreadyExists = "An account with that email address already exists." + AccountCreated = "Your account has been created. Please check your email to verify your account." + EmailVerified = "Your email has been verified successfully. You can now log in." + VerificationEmailSent = "A new verification email has been sent. Please check your inbox." + LoggedOut = "You have been logged out successfully." + FailedGenerateToken = "Failed to generate verification token." + FailedStoreToken = "Failed to store verification token." + FailedCreateSession = "Failed to create session." + FailedVerifyAccount = "Failed to verify your account." + FailedEndSession = "Failed to end your session." +)
\ No newline at end of file diff --git a/shrine/messages/common.go b/shrine/messages/common.go new file mode 100644 index 0000000..3401505 --- /dev/null +++ b/shrine/messages/common.go @@ -0,0 +1,5 @@ +package messages + +const ( + AuditLogNotFound = "Audit log not found." +)
\ No newline at end of file diff --git a/shrine/messages/council.go b/shrine/messages/council.go new file mode 100644 index 0000000..e871782 --- /dev/null +++ b/shrine/messages/council.go @@ -0,0 +1,26 @@ +package messages + +const ( + UserNotFound = "User not found." + CannotBanSelf = "You cannot ban yourself." + CannotBanOwner = "You cannot ban the owner." + CannotBanAdmin = "Only the owner can ban an administrator." + CannotDisableSelf = "You cannot disable yourself." + CannotDisableOwner = "You cannot disable the owner." + CannotDisableAdmin = "Only the owner can disable an administrator." + CannotChangeOwnRole = "You cannot change your own role." + CannotChangeOwnerRole = "You cannot change the owner's role." + OnlyOwnerAssignAdmin = "Only the owner can assign the admin role." + InvalidRole = "Invalid role." + InvalidDisabledUntil = "Invalid disabled_until format. Use RFC3339." + NoChangesProvided = "No changes provided." + OnlyOwnerChangeEmail = "Only the owner can change email addresses." + InvalidUsername = "Username must be 3-32 characters and can only contain letters, numbers, and underscores." + UsernameNotAvailable = "This username is not available." + FailedBanUser = "Failed to ban user." + FailedUnbanUser = "Failed to unban user." + FailedDisableUser = "Failed to disable user." + FailedEnableUser = "Failed to enable user." + FailedChangeRole = "Failed to change role." + FailedUpdateUser = "Failed to update user." +)
\ No newline at end of file diff --git a/shrine/messages/letter.go b/shrine/messages/letter.go new file mode 100644 index 0000000..2492c63 --- /dev/null +++ b/shrine/messages/letter.go @@ -0,0 +1,32 @@ +package messages + +const ( + LetterNotFound = "Letter not found." + MessageNotFound = "Message not found." + MessageBodyRequired = "Message body is required." + RecipientsRequired = "At least one recipient is required." + RecipientsMax = "Maximum 20 recipients allowed." + ValidRecipientRequired = "At least one valid recipient is required." + SystemLetterNotReplyable = "System letters are not replyable. To raise a concern, open a ticket." + SystemLetterNotRenameable = "System letters cannot be renamed." + SystemLetterNotLeaveable = "You cannot leave a system letter." + CannotEditOthersMessage = "You can only edit your own messages." + CannotDeleteOthersMessage = "You can only delete your own messages." + CannotRemoveSelf = "You cannot remove yourself. Use leave instead." + OnlyOwnerCanRemove = "Only the conversation owner can remove participants." + UserNotInConversation = "User is not in this conversation." + TitleTooLong = "Title must be 200 characters or less." + FileRequired = "File is required." + FailedCreateLetter = "Failed to create letter." + FailedSendMessage = "Failed to send message." + FailedEditMessage = "Failed to edit message." + FailedDeleteMessage = "Failed to delete message." + FailedRenameLetter = "Failed to rename letter." + FailedLeaveLetter = "Failed to leave letter." + FailedRemoveParticipant = "Failed to remove participant." + FailedReadFile = "Failed to read file." + FailedSaveAttachment = "Failed to save attachment." + FailedUploadFile = "Failed to upload file." + LetterRenamed = "Letter renamed." + LeftConversation = "You have left the conversation." +)
\ No newline at end of file diff --git a/shrine/messages/ticket.go b/shrine/messages/ticket.go new file mode 100644 index 0000000..aa9021e --- /dev/null +++ b/shrine/messages/ticket.go @@ -0,0 +1,19 @@ +package messages + +const ( + TicketNotFound = "Ticket not found." + TicketClosed = "This ticket is closed." + InvalidCategory = "Invalid category." + InvalidPriority = "Invalid priority." + InvalidStatus = "Invalid status." + SubjectRequired = "Subject must be between 1 and 200 characters." + CategoryNameRequired = "Category name is required." + CategoryNotFound = "Category not found." + AssigneeNotFound = "Assignee not found." + FailedCreateTicket = "Failed to create ticket." + FailedSendReply = "Failed to send reply." + FailedUpdateTicket = "Failed to update ticket." + FailedCreateCategory = "Failed to create category." + FailedUpdateCategory = "Failed to update category." + FailedDeleteCategory = "Failed to delete category." +)
\ No newline at end of file diff --git a/shrine/messages/warning.go b/shrine/messages/warning.go new file mode 100644 index 0000000..d29a34b --- /dev/null +++ b/shrine/messages/warning.go @@ -0,0 +1,13 @@ +package messages + +const ( + CannotWarnSelf = "You cannot warn yourself." + CannotWarnOwner = "You cannot warn the owner." + CannotWarnAdmin = "Only the owner can warn an administrator." + WarningTitleRequired = "Warning title is required." + WarningMessageRequired = "Warning message is required." + WarningNotFound = "Warning not found." + WarningAlreadyInactive = "Warning is already inactive." + FailedCreateWarning = "Failed to create warning." + FailedDeactivateWarn = "Failed to deactivate warning." +)
\ No newline at end of file diff --git a/shrine/models/audit.go b/shrine/models/audit.go new file mode 100644 index 0000000..03ee299 --- /dev/null +++ b/shrine/models/audit.go @@ -0,0 +1,38 @@ +package models + +import ( + "shrine/types/audit" + "time" +) + +type AuditLog struct { + ID uint `gorm:"primaryKey;autoIncrement"` + SystemRef string `gorm:"size:20;uniqueIndex"` + ActorID uint `gorm:"index;not null"` + Actor User `gorm:"foreignKey:ActorID"` + Action string `gorm:"size:50;index;not null"` + TargetType string `gorm:"size:30"` + TargetRef string `gorm:"size:100"` + Summary string `gorm:"size:500"` + Details string `gorm:"type:text"` + CreatedAt time.Time `gorm:"index"` +} + +func (self *AuditLog) ToResponse() audit.AuditLogResponse { + return audit.AuditLogResponse{ + SystemRef: self.SystemRef, + Actor: self.Actor.Username, + Action: self.Action, + TargetType: self.TargetType, + TargetRef: self.TargetRef, + Summary: self.Summary, + CreatedAt: self.CreatedAt, + } +} + +func (self *AuditLog) ToDetailResponse() audit.DetailResponse { + return audit.DetailResponse{ + AuditLogResponse: self.ToResponse(), + Details: self.Details, + } +}
\ No newline at end of file diff --git a/shrine/models/letter.go b/shrine/models/letter.go new file mode 100644 index 0000000..f74de66 --- /dev/null +++ b/shrine/models/letter.go @@ -0,0 +1,125 @@ +package models + +import ( + "shrine/types/letter" + "shrine/utils/crypto" + "shrine/utils/storage" + "time" + + "gorm.io/gorm" +) + +type Letter struct { + gorm.Model + Ref string `gorm:"size:12;uniqueIndex;not null"` + Title string `gorm:"size:200"` + IsSystem bool `gorm:"not null;default:false"` + SystemRef string `gorm:"size:20;index"` + CreatorID *uint `gorm:"index"` + Creator *User `gorm:"foreignKey:CreatorID"` +} + +type LetterParticipant struct { + gorm.Model + LetterID uint `gorm:"uniqueIndex:idx_letter_user;not null"` + Letter Letter `gorm:"foreignKey:LetterID"` + UserID uint `gorm:"uniqueIndex:idx_letter_user;index;not null"` + User User `gorm:"foreignKey:UserID"` + Role string `gorm:"size:10;not null;default:member"` + LastReadAt *time.Time +} + +type LetterMessage struct { + gorm.Model + Ref string `gorm:"size:12;uniqueIndex;not null"` + LetterID uint `gorm:"index;not null"` + Letter Letter `gorm:"foreignKey:LetterID"` + SenderID *uint `gorm:"index"` + Sender *User `gorm:"foreignKey:SenderID"` + Body string `gorm:"type:text"` + Attachments []LetterAttachment `gorm:"foreignKey:MessageID"` + EditedAt *time.Time +} + +type LetterAttachment struct { + gorm.Model + Ref string `gorm:"size:12;uniqueIndex;not null"` + MessageID *uint `gorm:"index"` + UploaderID uint `gorm:"index;not null"` + FileName string `gorm:"size:255;not null"` + FilePath string `gorm:"size:512;not null"` + FileSize int64 `gorm:"not null"` + ContentType string `gorm:"size:100;not null"` +} + +func (self *Letter) BeforeCreate(tx *gorm.DB) error { + if self.Ref == "" { + self.Ref = crypto.Ref() + } + return nil +} + +func (self *LetterMessage) BeforeCreate(tx *gorm.DB) error { + if self.Ref == "" { + self.Ref = crypto.Ref() + } + return nil +} + +func (self *LetterAttachment) BeforeCreate(tx *gorm.DB) error { + if self.Ref == "" { + self.Ref = crypto.Ref() + } + return nil +} + +func (self *LetterParticipant) ToResponse() letter.ParticipantResponse { + return letter.ParticipantResponse{ + Username: self.User.Username, + DisplayName: self.User.DisplayName, + AvatarURL: storage.ResolveCDN(self.User.AvatarURL), + Role: self.Role, + } +} + +func (self *LetterAttachment) ToResponse() letter.AttachmentResponse { + return letter.AttachmentResponse{ + Ref: self.Ref, + FileName: self.FileName, + URL: storage.ResolveCDN(self.FilePath), + FileSize: self.FileSize, + ContentType: self.ContentType, + } +} + +func (self *LetterMessage) ToResponse() letter.MessageResponse { + response := letter.MessageResponse{ + Ref: self.Ref, + Body: self.Body, + EditedAt: self.EditedAt, + CreatedAt: self.CreatedAt, + Deleted: self.DeletedAt.Valid, + } + + if self.IsDeleted() { + response.Body = "" + response.Attachments = []letter.AttachmentResponse{} + } else { + attachments := make([]letter.AttachmentResponse, len(self.Attachments)) + for index, attach := range self.Attachments { + attachments[index] = attach.ToResponse() + } + response.Attachments = attachments + } + + if self.Sender != nil { + summary := self.Sender.ToSummary() + response.Sender = &summary + } + + return response +} + +func (self *LetterMessage) IsDeleted() bool { + return self.DeletedAt.Valid +}
\ No newline at end of file diff --git a/shrine/models/ticket.go b/shrine/models/ticket.go new file mode 100644 index 0000000..363a987 --- /dev/null +++ b/shrine/models/ticket.go @@ -0,0 +1,101 @@ +package models + +import ( + "shrine/types/ticket" + "shrine/utils/crypto" + + "gorm.io/gorm" +) + +type TicketCategory struct { + gorm.Model + Ref string `gorm:"size:12;uniqueIndex;not null"` + Name string `gorm:"size:100;not null;uniqueIndex"` + Description string `gorm:"size:500"` + SortOrder uint `gorm:"not null;default:0"` +} + +type Ticket struct { + gorm.Model + Ref string `gorm:"size:12;uniqueIndex;not null"` + UserID uint `gorm:"index;not null"` + User User `gorm:"foreignKey:UserID"` + CategoryID uint `gorm:"index;not null"` + Category TicketCategory `gorm:"foreignKey:CategoryID"` + AssigneeID *uint `gorm:"index"` + Assignee *User `gorm:"foreignKey:AssigneeID"` + Subject string `gorm:"size:200;not null"` + Priority string `gorm:"size:10;not null;default:low"` + Status string `gorm:"size:20;not null;default:open"` +} + +type TicketMessage struct { + gorm.Model + Ref string `gorm:"size:12;uniqueIndex;not null"` + TicketID uint `gorm:"index;not null"` + Ticket Ticket `gorm:"foreignKey:TicketID"` + SenderID uint `gorm:"not null"` + Sender User `gorm:"foreignKey:SenderID"` + Body string `gorm:"type:text;not null"` + IsStaff bool `gorm:"not null;default:false"` +} + +func (self *TicketCategory) BeforeCreate(tx *gorm.DB) error { + if self.Ref == "" { + self.Ref = crypto.Ref() + } + return nil +} + +func (self *Ticket) BeforeCreate(tx *gorm.DB) error { + if self.Ref == "" { + self.Ref = crypto.Ref() + } + return nil +} + +func (self *TicketMessage) BeforeCreate(tx *gorm.DB) error { + if self.Ref == "" { + self.Ref = crypto.Ref() + } + return nil +} + +func (self *TicketCategory) ToResponse() ticket.CategoryResponse { + return ticket.CategoryResponse{ + Ref: self.Ref, + Name: self.Name, + Description: self.Description, + SortOrder: self.SortOrder, + } +} + +func (self *Ticket) ToResponse() ticket.TicketResponse { + response := ticket.TicketResponse{ + Ref: self.Ref, + Subject: self.Subject, + Category: self.Category.ToResponse(), + Priority: self.Priority, + Status: self.Status, + User: self.User.ToSummary(), + CreatedAt: self.CreatedAt, + UpdatedAt: self.UpdatedAt, + } + + if self.Assignee != nil { + summary := self.Assignee.ToSummary() + response.Assignee = &summary + } + + return response +} + +func (self *TicketMessage) ToResponse() ticket.MessageResponse { + return ticket.MessageResponse{ + Ref: self.Ref, + Sender: self.Sender.ToSummary(), + Body: self.Body, + IsStaff: self.IsStaff, + CreatedAt: self.CreatedAt, + } +}
\ No newline at end of file diff --git a/shrine/models/token.go b/shrine/models/token.go index 2c25c4e..045f20d 100644 --- a/shrine/models/token.go +++ b/shrine/models/token.go @@ -16,10 +16,6 @@ type Token struct { UserAgent string `gorm:"size:512"` } -func (token *Token) IsExpired() bool { - return time.Now().After(token.ExpiresAt) -} - func (token *Token) BeforeCreate(tx *gorm.DB) error { if token.TokenHash == "" { return gorm.ErrInvalidData diff --git a/shrine/models/user.go b/shrine/models/user.go index 7f0d9d1..a4311e2 100644 --- a/shrine/models/user.go +++ b/shrine/models/user.go @@ -3,7 +3,7 @@ package models import ( "errors" "shrine/enums" - "shrine/types" + "shrine/types/user" "shrine/utils/storage" "shrine/utils/validators" "strings" @@ -18,33 +18,37 @@ type User struct { Username string `gorm:"uniqueIndex;size:32;not null"` Email string `gorm:"uniqueIndex;size:255;not null"` PasswordHash string `gorm:"size:255;not null"` - DisplayName string `gorm:"size:50;not null"` - Bio string `gorm:"size:500"` - Birthday *time.Time - AvatarURL string `gorm:"size:512;not null;default:defaults/avatar.png"` - BlinkieURL string `gorm:"size:512"` - Website string `gorm:"size:255"` - Location string `gorm:"size:100"` - Pronouns string `gorm:"size:50"` - Signature string `gorm:"size:500"` - Role enums.UserRole `gorm:"size:20;not null;default:member"` - EmailVerified bool `gorm:"not null;default:false"` - VerificationHash string `gorm:"size:64"` + DisplayName string `gorm:"size:50;not null"` + Bio string `gorm:"size:500"` + Birthday *time.Time + AvatarURL string `gorm:"size:512;not null;default:defaults/avatar.png"` + BlinkieURL string `gorm:"size:512"` + Website string `gorm:"size:255"` + Location string `gorm:"size:100"` + Pronouns string `gorm:"size:50"` + Signature string `gorm:"size:500"` + Jade uint64 `gorm:"not null;default:0"` + Honor uint64 `gorm:"not null;default:0"` + Role enums.UserRole `gorm:"size:20;not null;default:member"` + EmailVerified bool `gorm:"not null;default:false"` + VerificationHash string `gorm:"size:64"` VerificationExpiry *time.Time VerificationType enums.VerificationType `gorm:"size:20"` - AccountBanned bool `gorm:"not null;default:false"` + AccountBanned bool `gorm:"not null;default:false"` BannedAt *time.Time - BannedReason string `gorm:"size:500"` - BannedBy *uint `gorm:"index"` - AccountDisabled bool `gorm:"not null;default:false"` + BannedReason string `gorm:"size:500"` + BannedBy *uint `gorm:"index"` + AccountDisabled bool `gorm:"not null;default:false"` DisabledAt *time.Time - DisabledReason string `gorm:"size:500"` - DisabledBy *uint `gorm:"index"` + DisabledReason string `gorm:"size:500"` + DisabledBy *uint `gorm:"index"` + DisabledUntil *time.Time + WarningCount uint `gorm:"not null;default:0"` LastSeenAt *time.Time - RegistrationIP string `gorm:"size:45"` + RegistrationIP string `gorm:"size:45"` } -func (user *User) SetPassword(password string) error { +func (self *User) SetPassword(password string) error { if len(password) < 8 { return errors.New("Password must be at least 8 characters.") } @@ -55,146 +59,151 @@ func (user *User) SetPassword(password string) error { if err != nil { return err } - user.PasswordHash = string(hash) + self.PasswordHash = string(hash) return nil } -func (user *User) CheckPassword(password string) bool { - return bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil +func (self *User) CheckPassword(password string) bool { + return bcrypt.CompareHashAndPassword([]byte(self.PasswordHash), []byte(password)) == nil } -func (user *User) IsOwner() bool { - return user.Role == enums.Owner +func (self *User) IsOwner() bool { + return self.Role == enums.Owner } -func (user *User) IsAdmin() bool { - return user.Role == enums.Admin || user.IsOwner() +func (self *User) IsAdmin() bool { + return self.Role == enums.Admin || self.IsOwner() } -func (user *User) IsModerator() bool { - return user.Role == enums.Moderator +func (self *User) IsModerator() bool { + return self.Role == enums.Moderator } -func (user *User) IsStaff() bool { - return user.IsAdmin() || user.IsModerator() +func (self *User) IsStaff() bool { + return self.IsAdmin() || self.IsModerator() } -func (user *User) IsBanned() bool { - return user.AccountBanned +func (self *User) CanAuthenticate() bool { + return !self.AccountBanned && !self.AccountDisabled } -func (user *User) IsDisabled() bool { - return user.AccountDisabled -} - -func (user *User) CanAuthenticate() bool { - return !user.AccountBanned && !user.AccountDisabled -} - -func (user *User) IsVerified() bool { - return user.EmailVerified -} - -func (user *User) SetVerification(hash string, expiry time.Time, verificationType enums.VerificationType) { - user.VerificationHash = hash - user.VerificationExpiry = &expiry - user.VerificationType = verificationType -} - -func (user *User) VerifyEmail() { - user.EmailVerified = true - user.VerificationHash = "" - user.VerificationExpiry = nil - user.VerificationType = "" -} +func (self *User) ClearExpiredDisable() bool { + if !self.AccountDisabled || self.DisabledUntil == nil || !self.DisabledUntil.Before(time.Now()) { + return false + } -func (user *User) ToResponse() types.UserResponse { - return types.UserResponse{ - Username: user.Username, - Email: user.Email, - DisplayName: user.DisplayName, - Bio: user.Bio, - Birthday: user.Birthday, - AvatarURL: storage.ResolveCDN(user.AvatarURL), - BlinkieURL: storage.ResolveCDN(user.BlinkieURL), - Website: user.Website, - Location: user.Location, - Pronouns: user.Pronouns, - Signature: user.Signature, - Role: string(user.Role), - CreatedAt: user.CreatedAt, + self.AccountDisabled = false + self.DisabledAt = nil + self.DisabledReason = "" + self.DisabledBy = nil + self.DisabledUntil = nil + return true +} + +func (self *User) IsVerified() bool { + return self.EmailVerified +} + +func (self *User) SetVerification(hash string, expiry time.Time, verificationType enums.VerificationType) { + self.VerificationHash = hash + self.VerificationExpiry = &expiry + self.VerificationType = verificationType +} + +func (self *User) VerifyEmail() { + self.EmailVerified = true + self.VerificationHash = "" + self.VerificationExpiry = nil + self.VerificationType = "" +} + +func (self *User) ToResponse() user.UserResponse { + return user.UserResponse{ + Username: self.Username, + Email: self.Email, + DisplayName: self.DisplayName, + Bio: self.Bio, + Birthday: self.Birthday, + AvatarURL: storage.ResolveCDN(self.AvatarURL), + BlinkieURL: storage.ResolveCDN(self.BlinkieURL), + Website: self.Website, + Location: self.Location, + Pronouns: self.Pronouns, + Signature: self.Signature, + Role: string(self.Role), + CreatedAt: self.CreatedAt, } } -func (user *User) ToAdminResponse() types.AdminUserResponse { - return types.AdminUserResponse{ - Username: user.Username, - Email: user.Email, - DisplayName: user.DisplayName, - AvatarURL: storage.ResolveCDN(user.AvatarURL), - Role: string(user.Role), - EmailVerified: user.EmailVerified, - AccountBanned: user.AccountBanned, - BannedReason: user.BannedReason, - BannedAt: user.BannedAt, - AccountDisabled: user.AccountDisabled, - DisabledReason: user.DisabledReason, - DisabledAt: user.DisabledAt, - LastSeenAt: user.LastSeenAt, - CreatedAt: user.CreatedAt, +func (self *User) ToAdminResponse() user.AdminUserResponse { + return user.AdminUserResponse{ + UserResponse: self.ToResponse(), + Jade: self.Jade, + Honor: self.Honor, + EmailVerified: self.EmailVerified, + WarningCount: self.WarningCount, + AccountBanned: self.AccountBanned, + BannedReason: self.BannedReason, + BannedAt: self.BannedAt, + AccountDisabled: self.AccountDisabled, + DisabledReason: self.DisabledReason, + DisabledAt: self.DisabledAt, + DisabledUntil: self.DisabledUntil, + LastSeenAt: self.LastSeenAt, + RegistrationIP: self.RegistrationIP, } } -func (user *User) ToSummary() types.CitizenSummary { - return types.CitizenSummary{ - Username: user.Username, - DisplayName: user.DisplayName, - AvatarURL: storage.ResolveCDN(user.AvatarURL), +func (self *User) ToSummary() user.CitizenSummaryResponse { + return user.CitizenSummaryResponse{ + Username: self.Username, + DisplayName: self.DisplayName, + AvatarURL: storage.ResolveCDN(self.AvatarURL), } } -func (user *User) BeforeCreate(tx *gorm.DB) error { +func (self *User) BeforeCreate(tx *gorm.DB) error { _, bypassUsername := tx.Get("bypass_username_validation") if !bypassUsername { - if !validators.IsValidUsername(user.Username, 3) { + if !validators.IsValidUsername(self.Username, 3) { return errors.New("Username must be 3-32 characters and can only contain letters, numbers, and underscores.") } - if validators.IsReservedUsername(user.Username) { + if validators.IsReservedUsername(self.Username) { return errors.New("This username is not available.") } } - if !validators.IsValidEmail(user.Email) { + if !validators.IsValidEmail(self.Email) { return errors.New("Please enter a valid email address.") } - if len(strings.TrimSpace(user.DisplayName)) < 1 || len(strings.TrimSpace(user.DisplayName)) > 50 { + if len(strings.TrimSpace(self.DisplayName)) < 1 || len(strings.TrimSpace(self.DisplayName)) > 50 { return errors.New("Display name must be between 1 and 50 characters.") } - if user.PasswordHash == "" { + if self.PasswordHash == "" { return errors.New("Password is required.") } - user.Email = strings.ToLower(strings.TrimSpace(user.Email)) - user.DisplayName = strings.TrimSpace(user.DisplayName) - user.Username = strings.TrimSpace(user.Username) + self.Email = strings.ToLower(strings.TrimSpace(self.Email)) + self.DisplayName = strings.TrimSpace(self.DisplayName) + self.Username = strings.TrimSpace(self.Username) return nil } -func (user *User) BeforeUpdate(tx *gorm.DB) error { - if !validators.IsValidEmail(user.Email) { +func (self *User) BeforeUpdate(tx *gorm.DB) error { + if !validators.IsValidEmail(self.Email) { return errors.New("Please enter a valid email address.") } - if len(strings.TrimSpace(user.DisplayName)) < 1 || len(strings.TrimSpace(user.DisplayName)) > 50 { + if len(strings.TrimSpace(self.DisplayName)) < 1 || len(strings.TrimSpace(self.DisplayName)) > 50 { return errors.New("Display name must be between 1 and 50 characters.") } - user.Email = strings.ToLower(strings.TrimSpace(user.Email)) - user.DisplayName = strings.TrimSpace(user.DisplayName) + self.Email = strings.ToLower(strings.TrimSpace(self.Email)) + self.DisplayName = strings.TrimSpace(self.DisplayName) return nil }
\ No newline at end of file diff --git a/shrine/models/warning.go b/shrine/models/warning.go new file mode 100644 index 0000000..12631a3 --- /dev/null +++ b/shrine/models/warning.go @@ -0,0 +1,30 @@ +package models + +import ( + "shrine/types/warning" + + "gorm.io/gorm" +) + +type Warning struct { + gorm.Model + UserID uint `gorm:"index;not null"` + User User `gorm:"foreignKey:UserID"` + AdminID uint `gorm:"not null"` + Admin User `gorm:"foreignKey:AdminID"` + LetterID uint `gorm:"not null"` + Letter Letter `gorm:"foreignKey:LetterID"` + SystemRef string `gorm:"size:20;uniqueIndex"` + Message string `gorm:"type:text;not null"` + Active bool `gorm:"not null;default:true"` +} + +func (self *Warning) ToResponse() warning.WarningResponse { + return warning.WarningResponse{ + SystemRef: self.SystemRef, + Admin: self.Admin.Username, + Message: self.Message, + Active: self.Active, + CreatedAt: self.CreatedAt, + } +}
\ No newline at end of file diff --git a/shrine/repositories/audit.go b/shrine/repositories/audit.go new file mode 100644 index 0000000..bb15d83 --- /dev/null +++ b/shrine/repositories/audit.go @@ -0,0 +1,52 @@ +package repositories + +import ( + "encoding/json" + "shrine/database" + "shrine/models" + "shrine/utils/crypto" + "shrine/utils/meta" +) + +func LogAction(actorID uint, action string, targetType string, targetRef string, summary string, details any) string { + detailsJSON, _ := json.Marshal(details) + ref := crypto.SystemRef() + + log := models.AuditLog{ + SystemRef: ref, + ActorID: actorID, + Action: action, + TargetType: targetType, + TargetRef: targetRef, + Summary: summary, + Details: string(detailsJSON), + } + + database.DB.Create(&log) + return ref +} + +func ListAuditLogs(p meta.Pagination, action string, targetType string) ([]models.AuditLog, int64) { + var logs []models.AuditLog + var total int64 + + query := database.DB.Model(&models.AuditLog{}) + + if action != "" { + query = query.Where("action = ?", action) + } + if targetType != "" { + query = query.Where("target_type = ?", targetType) + } + + query.Count(&total) + p.Apply(query.Order("created_at desc")).Preload("Actor").Find(&logs) + + return logs, total +} + +func FindAuditLogByRef(ref string) (*models.AuditLog, error) { + var log models.AuditLog + err := database.DB.Preload("Actor").Where("system_ref = ?", ref).First(&log).Error + return &log, err +}
\ No newline at end of file diff --git a/shrine/repositories/letter.go b/shrine/repositories/letter.go new file mode 100644 index 0000000..b9c81aa --- /dev/null +++ b/shrine/repositories/letter.go @@ -0,0 +1,263 @@ +package repositories + +import ( + "shrine/database" + "shrine/models" + "shrine/utils/meta" + "time" + + "gorm.io/gorm" +) + +func CreateLetter(creatorID uint, title string, recipientIDs []uint, body string, attachmentRefs []string) (*models.Letter, error) { + var letter models.Letter + err := database.DB.Transaction(func(tx *gorm.DB) error { + letter = models.Letter{ + Title: title, + CreatorID: &creatorID, + } + if err := tx.Create(&letter).Error; err != nil { + return err + } + + participants := make([]models.LetterParticipant, 0, len(recipientIDs)+1) + participants = append(participants, models.LetterParticipant{ + LetterID: letter.ID, + UserID: creatorID, + Role: "owner", + }) + for _, uid := range recipientIDs { + participants = append(participants, models.LetterParticipant{ + LetterID: letter.ID, + UserID: uid, + }) + } + if err := tx.Create(&participants).Error; err != nil { + return err + } + + msg := models.LetterMessage{ + LetterID: letter.ID, + SenderID: &creatorID, + Body: body, + } + if err := tx.Create(&msg).Error; err != nil { + return err + } + + if len(attachmentRefs) > 0 { + tx.Model(&models.LetterAttachment{}).Where("ref IN ? AND uploader_id = ? AND message_id IS NULL", attachmentRefs, creatorID).Update("message_id", msg.ID) + } + + return nil + }) + + return &letter, err +} + +func CreateSystemLetter(recipientID uint, title string, body string, systemRef string) (*models.Letter, error) { + var letter models.Letter + err := database.DB.Transaction(func(tx *gorm.DB) error { + letter = models.Letter{ + Title: title, + IsSystem: true, + SystemRef: systemRef, + } + if err := tx.Create(&letter).Error; err != nil { + return err + } + + participant := models.LetterParticipant{ + LetterID: letter.ID, + UserID: recipientID, + } + if err := tx.Create(&participant).Error; err != nil { + return err + } + + msg := models.LetterMessage{ + LetterID: letter.ID, + Body: body, + } + if err := tx.Create(&msg).Error; err != nil { + return err + } + + return nil + }) + + return &letter, err +} + +func FindLetterByRef(ref string) (*models.Letter, error) { + var letter models.Letter + err := database.DB.Where("ref = ?", ref).First(&letter).Error + return &letter, err +} + +func IsLetterParticipant(letterID uint, userID uint) bool { + var count int64 + database.DB.Model(&models.LetterParticipant{}).Where("letter_id = ? AND user_id = ?", letterID, userID).Count(&count) + return count > 0 +} + +func GetLetterParticipants(letterID uint) []models.LetterParticipant { + var participants []models.LetterParticipant + database.DB.Where("letter_id = ?", letterID).Preload("User").Find(&participants) + return participants +} + +func GetLetterMessages(letterID uint, p meta.Pagination) ([]models.LetterMessage, int64) { + var messages []models.LetterMessage + var total int64 + + query := database.DB.Unscoped().Model(&models.LetterMessage{}).Where("letter_id = ?", letterID) + query.Count(&total) + p.Apply(query.Order("created_at asc")).Preload("Sender").Preload("Attachments").Find(&messages) + + return messages, total +} + +func ListLettersForUser(userID uint, p meta.Pagination) ([]models.Letter, int64) { + var letters []models.Letter + var total int64 + + subQuery := database.DB.Model(&models.LetterParticipant{}).Select("letter_id").Where("user_id = ?", userID) + query := database.DB.Model(&models.Letter{}).Where("id IN (?)", subQuery) + + query.Count(&total) + p.Apply(query.Order("updated_at desc")).Find(&letters) + + return letters, total +} + +func GetLastMessage(letterID uint) *models.LetterMessage { + var msg models.LetterMessage + err := database.DB.Where("letter_id = ?", letterID).Preload("Sender").Preload("Attachments").Order("created_at desc").First(&msg).Error + if err != nil { + return nil + } + return &msg +} + +func GetParticipantRecord(letterID uint, userID uint) (*models.LetterParticipant, error) { + var p models.LetterParticipant + err := database.DB.Where("letter_id = ? AND user_id = ?", letterID, userID).First(&p).Error + return &p, err +} + +func UpdateLastRead(letterID uint, userID uint) { + now := time.Now() + database.DB.Model(&models.LetterParticipant{}).Where("letter_id = ? AND user_id = ?", letterID, userID).Update("last_read_at", now) +} + +func SendMessage(letterID uint, senderID uint, body string, attachmentRefs []string) (*models.LetterMessage, error) { + var msg models.LetterMessage + err := database.DB.Transaction(func(tx *gorm.DB) error { + msg = models.LetterMessage{ + LetterID: letterID, + SenderID: &senderID, + Body: body, + } + if err := tx.Create(&msg).Error; err != nil { + return err + } + + if len(attachmentRefs) > 0 { + tx.Model(&models.LetterAttachment{}).Where("ref IN ? AND uploader_id = ? AND message_id IS NULL", attachmentRefs, senderID).Update("message_id", msg.ID) + } + + tx.Model(&models.Letter{}).Where("id = ?", letterID).Update("updated_at", time.Now()) + + return nil + }) + + if err == nil { + database.DB.Preload("Sender").Preload("Attachments").First(&msg, msg.ID) + } + + return &msg, err +} + +func FindMessageByRef(ref string) (*models.LetterMessage, error) { + var msg models.LetterMessage + err := database.DB.Preload("Sender").Preload("Attachments").Where("ref = ?", ref).First(&msg).Error + return &msg, err +} + +func EditMessage(msg *models.LetterMessage, body string) error { + now := time.Now() + msg.Body = body + msg.EditedAt = &now + return database.DB.Save(msg).Error +} + +func DeleteMessage(msg *models.LetterMessage) error { + for _, a := range msg.Attachments { + database.DB.Delete(&a) + } + return database.DB.Delete(msg).Error +} + +func RenameLetter(letter *models.Letter, title string) error { + letter.Title = title + return database.DB.Save(letter).Error +} + +func LeaveLetter(letterID uint, userID uint) error { + return database.DB.Where("letter_id = ? AND user_id = ?", letterID, userID).Delete(&models.LetterParticipant{}).Error +} + +func RemoveParticipant(letterID uint, userID uint) error { + return database.DB.Where("letter_id = ? AND user_id = ?", letterID, userID).Delete(&models.LetterParticipant{}).Error +} + +func GetLetterParticipantCount(letterID uint) int64 { + var count int64 + database.DB.Model(&models.LetterParticipant{}).Where("letter_id = ?", letterID).Count(&count) + return count +} + +func IsLetterOwner(letterID uint, userID uint) bool { + var count int64 + database.DB.Model(&models.LetterParticipant{}).Where("letter_id = ? AND user_id = ? AND role = ?", letterID, userID, "owner").Count(&count) + return count > 0 +} + +func UpdateAttachmentPath(attachment *models.LetterAttachment) error { + return database.DB.Save(attachment).Error +} + +func FindExistingDM(userID uint, recipientID uint) *models.Letter { + var participant models.LetterParticipant + subQuery := database.DB.Model(&models.LetterParticipant{}).Select("letter_id").Where("user_id = ?", recipientID) + err := database.DB.Where("user_id = ? AND letter_id IN (?)", userID, subQuery).First(&participant).Error + if err != nil { + return nil + } + + count := GetLetterParticipantCount(participant.LetterID) + if count != 2 { + return nil + } + + var letter models.Letter + if err := database.DB.Where("id = ? AND is_system = ?", participant.LetterID, false).First(&letter).Error; err != nil { + return nil + } + + return &letter +} + +func UploadAttachment(uploaderID uint, attachment *models.LetterAttachment) error { + attachment.UploaderID = uploaderID + return database.DB.Create(attachment).Error +} + +func DeleteOrphanedAttachments() { + var attachments []models.LetterAttachment + database.DB.Where("message_id IS NULL AND created_at < ?", time.Now().Add(-24*time.Hour)).Find(&attachments) + for _, a := range attachments { + database.DB.Delete(&a) + } +}
\ No newline at end of file diff --git a/shrine/repositories/ticket.go b/shrine/repositories/ticket.go new file mode 100644 index 0000000..c3c9d8c --- /dev/null +++ b/shrine/repositories/ticket.go @@ -0,0 +1,92 @@ +package repositories + +import ( + "shrine/database" + "shrine/models" + "shrine/utils/meta" +) + +func CreateTicketCategory(category *models.TicketCategory) error { + return database.DB.Create(category).Error +} + +func UpdateTicketCategory(category *models.TicketCategory) error { + return database.DB.Save(category).Error +} + +func DeleteTicketCategory(category *models.TicketCategory) error { + return database.DB.Delete(category).Error +} + +func ListTicketCategories() []models.TicketCategory { + var categories []models.TicketCategory + database.DB.Order("sort_order asc").Find(&categories) + return categories +} + +func FindTicketCategoryByRef(ref string) (*models.TicketCategory, error) { + var category models.TicketCategory + err := database.DB.Where("ref = ?", ref).First(&category).Error + return &category, err +} + +func CreateTicket(ticket *models.Ticket, body string) error { + if err := database.DB.Create(ticket).Error; err != nil { + return err + } + msg := models.TicketMessage{ + TicketID: ticket.ID, + SenderID: ticket.UserID, + Body: body, + } + return database.DB.Create(&msg).Error +} + +func FindTicketByRef(ref string) (*models.Ticket, error) { + var ticket models.Ticket + err := database.DB.Preload("User").Preload("Category").Preload("Assignee").Where("ref = ?", ref).First(&ticket).Error + return &ticket, err +} + +func ListTicketsForUser(userID uint, p meta.Pagination) ([]models.Ticket, int64) { + var tickets []models.Ticket + var total int64 + + query := database.DB.Model(&models.Ticket{}).Where("user_id = ?", userID) + query.Count(&total) + p.Apply(query.Order("updated_at desc")).Preload("User").Preload("Category").Preload("Assignee").Find(&tickets) + + return tickets, total +} + +func ListAllTickets(p meta.Pagination, status string, priority string) ([]models.Ticket, int64) { + var tickets []models.Ticket + var total int64 + + query := database.DB.Model(&models.Ticket{}) + if status != "" { + query = query.Where("status = ?", status) + } + if priority != "" { + query = query.Where("priority = ?", priority) + } + + query.Count(&total) + p.Apply(query.Order("updated_at desc")).Preload("User").Preload("Category").Preload("Assignee").Find(&tickets) + + return tickets, total +} + +func GetTicketMessages(ticketID uint) []models.TicketMessage { + var messages []models.TicketMessage + database.DB.Where("ticket_id = ?", ticketID).Preload("Sender").Order("created_at asc").Find(&messages) + return messages +} + +func CreateTicketMessage(msg *models.TicketMessage) error { + return database.DB.Create(msg).Error +} + +func UpdateTicket(ticket *models.Ticket) error { + return database.DB.Save(ticket).Error +}
\ No newline at end of file diff --git a/shrine/repositories/warning.go b/shrine/repositories/warning.go new file mode 100644 index 0000000..6cbd776 --- /dev/null +++ b/shrine/repositories/warning.go @@ -0,0 +1,93 @@ +package repositories + +import ( + "shrine/database" + "shrine/models" + "shrine/utils/crypto" + "shrine/utils/meta" + + "gorm.io/gorm" +) + +func CreateWarning(adminID uint, userID uint, title string, message string) (*models.Warning, error) { + var warning models.Warning + ref := crypto.SystemRef() + + err := database.DB.Transaction(func(tx *gorm.DB) error { + letter := models.Letter{ + Title: title, + IsSystem: true, + SystemRef: ref, + } + if err := tx.Create(&letter).Error; err != nil { + return err + } + + participant := models.LetterParticipant{ + LetterID: letter.ID, + UserID: userID, + } + if err := tx.Create(&participant).Error; err != nil { + return err + } + + msg := models.LetterMessage{ + LetterID: letter.ID, + Body: message, + } + if err := tx.Create(&msg).Error; err != nil { + return err + } + + warning = models.Warning{ + UserID: userID, + AdminID: adminID, + LetterID: letter.ID, + SystemRef: ref, + Message: message, + } + if err := tx.Create(&warning).Error; err != nil { + return err + } + + if err := tx.Model(&models.User{}).Where("id = ?", userID).Update("warning_count", gorm.Expr("warning_count + 1")).Error; err != nil { + return err + } + + return nil + }) + + return &warning, err +} + +func DeactivateWarning(warning *models.Warning) error { + return database.DB.Transaction(func(tx *gorm.DB) error { + warning.Active = false + if err := tx.Save(warning).Error; err != nil { + return err + } + + if err := tx.Model(&models.User{}).Where("id = ? AND warning_count > 0", warning.UserID).Update("warning_count", gorm.Expr("warning_count - 1")).Error; err != nil { + return err + } + + return nil + }) +} + +func ListWarningsForUser(userID uint, p meta.Pagination) ([]models.Warning, int64) { + var warnings []models.Warning + var total int64 + + query := database.DB.Model(&models.Warning{}).Where("user_id = ?", userID) + query.Count(&total) + p.Apply(query.Order("created_at desc")).Preload("Admin").Find(&warnings) + + return warnings, total +} + +func FindWarningByRef(ref string) (*models.Warning, error) { + var warning models.Warning + err := database.DB.Preload("Admin").Where("system_ref = ?", ref).First(&warning).Error + return &warning, err +}
\ No newline at end of file diff --git a/shrine/router/auth.go b/shrine/router/auth.go index 0c625fe..a7ef54b 100644 --- a/shrine/router/auth.go +++ b/shrine/router/auth.go @@ -2,7 +2,7 @@ package router import ( "shrine/controllers" - "shrine/types" + "shrine/enums" "shrine/utils/auth" "shrine/utils/urls" ) @@ -10,11 +10,11 @@ import ( func init() { urls.SetNamespace("auth") - urls.Path(types.POST, "/register", controllers.RegisterController, "register") - urls.Path(types.POST, "/login", controllers.LoginController, "login") - urls.Path(types.POST, "/verify", controllers.VerifyController, "verify") - urls.Path(types.POST, "/reactivate", controllers.ResendActivationController, "reactivate") - urls.Path(types.POST, "/logout", auth.RequireAuthentication(controllers.LogoutController), "logout") - urls.Path(types.GET, "/me", auth.RequireAuthentication(controllers.MeController), "me") - urls.Path(types.POST, "/heartbeat", auth.RequireAuthentication(controllers.HeartbeatController), "heartbeat") + urls.Path(enums.POST, "/register", controllers.RegisterController, "register") + urls.Path(enums.POST, "/login", controllers.LoginController, "login") + urls.Path(enums.POST, "/verify", controllers.VerifyController, "verify") + urls.Path(enums.POST, "/reactivate", controllers.ResendActivationController, "reactivate") + urls.Path(enums.POST, "/logout", auth.RequireAuthentication(controllers.LogoutController), "logout") + urls.Path(enums.GET, "/me", auth.RequireAuthentication(controllers.MeController), "me") + urls.Path(enums.POST, "/heartbeat", auth.RequireAuthentication(controllers.HeartbeatController), "heartbeat") }
\ No newline at end of file diff --git a/shrine/router/council.go b/shrine/router/council.go index 99f466d..2eb96b4 100644 --- a/shrine/router/council.go +++ b/shrine/router/council.go @@ -2,7 +2,7 @@ package router import ( "shrine/controllers" - "shrine/types" + "shrine/enums" "shrine/utils/auth" "shrine/utils/urls" ) @@ -10,11 +10,28 @@ import ( func init() { urls.SetNamespace("council") - urls.Path(types.GET, "/users", auth.RequireStaff(controllers.ListUsersController), "users") - urls.Path(types.GET, "/users/:username", auth.RequireStaff(controllers.GetUserController), "user") - urls.Path(types.POST, "/users/:username/ban", auth.RequireStaff(controllers.BanUserController), "ban") - urls.Path(types.POST, "/users/:username/unban", auth.RequireStaff(controllers.UnbanUserController), "unban") - urls.Path(types.POST, "/users/:username/disable", auth.RequireStaff(controllers.DisableUserController), "disable") - urls.Path(types.POST, "/users/:username/enable", auth.RequireStaff(controllers.EnableUserController), "enable") - urls.Path(types.POST, "/users/:username/role", auth.RequireAdmin(controllers.ChangeRoleController), "role") + urls.Path(enums.GET, "/users", auth.RequireStaff(controllers.ListUsersController), "users") + urls.Path(enums.GET, "/users/:username", auth.RequireStaff(controllers.GetUserController), "user") + urls.Path(enums.POST, "/users/:username/ban", auth.RequireStaff(controllers.BanUserController), "ban") + urls.Path(enums.POST, "/users/:username/unban", auth.RequireStaff(controllers.UnbanUserController), "unban") + urls.Path(enums.POST, "/users/:username/disable", auth.RequireStaff(controllers.DisableUserController), "disable") + urls.Path(enums.POST, "/users/:username/enable", auth.RequireStaff(controllers.EnableUserController), "enable") + urls.Path(enums.POST, "/users/:username/role", auth.RequireAdmin(controllers.ChangeRoleController), "role") + urls.Path(enums.PATCH, "/users/:username", auth.RequireAdmin(controllers.EditUserController), "edit") + + urls.Path(enums.GET, "/users/:username/warnings", auth.RequireStaff(controllers.ListWarningsController), "warnings") + urls.Path(enums.POST, "/users/:username/warn", auth.RequireStaff(controllers.WarnUserController), "warn") + urls.Path(enums.POST, "/warnings/:ref/deactivate", auth.RequireStaff(controllers.DeactivateWarningController), "deactivate") + + urls.Path(enums.GET, "/tickets", auth.RequireStaff(controllers.ListAllTicketsController), "tickets") + urls.Path(enums.GET, "/tickets/:ref", auth.RequireStaff(controllers.GetStaffTicketController), "ticket") + urls.Path(enums.PATCH, "/tickets/:ref", auth.RequireStaff(controllers.UpdateTicketController), "ticketupdate") + urls.Path(enums.POST, "/tickets/:ref/messages", auth.RequireStaff(controllers.StaffReplyTicketController), "ticketreply") + urls.Path(enums.GET, "/categories", auth.RequireStaff(controllers.ListTicketCategoriesController), "categories") + urls.Path(enums.POST, "/categories", auth.RequireAdmin(controllers.CreateTicketCategoryController), "categorycreate") + urls.Path(enums.PATCH, "/categories/:ref", auth.RequireAdmin(controllers.UpdateTicketCategoryController), "categoryupdate") + urls.Path(enums.DELETE, "/categories/:ref", auth.RequireAdmin(controllers.DeleteTicketCategoryController), "categorydelete") + + urls.Path(enums.GET, "/audit", auth.RequireStaff(controllers.ListAuditLogsController), "audit") + urls.Path(enums.GET, "/audit/:ref", auth.RequireStaff(controllers.GetAuditLogController), "auditdetail") }
\ No newline at end of file diff --git a/shrine/router/letter.go b/shrine/router/letter.go new file mode 100644 index 0000000..ac8fd25 --- /dev/null +++ b/shrine/router/letter.go @@ -0,0 +1,23 @@ +package router + +import ( + "shrine/controllers" + "shrine/enums" + "shrine/utils/auth" + "shrine/utils/urls" +) + +func init() { + urls.SetNamespace("letters") + + urls.Path(enums.GET, "", auth.RequireAuthentication(controllers.ListLettersController), "list") + urls.Path(enums.POST, "", auth.RequireAuthentication(controllers.CreateLetterController), "create") + urls.Path(enums.POST, "/attachments", auth.RequireAuthentication(controllers.UploadAttachmentController), "upload") + urls.Path(enums.GET, "/:ref", auth.RequireAuthentication(controllers.GetLetterController), "detail") + urls.Path(enums.PATCH, "/:ref", auth.RequireAuthentication(controllers.RenameLetterController), "rename") + urls.Path(enums.POST, "/:ref/messages", auth.RequireAuthentication(controllers.SendMessageController), "send") + urls.Path(enums.PATCH, "/:ref/messages/:messageRef", auth.RequireAuthentication(controllers.EditMessageController), "edit") + urls.Path(enums.DELETE, "/:ref/messages/:messageRef", auth.RequireAuthentication(controllers.DeleteMessageController), "delete") + urls.Path(enums.POST, "/:ref/leave", auth.RequireAuthentication(controllers.LeaveLetterController), "leave") + urls.Path(enums.POST, "/:ref/remove", auth.RequireAuthentication(controllers.RemoveParticipantController), "remove") +}
\ No newline at end of file diff --git a/shrine/router/router.go b/shrine/router/router.go index b92aecb..8753717 100644 --- a/shrine/router/router.go +++ b/shrine/router/router.go @@ -1,7 +1,7 @@ package router import ( - "shrine/controllers" + "shrine/utils/shortcuts" "shrine/utils/urls" "github.com/gofiber/fiber/v2" @@ -11,24 +11,22 @@ func Initialize(router *fiber.App) { urls.Attach(router) } -func ErrorHandler(ctx *fiber.Ctx, err error) error { +func ErrorHandler(context *fiber.Ctx, err error) error { code := fiber.StatusInternalServerError - if e, ok := err.(*fiber.Error); ok { - code = e.Code + if fiberErr, ok := err.(*fiber.Error); ok { + code = fiberErr.Code } switch code { case fiber.StatusBadRequest: - return controllers.BadRequest(ctx, err) + return shortcuts.BadRequest(context, err) case fiber.StatusUnauthorized: - return controllers.Unauthorized(ctx, err) + return shortcuts.Unauthorized(context, err) case fiber.StatusForbidden: - return controllers.Forbidden(ctx, err) + return shortcuts.Forbidden(context, err) case fiber.StatusNotFound: - return controllers.NotFound(ctx, err) - case fiber.StatusInternalServerError: - return controllers.InternalServerError(ctx, err) + return shortcuts.NotFound(context, err) default: - return controllers.DefaultError(ctx, err) + return shortcuts.InternalServerError(context, err) } } diff --git a/shrine/router/stats.go b/shrine/router/stats.go index 8c165f9..8837973 100644 --- a/shrine/router/stats.go +++ b/shrine/router/stats.go @@ -2,12 +2,12 @@ package router import ( "shrine/controllers" - "shrine/types" + "shrine/enums" "shrine/utils/urls" ) func init() { urls.SetNamespace("stats") - urls.Path(types.GET, "/", controllers.StatsController, "index") + urls.Path(enums.GET, "/", controllers.StatsController, "index") }
\ No newline at end of file diff --git a/shrine/router/ticket.go b/shrine/router/ticket.go new file mode 100644 index 0000000..edb7cb2 --- /dev/null +++ b/shrine/router/ticket.go @@ -0,0 +1,17 @@ +package router + +import ( + "shrine/controllers" + "shrine/enums" + "shrine/utils/auth" + "shrine/utils/urls" +) + +func init() { + urls.SetNamespace("tickets") + + urls.Path(enums.GET, "", auth.RequireAuthentication(controllers.ListUserTicketsController), "list") + urls.Path(enums.POST, "", auth.RequireAuthentication(controllers.CreateTicketController), "create") + urls.Path(enums.GET, "/:ref", auth.RequireAuthentication(controllers.GetUserTicketController), "detail") + urls.Path(enums.POST, "/:ref/messages", auth.RequireAuthentication(controllers.ReplyTicketController), "reply") +}
\ No newline at end of file diff --git a/shrine/services/audit.go b/shrine/services/audit.go new file mode 100644 index 0000000..3140cf1 --- /dev/null +++ b/shrine/services/audit.go @@ -0,0 +1,31 @@ +package services + +import ( + "shrine/enums" + "shrine/messages" + "shrine/repositories" + "shrine/types/audit" + "shrine/types/hypertext" + "shrine/utils/meta" +) + +func ListAuditLogs(pagination meta.Pagination, action string, targetType string) ([]audit.AuditLogResponse, int64) { + logs, total := repositories.ListAuditLogs(pagination, action, targetType) + + items := make([]audit.AuditLogResponse, len(logs)) + for index, record := range logs { + items[index] = record.ToResponse() + } + + return items, total +} + +func GetAuditLog(ref string) (*audit.DetailResponse, *hypertext.ServiceError) { + record, err := repositories.FindAuditLogByRef(ref) + if err != nil { + return nil, fail(enums.NotFound, messages.AuditLogNotFound) + } + + response := record.ToDetailResponse() + return &response, nil +}
\ No newline at end of file diff --git a/shrine/services/auth.go b/shrine/services/auth.go new file mode 100644 index 0000000..28e63c4 --- /dev/null +++ b/shrine/services/auth.go @@ -0,0 +1,108 @@ +package services + +import ( + "shrine/enums" + "shrine/messages" + "shrine/models" + "shrine/repositories" + "shrine/types/account" + "shrine/types/common" + "shrine/types/hypertext" + "shrine/utils/crypto" +) + +func Register(request account.RegisterRequest) (*common.MessageResponse, *hypertext.ServiceError) { + citizen := models.User{ + Username: request.Username, + Email: request.Email, + DisplayName: request.DisplayName, + } + + if err := citizen.SetPassword(request.Password); err != nil { + return nil, fail(enums.BadRequest, err.Error()) + } + + if err := repositories.CreateUser(&citizen); err != nil { + return nil, mapRegistrationError(err) + } + + if serviceErr := SendVerification(&citizen, enums.Activation); serviceErr != nil { + return nil, serviceErr + } + + return &common.MessageResponse{Message: messages.AccountCreated}, nil +} + +func Authenticate(request account.LoginRequest) (*models.User, *hypertext.ServiceError) { + citizen, err := repositories.FindUserByUsername(request.Username) + if err != nil { + return nil, fail(enums.Unauthorized, messages.InvalidUsernameOrPassword) + } + + if citizen.ClearExpiredDisable() { + repositories.UpdateUser(citizen) + } + + if !citizen.CanAuthenticate() { + return nil, fail(enums.Forbidden, messages.AccountBannedOrDisabled) + } + + if !citizen.CheckPassword(request.Password) { + return nil, fail(enums.Unauthorized, messages.InvalidUsernameOrPassword) + } + + if !citizen.IsVerified() { + return nil, fail(enums.Forbidden, messages.EmailNotVerified) + } + + return citizen, nil +} + +func VerifyAccount(request account.VerifyRequest) (*common.MessageResponse, *hypertext.ServiceError) { + if request.Token == "" { + return nil, fail(enums.BadRequest, messages.VerificationTokenRequired) + } + + verificationType := enums.VerificationType(request.Type) + citizen, err := repositories.FindUserByVerification(crypto.HashToken(request.Token), verificationType) + if err != nil { + return nil, fail(enums.BadRequest, messages.VerificationLinkInvalid) + } + + switch verificationType { + case enums.Activation: + citizen.VerifyEmail() + default: + return nil, fail(enums.BadRequest, messages.InvalidVerificationType) + } + + if err := repositories.UpdateUser(citizen); err != nil { + return nil, fail(enums.Internal, messages.FailedVerifyAccount) + } + + return &common.MessageResponse{Message: messages.EmailVerified}, nil +} + +func ResendActivation(request account.ResendActivationRequest) (*common.MessageResponse, *hypertext.ServiceError) { + citizen, err := repositories.FindUserByEmail(request.Email) + if err != nil { + return nil, fail(enums.BadRequest, messages.NoAccountWithEmail) + } + + if citizen.IsVerified() { + return nil, fail(enums.BadRequest, messages.AccountAlreadyVerified) + } + + if serviceErr := SendVerification(citizen, enums.Activation); serviceErr != nil { + return nil, serviceErr + } + + return &common.MessageResponse{Message: messages.VerificationEmailSent}, nil +} + +func RevokeToken(tokenHash string) (*common.MessageResponse, *hypertext.ServiceError) { + if err := repositories.DeleteToken(tokenHash); err != nil { + return nil, fail(enums.Internal, messages.FailedEndSession) + } + return &common.MessageResponse{Message: messages.LoggedOut}, nil +}
\ No newline at end of file diff --git a/shrine/services/council.go b/shrine/services/council.go new file mode 100644 index 0000000..c3b4d6b --- /dev/null +++ b/shrine/services/council.go @@ -0,0 +1,297 @@ +package services + +import ( + "fmt" + "shrine/enums" + "shrine/messages" + "shrine/models" + "shrine/repositories" + "shrine/types/audit" + "shrine/types/council" + "shrine/types/hypertext" + "shrine/types/user" + "shrine/utils/auth" + "shrine/utils/meta" + "shrine/utils/crypto" + "shrine/utils/emails" + "shrine/utils/logger" + "shrine/utils/sanitize" + "shrine/utils/validators" + "strings" + "time" +) + +func ListUsers(pagination meta.Pagination, search string) ([]user.AdminUserResponse, int64) { + citizens, total := repositories.ListUsers(pagination, search) + + items := make([]user.AdminUserResponse, len(citizens)) + for index, citizen := range citizens { + items[index] = citizen.ToAdminResponse() + } + + return items, total +} + +func GetUser(username string) (*user.AdminUserResponse, *hypertext.ServiceError) { + citizen, err := repositories.FindUserByUsername(username) + if err != nil { + return nil, fail(enums.NotFound, messages.UserNotFound) + } + + response := citizen.ToAdminResponse() + return &response, nil +} + +func BanUser(admin *models.User, target *models.User, request council.BanRequest) (*user.AdminUserResponse, *hypertext.ServiceError) { + if hierarchyErr := auth.ValidateHierarchy(admin, target, "ban"); hierarchyErr != nil { + return nil, fail(enums.Forbidden, hierarchyErr.Error()) + } + + ref := crypto.SystemRef() + sanitizedMessage := sanitize.HTML(request.Message) + + now := time.Now() + target.AccountBanned = true + target.BannedAt = &now + target.BannedReason = request.Reason + target.BannedBy = &admin.ID + + if err := repositories.UpdateUser(target); err != nil { + return nil, fail(enums.Internal, messages.FailedBanUser) + } + + if sanitizedMessage != "" { + repositories.CreateSystemLetter(target.ID, fmt.Sprintf("Account Banned [%s]", ref), sanitizedMessage, ref) + } + + repositories.LogAction(admin.ID, "user.ban", "user", target.Username, fmt.Sprintf("Banned user @%s", target.Username), audit.BanDetails{ + Reason: request.Reason, + SystemRef: ref, + }) + + if err := emails.SendBannedNotification(target.Email, target.Username, request.Reason); err != nil { + logger.Errorf("Council", "Failed to send ban notification to %s: %v", target.Email, err) + } + + response := target.ToAdminResponse() + return &response, nil +} + +func UnbanUser(admin *models.User, target *models.User) (*user.AdminUserResponse, *hypertext.ServiceError) { + target.AccountBanned = false + target.BannedAt = nil + target.BannedReason = "" + target.BannedBy = nil + + if err := repositories.UpdateUser(target); err != nil { + return nil, fail(enums.Internal, messages.FailedUnbanUser) + } + + repositories.LogAction(admin.ID, "user.unban", "user", target.Username, fmt.Sprintf("Unbanned user @%s", target.Username), nil) + + response := target.ToAdminResponse() + return &response, nil +} + +func DisableUser(admin *models.User, target *models.User, request council.DisableRequest) (*user.AdminUserResponse, *hypertext.ServiceError) { + if hierarchyErr := auth.ValidateHierarchy(admin, target, "disable"); hierarchyErr != nil { + return nil, fail(enums.Forbidden, hierarchyErr.Error()) + } + + ref := crypto.SystemRef() + sanitizedMessage := sanitize.HTML(request.Message) + + now := time.Now() + target.AccountDisabled = true + target.DisabledAt = &now + target.DisabledReason = request.Reason + target.DisabledBy = &admin.ID + + var disabledUntilStr string + if request.DisabledUntil != nil { + parsed, err := time.Parse(time.RFC3339, *request.DisabledUntil) + if err != nil { + return nil, fail(enums.BadRequest, messages.InvalidDisabledUntil) + } + target.DisabledUntil = &parsed + disabledUntilStr = parsed.Format("January 2, 2006") + } + + if err := repositories.UpdateUser(target); err != nil { + return nil, fail(enums.Internal, messages.FailedDisableUser) + } + + if sanitizedMessage != "" { + repositories.CreateSystemLetter(target.ID, fmt.Sprintf("Account Disabled [%s]", ref), sanitizedMessage, ref) + } + + repositories.LogAction(admin.ID, "user.disable", "user", target.Username, fmt.Sprintf("Disabled user @%s", target.Username), audit.DisableDetails{ + Reason: request.Reason, + DisabledUntil: request.DisabledUntil, + SystemRef: ref, + }) + + if err := emails.SendDisabledNotification(target.Email, target.Username, request.Reason, disabledUntilStr); err != nil { + logger.Errorf("Council", "Failed to send disable notification to %s: %v", target.Email, err) + } + + response := target.ToAdminResponse() + return &response, nil +} + +func EnableUser(admin *models.User, target *models.User) (*user.AdminUserResponse, *hypertext.ServiceError) { + target.AccountDisabled = false + target.DisabledAt = nil + target.DisabledReason = "" + target.DisabledBy = nil + target.DisabledUntil = nil + + if err := repositories.UpdateUser(target); err != nil { + return nil, fail(enums.Internal, messages.FailedEnableUser) + } + + repositories.LogAction(admin.ID, "user.enable", "user", target.Username, fmt.Sprintf("Enabled user @%s", target.Username), nil) + + response := target.ToAdminResponse() + return &response, nil +} + +func ChangeRole(admin *models.User, target *models.User, request council.ChangeRoleRequest) (*user.AdminUserResponse, *hypertext.ServiceError) { + if target.ID == admin.ID { + return nil, fail(enums.BadRequest, messages.CannotChangeOwnRole) + } + + if target.IsOwner() { + return nil, fail(enums.BadRequest, messages.CannotChangeOwnerRole) + } + + oldRole := string(target.Role) + role := enums.UserRole(request.Role) + + switch role { + case enums.Member, enums.Moderator: + target.Role = role + case enums.Admin: + if !admin.IsOwner() { + return nil, fail(enums.Forbidden, messages.OnlyOwnerAssignAdmin) + } + target.Role = role + default: + return nil, fail(enums.BadRequest, messages.InvalidRole) + } + + if err := repositories.UpdateUser(target); err != nil { + return nil, fail(enums.Internal, messages.FailedChangeRole) + } + + repositories.LogAction(admin.ID, "user.role_change", "user", target.Username, fmt.Sprintf("Changed role of @%s from %s to %s", target.Username, oldRole, request.Role), audit.RoleChangeDetails{ + OldRole: oldRole, + NewRole: request.Role, + }) + + response := target.ToAdminResponse() + return &response, nil +} + +func EditUser(admin *models.User, target *models.User, request council.EditUserRequest) (*user.AdminUserResponse, *hypertext.ServiceError) { + var changes []audit.FieldChange + + if request.Email != nil && !admin.IsOwner() { + return nil, fail(enums.Forbidden, messages.OnlyOwnerChangeEmail) + } + + if request.Username != nil && *request.Username != target.Username { + username := strings.TrimSpace(*request.Username) + if !validators.IsValidUsername(username, 3) { + return nil, fail(enums.BadRequest, messages.InvalidUsername) + } + if validators.IsReservedUsername(username) { + return nil, fail(enums.BadRequest, messages.UsernameNotAvailable) + } + changes = append(changes, audit.FieldChange{Field: "username", Old: target.Username, New: username}) + target.Username = username + } + + if request.DisplayName != nil { + changes = append(changes, audit.FieldChange{Field: "display_name", Old: target.DisplayName, New: *request.DisplayName}) + target.DisplayName = *request.DisplayName + } + + if request.Email != nil { + changes = append(changes, audit.FieldChange{Field: "email", Old: target.Email, New: *request.Email}) + target.Email = *request.Email + } + + if request.Bio != nil { + target.Bio = *request.Bio + changes = append(changes, audit.FieldChange{Field: "bio", Old: nil, New: nil}) + } + + if request.Birthday != nil { + if *request.Birthday == "" { + target.Birthday = nil + } else { + parsed, err := time.Parse("2006-01-02", *request.Birthday) + if err != nil { + return nil, fail(enums.BadRequest, "Invalid birthday format. Use YYYY-MM-DD.") + } + target.Birthday = &parsed + } + changes = append(changes, audit.FieldChange{Field: "birthday", Old: nil, New: nil}) + } + + if request.AvatarURL != nil { + target.AvatarURL = *request.AvatarURL + changes = append(changes, audit.FieldChange{Field: "avatar_url", Old: nil, New: nil}) + } + + if request.BlinkieURL != nil { + target.BlinkieURL = *request.BlinkieURL + changes = append(changes, audit.FieldChange{Field: "blinkie_url", Old: nil, New: nil}) + } + + if request.Website != nil { + target.Website = *request.Website + changes = append(changes, audit.FieldChange{Field: "website", Old: nil, New: nil}) + } + + if request.Location != nil { + target.Location = *request.Location + changes = append(changes, audit.FieldChange{Field: "location", Old: nil, New: nil}) + } + + if request.Pronouns != nil { + target.Pronouns = *request.Pronouns + changes = append(changes, audit.FieldChange{Field: "pronouns", Old: nil, New: nil}) + } + + if request.Signature != nil { + target.Signature = *request.Signature + changes = append(changes, audit.FieldChange{Field: "signature", Old: nil, New: nil}) + } + + if request.Jade != nil { + changes = append(changes, audit.FieldChange{Field: "jade", Old: target.Jade, New: *request.Jade}) + target.Jade = *request.Jade + } + + if request.Honor != nil { + changes = append(changes, audit.FieldChange{Field: "honor", Old: target.Honor, New: *request.Honor}) + target.Honor = *request.Honor + } + + if len(changes) == 0 { + return nil, fail(enums.BadRequest, messages.NoChangesProvided) + } + + if err := repositories.UpdateUser(target); err != nil { + return nil, fail(enums.Internal, messages.FailedUpdateUser) + } + + repositories.LogAction(admin.ID, "user.edit", "user", target.Username, fmt.Sprintf("Edited user @%s", target.Username), audit.EditUserDetails{ + Changes: changes, + }) + + response := target.ToAdminResponse() + return &response, nil +}
\ No newline at end of file diff --git a/shrine/services/errors.go b/shrine/services/errors.go new file mode 100644 index 0000000..1c390ff --- /dev/null +++ b/shrine/services/errors.go @@ -0,0 +1,10 @@ +package services + +import ( + "shrine/enums" + "shrine/types/hypertext" +) + +func fail(kind enums.ErrorKind, message string) *hypertext.ServiceError { + return &hypertext.ServiceError{Kind: kind, Message: message} +}
\ No newline at end of file diff --git a/shrine/services/functions.go b/shrine/services/functions.go new file mode 100644 index 0000000..f502ff6 --- /dev/null +++ b/shrine/services/functions.go @@ -0,0 +1,141 @@ +package services + +import ( + "fmt" + "shrine/enums" + "shrine/messages" + "shrine/models" + "shrine/repositories" + "shrine/types/hypertext" + "shrine/types/letter" + "shrine/types/ticket" + "shrine/utils/sanitize" + "strings" +) + +func ResolveUser(username string) (*models.User, *hypertext.ServiceError) { + citizen, err := repositories.FindUserByUsername(username) + if err != nil { + return nil, fail(enums.NotFound, messages.UserNotFound) + } + return citizen, nil +} + +func mapRegistrationError(err error) *hypertext.ServiceError { + if strings.Contains(err.Error(), "users.username") { + return fail(enums.Conflict, messages.UsernameAlreadyExists) + } + if strings.Contains(err.Error(), "users.email") { + return fail(enums.Conflict, messages.EmailAlreadyExists) + } + return fail(enums.BadRequest, err.Error()) +} + +func assembleLetterResponse(record *models.Letter, viewerID uint) letter.LetterResponse { + participants := repositories.GetLetterParticipants(record.ID) + lastMessage := repositories.GetLastMessage(record.ID) + viewer, _ := repositories.GetParticipantRecord(record.ID, viewerID) + + var unread bool + if viewer != nil && lastMessage != nil { + unread = viewer.LastReadAt == nil || lastMessage.CreatedAt.After(*viewer.LastReadAt) + } + + var lastMessageResponse *letter.MessageResponse + if lastMessage != nil { + response := lastMessage.ToResponse() + lastMessageResponse = &response + } + + return letter.LetterResponse{ + Ref: record.Ref, + Title: computeTitle(record, participants, viewerID), + IsSystem: record.IsSystem, + SystemRef: record.SystemRef, + Participants: buildParticipantResponses(participants), + LastMessage: lastMessageResponse, + Unread: unread, + UpdatedAt: record.UpdatedAt, + } +} + +func computeTitle(record *models.Letter, participants []models.LetterParticipant, viewerID uint) string { + if record.Title != "" { + return record.Title + } + + if record.IsSystem { + return "System Message" + } + + var others []string + for _, participant := range participants { + if participant.UserID != viewerID { + others = append(others, participant.User.DisplayName) + } + } + + switch len(others) { + case 0: + return "Empty Conversation" + case 1: + return others[0] + case 2: + return fmt.Sprintf("%s and %s", others[0], others[1]) + default: + return fmt.Sprintf("%s, %s, and %d others", others[0], others[1], len(others)-2) + } +} + +func buildParticipantResponses(participants []models.LetterParticipant) []letter.ParticipantResponse { + responses := make([]letter.ParticipantResponse, len(participants)) + for index, participant := range participants { + responses[index] = participant.ToResponse() + } + return responses +} + +func buildMessageResponses(letterMessages []models.LetterMessage) []letter.MessageResponse { + responses := make([]letter.MessageResponse, len(letterMessages)) + for index, message := range letterMessages { + responses[index] = message.ToResponse() + } + return responses +} + +func resolveLetter(ref string, userID uint) (*models.Letter, *hypertext.ServiceError) { + record, err := repositories.FindLetterByRef(ref) + if err != nil { + return nil, fail(enums.NotFound, messages.LetterNotFound) + } + + if !repositories.IsLetterParticipant(record.ID, userID) { + return nil, fail(enums.NotFound, messages.LetterNotFound) + } + + return record, nil +} + +func resolveTicket(ref string) (*models.Ticket, *hypertext.ServiceError) { + record, err := repositories.FindTicketByRef(ref) + if err != nil { + return nil, fail(enums.NotFound, messages.TicketNotFound) + } + return record, nil +} + +func sanitizeRequiredBody(body string) (string, *hypertext.ServiceError) { + sanitized := sanitize.HTML(body) + if sanitized == "" { + return "", fail(enums.BadRequest, messages.MessageBodyRequired) + } + return sanitized, nil +} + +func buildTicketMessageResponses(ticketMessages []models.TicketMessage) []ticket.MessageResponse { + responses := make([]ticket.MessageResponse, len(ticketMessages)) + for index, message := range ticketMessages { + responses[index] = message.ToResponse() + } + return responses +}
\ No newline at end of file diff --git a/shrine/services/letter.go b/shrine/services/letter.go new file mode 100644 index 0000000..66bf9b8 --- /dev/null +++ b/shrine/services/letter.go @@ -0,0 +1,277 @@ +package services + +import ( + "fmt" + "io" + "shrine/config" + "shrine/enums" + "shrine/messages" + "shrine/models" + "shrine/repositories" + "shrine/types/common" + "shrine/types/hypertext" + "shrine/types/letter" + "shrine/utils/meta" + "shrine/utils/storage" + "strings" +) + +func ListLetters(userID uint, pagination meta.Pagination) ([]letter.LetterResponse, int64) { + letters, total := repositories.ListLettersForUser(userID, pagination) + + items := make([]letter.LetterResponse, len(letters)) + for index, record := range letters { + items[index] = assembleLetterResponse(&record, userID) + } + + return items, total +} + +func GetLetter(ref string, userID uint, pagination meta.Pagination) (*letter.DetailResponse, *hypertext.ServiceError) { + record, serviceErr := resolveLetter(ref, userID) + if serviceErr != nil { + return nil, serviceErr + } + + participants := repositories.GetLetterParticipants(record.ID) + letterMessages, _ := repositories.GetLetterMessages(record.ID, pagination) + + repositories.UpdateLastRead(record.ID, userID) + + response := letter.DetailResponse{ + Ref: record.Ref, + Title: computeTitle(record, participants, userID), + IsSystem: record.IsSystem, + SystemRef: record.SystemRef, + Participants: buildParticipantResponses(participants), + Messages: buildMessageResponses(letterMessages), + CreatedAt: record.CreatedAt, + } + + return &response, nil +} + +func CreateLetter(userID uint, request letter.CreateRequest) (*common.MessageResponse, *hypertext.ServiceError) { + if len(request.Recipients) == 0 { + return nil, fail(enums.BadRequest, messages.RecipientsRequired) + } + + if len(request.Recipients) > 20 { + return nil, fail(enums.BadRequest, messages.RecipientsMax) + } + + sanitizedBody, serviceErr := sanitizeRequiredBody(request.Body) + if serviceErr != nil { + return nil, serviceErr + } + + recipientIDs := make([]uint, 0, len(request.Recipients)) + for _, username := range request.Recipients { + recipient, err := repositories.FindUserByUsername(username) + if err != nil { + return nil, fail(enums.BadRequest, fmt.Sprintf("User '%s' not found.", username)) + } + if recipient.ID == userID { + continue + } + recipientIDs = append(recipientIDs, recipient.ID) + } + + if len(recipientIDs) == 0 { + return nil, fail(enums.BadRequest, messages.ValidRecipientRequired) + } + + if len(recipientIDs) == 1 && request.Title == "" { + existing := repositories.FindExistingDM(userID, recipientIDs[0]) + if existing != nil { + _, err := repositories.SendMessage(existing.ID, userID, sanitizedBody, nil) + if err != nil { + return nil, fail(enums.Internal, messages.FailedSendMessage) + } + return &common.MessageResponse{Message: existing.Ref}, nil + } + } + + record, err := repositories.CreateLetter(userID, strings.TrimSpace(request.Title), recipientIDs, sanitizedBody, nil) + if err != nil { + return nil, fail(enums.Internal, messages.FailedCreateLetter) + } + + return &common.MessageResponse{Message: record.Ref}, nil +} + +func SendLetterMessage(ref string, userID uint, request letter.SendMessageRequest) (*letter.MessageResponse, *hypertext.ServiceError) { + record, serviceErr := resolveLetter(ref, userID) + if serviceErr != nil { + return nil, serviceErr + } + + if record.IsSystem { + return nil, fail(enums.BadRequest, messages.SystemLetterNotReplyable) + } + + sanitizedBody, serviceErr := sanitizeRequiredBody(request.Body) + if serviceErr != nil { + return nil, serviceErr + } + + message, err := repositories.SendMessage(record.ID, userID, sanitizedBody, nil) + if err != nil { + return nil, fail(enums.Internal, messages.FailedSendMessage) + } + + response := message.ToResponse() + return &response, nil +} + +func EditLetterMessage(letterRef string, messageRef string, userID uint, request letter.EditMessageRequest) (*letter.MessageResponse, *hypertext.ServiceError) { + record, serviceErr := resolveLetter(letterRef, userID) + if serviceErr != nil { + return nil, serviceErr + } + + message, err := repositories.FindMessageByRef(messageRef) + if err != nil || message.LetterID != record.ID { + return nil, fail(enums.NotFound, messages.MessageNotFound) + } + + if message.SenderID == nil || *message.SenderID != userID { + return nil, fail(enums.Forbidden, messages.CannotEditOthersMessage) + } + + sanitizedBody, serviceErr := sanitizeRequiredBody(request.Body) + if serviceErr != nil { + return nil, serviceErr + } + + if err := repositories.EditMessage(message, sanitizedBody); err != nil { + return nil, fail(enums.Internal, messages.FailedEditMessage) + } + + response := message.ToResponse() + return &response, nil +} + +func DeleteLetterMessage(letterRef string, messageRef string, userID uint) *hypertext.ServiceError { + record, serviceErr := resolveLetter(letterRef, userID) + if serviceErr != nil { + return serviceErr + } + + message, err := repositories.FindMessageByRef(messageRef) + if err != nil || message.LetterID != record.ID { + return fail(enums.NotFound, messages.MessageNotFound) + } + + if message.SenderID == nil || *message.SenderID != userID { + return fail(enums.Forbidden, messages.CannotDeleteOthersMessage) + } + + for _, attachment := range message.Attachments { + storage.Delete(attachment.FilePath) + } + + if err := repositories.DeleteMessage(message); err != nil { + return fail(enums.Internal, messages.FailedDeleteMessage) + } + + return nil +} + +func RenameLetter(ref string, userID uint, request letter.RenameRequest) (*common.MessageResponse, *hypertext.ServiceError) { + record, serviceErr := resolveLetter(ref, userID) + if serviceErr != nil { + return nil, serviceErr + } + + if record.IsSystem { + return nil, fail(enums.BadRequest, messages.SystemLetterNotRenameable) + } + + title := strings.TrimSpace(request.Title) + if len(title) > 200 { + return nil, fail(enums.BadRequest, messages.TitleTooLong) + } + + if err := repositories.RenameLetter(record, title); err != nil { + return nil, fail(enums.Internal, messages.FailedRenameLetter) + } + + return &common.MessageResponse{Message: messages.LetterRenamed}, nil +} + +func LeaveLetter(ref string, userID uint) (*common.MessageResponse, *hypertext.ServiceError) { + record, serviceErr := resolveLetter(ref, userID) + if serviceErr != nil { + return nil, serviceErr + } + + if record.IsSystem { + return nil, fail(enums.BadRequest, messages.SystemLetterNotLeaveable) + } + + if err := repositories.LeaveLetter(record.ID, userID); err != nil { + return nil, fail(enums.Internal, messages.FailedLeaveLetter) + } + + return &common.MessageResponse{Message: messages.LeftConversation}, nil +} + +func RemoveLetterParticipant(ref string, ownerID uint, request letter.RemoveParticipantRequest) (*common.MessageResponse, *hypertext.ServiceError) { + record, err := repositories.FindLetterByRef(ref) + if err != nil { + return nil, fail(enums.NotFound, messages.LetterNotFound) + } + + if !repositories.IsLetterOwner(record.ID, ownerID) { + return nil, fail(enums.Forbidden, messages.OnlyOwnerCanRemove) + } + + target, serviceErr := ResolveUser(request.Username) + if serviceErr != nil { + return nil, serviceErr + } + + if target.ID == ownerID { + return nil, fail(enums.BadRequest, messages.CannotRemoveSelf) + } + + if !repositories.IsLetterParticipant(record.ID, target.ID) { + return nil, fail(enums.BadRequest, messages.UserNotInConversation) + } + + if err := repositories.RemoveParticipant(record.ID, target.ID); err != nil { + return nil, fail(enums.Internal, messages.FailedRemoveParticipant) + } + + return &common.MessageResponse{Message: fmt.Sprintf("%s has been removed.", target.DisplayName)}, nil +} + +func UploadLetterAttachment(userID uint, fileName string, fileSize int64, contentType string, reader io.Reader) (*letter.AttachmentResponse, *hypertext.ServiceError) { + if fileSize > config.Storage.MaxFileSize { + return nil, fail(enums.BadRequest, fmt.Sprintf("File exceeds the maximum size of %d MB.", config.Storage.MaxFileSize/1024/1024)) + } + + attachment := models.LetterAttachment{ + FileName: fileName, + FileSize: fileSize, + ContentType: contentType, + } + + if err := repositories.UploadAttachment(userID, &attachment); err != nil { + return nil, fail(enums.Internal, messages.FailedSaveAttachment) + } + + path := fmt.Sprintf("letters/attachments/%s/%s", attachment.Ref, fileName) + if err := storage.Upload(path, reader, fileSize, contentType); err != nil { + return nil, fail(enums.Internal, messages.FailedUploadFile) + } + + attachment.FilePath = path + if err := repositories.UpdateAttachmentPath(&attachment); err != nil { + return nil, fail(enums.Internal, messages.FailedSaveAttachment) + } + + response := attachment.ToResponse() + return &response, nil +}
\ No newline at end of file diff --git a/shrine/services/stats.go b/shrine/services/stats.go new file mode 100644 index 0000000..1d6eb64 --- /dev/null +++ b/shrine/services/stats.go @@ -0,0 +1,28 @@ +package services + +import ( + "shrine/repositories" + "shrine/types/user" +) + +func GetStats() user.StatsResponse { + newest := repositories.NewestCitizens(5) + online := repositories.OnlineCitizens(10) + + newestSummaries := make([]user.CitizenSummaryResponse, len(newest)) + for index, citizen := range newest { + newestSummaries[index] = citizen.ToSummary() + } + + onlineSummaries := make([]user.CitizenSummaryResponse, len(online)) + for index, citizen := range online { + onlineSummaries[index] = citizen.ToSummary() + } + + return user.StatsResponse{ + Citizens: repositories.CountCitizens(), + Online: repositories.CountOnline(), + NewestCitizens: newestSummaries, + OnlineCitizens: onlineSummaries, + } +}
\ No newline at end of file diff --git a/shrine/services/ticket.go b/shrine/services/ticket.go new file mode 100644 index 0000000..eff662b --- /dev/null +++ b/shrine/services/ticket.go @@ -0,0 +1,296 @@ +package services + +import ( + "fmt" + "shrine/enums" + "shrine/messages" + "shrine/models" + "shrine/repositories" + "shrine/types/common" + "shrine/types/hypertext" + "shrine/types/ticket" + "shrine/utils/meta" + "strings" +) + +func ListUserTickets(userID uint, pagination meta.Pagination) ([]ticket.TicketResponse, int64) { + tickets, total := repositories.ListTicketsForUser(userID, pagination) + + items := make([]ticket.TicketResponse, len(tickets)) + for index, record := range tickets { + items[index] = record.ToResponse() + } + + return items, total +} + +func GetUserTicket(ref string, userID uint) (*ticket.DetailResponse, *hypertext.ServiceError) { + record, serviceErr := resolveTicket(ref) + if serviceErr != nil { + return nil, serviceErr + } + + if record.UserID != userID { + return nil, fail(enums.NotFound, messages.TicketNotFound) + } + + ticketMessages := repositories.GetTicketMessages(record.ID) + + response := ticket.DetailResponse{ + TicketResponse: record.ToResponse(), + Messages: buildTicketMessageResponses(ticketMessages), + } + + return &response, nil +} + +func CreateTicket(userID uint, request ticket.CreateRequest) (*common.MessageResponse, *hypertext.ServiceError) { + category, err := repositories.FindTicketCategoryByRef(request.CategoryRef) + if err != nil { + return nil, fail(enums.BadRequest, messages.InvalidCategory) + } + + subject := strings.TrimSpace(request.Subject) + if subject == "" || len(subject) > 200 { + return nil, fail(enums.BadRequest, messages.SubjectRequired) + } + + sanitizedBody, serviceErr := sanitizeRequiredBody(request.Body) + if serviceErr != nil { + return nil, serviceErr + } + + priority := enums.TicketPriority(request.Priority) + if priority == "" { + priority = enums.PriorityLow + } + + switch priority { + case enums.PriorityLow, enums.PriorityMedium, enums.PriorityHigh, enums.PriorityUrgent: + default: + return nil, fail(enums.BadRequest, messages.InvalidPriority) + } + + record := models.Ticket{ + UserID: userID, + CategoryID: category.ID, + Subject: subject, + Priority: string(priority), + } + + if err := repositories.CreateTicket(&record, sanitizedBody); err != nil { + return nil, fail(enums.Internal, messages.FailedCreateTicket) + } + + return &common.MessageResponse{Message: record.Ref}, nil +} + +func ReplyTicket(ref string, userID uint, request ticket.SendMessageRequest) (*ticket.MessageResponse, *hypertext.ServiceError) { + record, serviceErr := resolveTicket(ref) + if serviceErr != nil { + return nil, serviceErr + } + + if record.UserID != userID { + return nil, fail(enums.NotFound, messages.TicketNotFound) + } + + if record.Status == string(enums.StatusClosed) { + return nil, fail(enums.BadRequest, messages.TicketClosed) + } + + sanitizedBody, serviceErr := sanitizeRequiredBody(request.Body) + if serviceErr != nil { + return nil, serviceErr + } + + message := models.TicketMessage{ + TicketID: record.ID, + SenderID: userID, + Body: sanitizedBody, + } + + if err := repositories.CreateTicketMessage(&message); err != nil { + return nil, fail(enums.Internal, messages.FailedSendReply) + } + + response := message.ToResponse() + return &response, nil +} + +func ListAllTickets(pagination meta.Pagination, status string, priority string) ([]ticket.TicketResponse, int64) { + tickets, total := repositories.ListAllTickets(pagination, status, priority) + + items := make([]ticket.TicketResponse, len(tickets)) + for index, record := range tickets { + items[index] = record.ToResponse() + } + + return items, total +} + +func GetStaffTicket(ref string) (*ticket.DetailResponse, *hypertext.ServiceError) { + record, serviceErr := resolveTicket(ref) + if serviceErr != nil { + return nil, serviceErr + } + + ticketMessages := repositories.GetTicketMessages(record.ID) + + response := ticket.DetailResponse{ + TicketResponse: record.ToResponse(), + Messages: buildTicketMessageResponses(ticketMessages), + } + + return &response, nil +} + +func UpdateTicket(adminID uint, ref string, request ticket.UpdateRequest) (*ticket.TicketResponse, *hypertext.ServiceError) { + record, serviceErr := resolveTicket(ref) + if serviceErr != nil { + return nil, serviceErr + } + + if request.Status != nil { + status := enums.TicketStatus(*request.Status) + switch status { + case enums.StatusOpen, enums.StatusInProgress, enums.StatusResolved, enums.StatusClosed: + record.Status = *request.Status + default: + return nil, fail(enums.BadRequest, messages.InvalidStatus) + } + } + + if request.Priority != nil { + priority := enums.TicketPriority(*request.Priority) + switch priority { + case enums.PriorityLow, enums.PriorityMedium, enums.PriorityHigh, enums.PriorityUrgent: + record.Priority = *request.Priority + default: + return nil, fail(enums.BadRequest, messages.InvalidPriority) + } + } + + if request.CategoryRef != nil { + category, err := repositories.FindTicketCategoryByRef(*request.CategoryRef) + if err != nil { + return nil, fail(enums.BadRequest, messages.InvalidCategory) + } + record.CategoryID = category.ID + } + + if request.Assignee != nil { + if *request.Assignee == "" { + record.AssigneeID = nil + } else { + assignee, serviceErr := ResolveUser(*request.Assignee) + if serviceErr != nil { + return nil, fail(enums.BadRequest, messages.AssigneeNotFound) + } + record.AssigneeID = &assignee.ID + } + } + + if err := repositories.UpdateTicket(record); err != nil { + return nil, fail(enums.Internal, messages.FailedUpdateTicket) + } + + repositories.LogAction(adminID, "ticket.update", "ticket", record.Ref, fmt.Sprintf("Updated ticket %s", record.Ref), request) + + record, _ = repositories.FindTicketByRef(record.Ref) + response := record.ToResponse() + return &response, nil +} + +func StaffReplyTicket(ref string, staffID uint, request ticket.SendMessageRequest) (*ticket.MessageResponse, *hypertext.ServiceError) { + record, serviceErr := resolveTicket(ref) + if serviceErr != nil { + return nil, serviceErr + } + + sanitizedBody, serviceErr := sanitizeRequiredBody(request.Body) + if serviceErr != nil { + return nil, serviceErr + } + + message := models.TicketMessage{ + TicketID: record.ID, + SenderID: staffID, + Body: sanitizedBody, + IsStaff: true, + } + + if err := repositories.CreateTicketMessage(&message); err != nil { + return nil, fail(enums.Internal, messages.FailedSendReply) + } + + response := message.ToResponse() + return &response, nil +} + +func ListTicketCategories() []ticket.CategoryResponse { + categories := repositories.ListTicketCategories() + + items := make([]ticket.CategoryResponse, len(categories)) + for index, category := range categories { + items[index] = category.ToResponse() + } + + return items +} + +func CreateTicketCategory(request ticket.CreateCategoryRequest) (*ticket.CategoryResponse, *hypertext.ServiceError) { + name := strings.TrimSpace(request.Name) + if name == "" { + return nil, fail(enums.BadRequest, messages.CategoryNameRequired) + } + + category := models.TicketCategory{ + Name: name, + Description: strings.TrimSpace(request.Description), + } + + if err := repositories.CreateTicketCategory(&category); err != nil { + return nil, fail(enums.Internal, messages.FailedCreateCategory) + } + + response := category.ToResponse() + return &response, nil +} + +func UpdateTicketCategory(ref string, request ticket.UpdateCategoryRequest) (*ticket.CategoryResponse, *hypertext.ServiceError) { + category, err := repositories.FindTicketCategoryByRef(ref) + if err != nil { + return nil, fail(enums.NotFound, messages.CategoryNotFound) + } + + if request.Name != nil { + category.Name = strings.TrimSpace(*request.Name) + } + if request.Description != nil { + category.Description = strings.TrimSpace(*request.Description) + } + if request.SortOrder != nil { + category.SortOrder = *request.SortOrder + } + + if err := repositories.UpdateTicketCategory(category); err != nil { + return nil, fail(enums.Internal, messages.FailedUpdateCategory) + } + + response := category.ToResponse() + return &response, nil +} + +func DeleteTicketCategory(ref string) *hypertext.ServiceError { + category, err := repositories.FindTicketCategoryByRef(ref) + if err != nil { + return fail(enums.NotFound, messages.CategoryNotFound) + } + + if err := repositories.DeleteTicketCategory(category); err != nil { + return fail(enums.Internal, messages.FailedDeleteCategory) + } + + return nil +}
\ No newline at end of file diff --git a/shrine/services/verification.go b/shrine/services/verification.go new file mode 100644 index 0000000..a589fd4 --- /dev/null +++ b/shrine/services/verification.go @@ -0,0 +1,35 @@ +package services + +import ( + "shrine/enums" + "shrine/messages" + "shrine/models" + "shrine/repositories" + "shrine/types/hypertext" + "shrine/utils/crypto" + "shrine/utils/emails" + "shrine/utils/logger" + "time" +) + +func SendVerification(citizen *models.User, verificationType enums.VerificationType) *hypertext.ServiceError { + token, err := crypto.GenerateToken() + if err != nil { + return fail(enums.Internal, messages.FailedGenerateToken) + } + + citizen.SetVerification(crypto.HashToken(token), time.Now().Add(24*time.Hour), verificationType) + + if err := repositories.UpdateUser(citizen); err != nil { + return fail(enums.Internal, messages.FailedStoreToken) + } + + switch verificationType { + case enums.Activation: + if err := emails.SendActivation(citizen.Email, citizen.Username, token); err != nil { + logger.Errorf("Auth", "Failed to send activation email to %s: %v", citizen.Email, err) + } + } + + return nil +}
\ No newline at end of file diff --git a/shrine/services/warning.go b/shrine/services/warning.go new file mode 100644 index 0000000..34e38eb --- /dev/null +++ b/shrine/services/warning.go @@ -0,0 +1,84 @@ +package services + +import ( + "fmt" + "shrine/enums" + "shrine/messages" + "shrine/models" + "shrine/repositories" + "shrine/types/audit" + "shrine/types/hypertext" + "shrine/types/warning" + "shrine/utils/auth" + "shrine/utils/meta" + "shrine/utils/sanitize" + "strings" +) + +func WarnUser(admin *models.User, target *models.User, request warning.WarnRequest) (*warning.WarningResponse, *hypertext.ServiceError) { + if hierarchyErr := auth.ValidateHierarchy(admin, target, "warn"); hierarchyErr != nil { + return nil, fail(enums.Forbidden, hierarchyErr.Error()) + } + + title := strings.TrimSpace(request.Title) + if title == "" { + return nil, fail(enums.BadRequest, messages.WarningTitleRequired) + } + + sanitizedMessage := sanitize.HTML(request.Message) + if sanitizedMessage == "" { + return nil, fail(enums.BadRequest, messages.WarningMessageRequired) + } + + record, err := repositories.CreateWarning(admin.ID, target.ID, title, sanitizedMessage) + if err != nil { + return nil, fail(enums.Internal, messages.FailedCreateWarning) + } + + repositories.LogAction(admin.ID, "user.warn", "user", target.Username, fmt.Sprintf("Warned user @%s", target.Username), audit.WarningDetails{ + WarningRef: record.SystemRef, + Title: title, + Message: sanitizedMessage, + }) + + response := record.ToResponse() + return &response, nil +} + +func DeactivateWarning(admin *models.User, ref string) (*warning.WarningResponse, *hypertext.ServiceError) { + record, err := repositories.FindWarningByRef(ref) + if err != nil { + return nil, fail(enums.NotFound, messages.WarningNotFound) + } + + if !record.Active { + return nil, fail(enums.BadRequest, messages.WarningAlreadyInactive) + } + + if err := repositories.DeactivateWarning(record); err != nil { + return nil, fail(enums.Internal, messages.FailedDeactivateWarn) + } + + repositories.LogAction(admin.ID, "user.unwarn", "user", "", fmt.Sprintf("Deactivated warning %s", record.SystemRef), audit.DeactivateWarningDetails{ + WarningRef: record.SystemRef, + }) + + response := record.ToResponse() + return &response, nil +} + +func ListWarnings(username string, pagination meta.Pagination) ([]warning.WarningResponse, int64, *hypertext.ServiceError) { + citizen, serviceErr := ResolveUser(username) + if serviceErr != nil { + return nil, 0, serviceErr + } + + warnings, total := repositories.ListWarningsForUser(citizen.ID, pagination) + + items := make([]warning.WarningResponse, len(warnings)) + for index, record := range warnings { + items[index] = record.ToResponse() + } + + return items, total, nil +}
\ No newline at end of file diff --git a/shrine/templates/account_banned.html b/shrine/templates/account_banned.html new file mode 100644 index 0000000..52de852 --- /dev/null +++ b/shrine/templates/account_banned.html @@ -0,0 +1,8 @@ +{% extends "layouts/email.html" %} +{% block content %} +<h2 style="margin: 0 0 16px 0; font-size: 20px; color: #e8e8f0;">Your account has been banned</h2> +<p style="margin: 0 0 16px 0; line-height: 1.6; color: #c8c8d8;">Hello {{ username }}, your Pagoda account has been permanently banned.</p> +<p style="margin: 0 0 8px 0; font-size: 13px; color: #707088;">Reason:</p> +<p style="margin: 0 0 16px 0; line-height: 1.6; color: #c8c8d8;">{{ reason }}</p> +<p style="margin: 0 0 16px 0; line-height: 1.6; color: #c8c8d8;">This action is permanent. If you believe this was a mistake, please contact us via email.</p> +{% endblock %}
\ No newline at end of file diff --git a/shrine/templates/account_disabled.html b/shrine/templates/account_disabled.html new file mode 100644 index 0000000..b14d0e6 --- /dev/null +++ b/shrine/templates/account_disabled.html @@ -0,0 +1,11 @@ +{% extends "layouts/email.html" %} +{% block content %} +<h2 style="margin: 0 0 16px 0; font-size: 20px; color: #e8e8f0;">Your account has been disabled</h2> +<p style="margin: 0 0 16px 0; line-height: 1.6; color: #c8c8d8;">Hello {{ username }}, your Pagoda account has been temporarily disabled.</p> +<p style="margin: 0 0 8px 0; font-size: 13px; color: #707088;">Reason:</p> +<p style="margin: 0 0 16px 0; line-height: 1.6; color: #c8c8d8;">{{ reason }}</p> +{% if disabled_until %} +<p style="margin: 0 0 16px 0; line-height: 1.6; color: #c8c8d8;">Your account will be re-enabled on {{ disabled_until }}.</p> +{% endif %} +<p style="margin: 0 0 16px 0; line-height: 1.6; color: #c8c8d8;">A system notice has been sent to your inbox with more details. If you believe this was a mistake, you can open a support ticket after logging in.</p> +{% endblock %}
\ No newline at end of file diff --git a/shrine/types/request.go b/shrine/types/account/request.go index c32070d..08a8b0b 100644 --- a/shrine/types/request.go +++ b/shrine/types/account/request.go @@ -1,4 +1,4 @@ -package types +package account type RegisterRequest struct { Username string `json:"username"` @@ -19,16 +19,4 @@ type VerifyRequest struct { type ResendActivationRequest struct { Email string `json:"email"` -} - -type BanUserRequest struct { - Reason string `json:"reason"` -} - -type DisableUserRequest struct { - Reason string `json:"reason"` -} - -type ChangeRoleRequest struct { - Role string `json:"role"` }
\ No newline at end of file diff --git a/shrine/types/account/response.go b/shrine/types/account/response.go new file mode 100644 index 0000000..5efcf62 --- /dev/null +++ b/shrine/types/account/response.go @@ -0,0 +1,8 @@ +package account + +import "shrine/types/user" + +type AuthResponse struct { + Token string `json:"token"` + User user.UserResponse `json:"user"` +}
\ No newline at end of file diff --git a/shrine/types/audit/details.go b/shrine/types/audit/details.go new file mode 100644 index 0000000..2c15e04 --- /dev/null +++ b/shrine/types/audit/details.go @@ -0,0 +1,37 @@ +package audit + +type BanDetails struct { + Reason string `json:"reason"` + SystemRef string `json:"system_ref"` +} + +type DisableDetails struct { + Reason string `json:"reason"` + DisabledUntil *string `json:"disabled_until"` + SystemRef string `json:"system_ref"` +} + +type RoleChangeDetails struct { + OldRole string `json:"old_role"` + NewRole string `json:"new_role"` +} + +type WarningDetails struct { + WarningRef string `json:"warning_ref"` + Title string `json:"title"` + Message string `json:"message"` +} + +type DeactivateWarningDetails struct { + WarningRef string `json:"warning_ref"` +} + +type FieldChange struct { + Field string `json:"field"` + Old any `json:"old"` + New any `json:"new"` +} + +type EditUserDetails struct { + Changes []FieldChange `json:"changes"` +}
\ No newline at end of file diff --git a/shrine/types/audit/response.go b/shrine/types/audit/response.go new file mode 100644 index 0000000..3fa2e89 --- /dev/null +++ b/shrine/types/audit/response.go @@ -0,0 +1,18 @@ +package audit + +import "time" + +type AuditLogResponse struct { + SystemRef string `json:"system_ref"` + Actor string `json:"actor"` + Action string `json:"action"` + TargetType string `json:"target_type"` + TargetRef string `json:"target_ref"` + Summary string `json:"summary"` + CreatedAt time.Time `json:"created_at"` +} + +type DetailResponse struct { + AuditLogResponse + Details string `json:"details"` +}
\ No newline at end of file diff --git a/shrine/types/common/common.go b/shrine/types/common/common.go new file mode 100644 index 0000000..5eec682 --- /dev/null +++ b/shrine/types/common/common.go @@ -0,0 +1,17 @@ +package common + +type ErrorResponse struct { + Error string `json:"error"` +} + +type MessageResponse struct { + Message string `json:"message"` +} + +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"` +}
\ No newline at end of file diff --git a/shrine/types/council/request.go b/shrine/types/council/request.go new file mode 100644 index 0000000..2279afd --- /dev/null +++ b/shrine/types/council/request.go @@ -0,0 +1,32 @@ +package council + +type BanRequest struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +type DisableRequest struct { + Reason string `json:"reason"` + Message string `json:"message"` + DisabledUntil *string `json:"disabled_until"` +} + +type EditUserRequest struct { + Username *string `json:"username"` + DisplayName *string `json:"display_name"` + Email *string `json:"email"` + Bio *string `json:"bio"` + Birthday *string `json:"birthday"` + AvatarURL *string `json:"avatar_url"` + BlinkieURL *string `json:"blinkie_url"` + Website *string `json:"website"` + Location *string `json:"location"` + Pronouns *string `json:"pronouns"` + Signature *string `json:"signature"` + Jade *uint64 `json:"jade"` + Honor *uint64 `json:"honor"` +} + +type ChangeRoleRequest struct { + Role string `json:"role"` +}
\ No newline at end of file diff --git a/shrine/types/http.go b/shrine/types/http.go deleted file mode 100644 index ee4eb70..0000000 --- a/shrine/types/http.go +++ /dev/null @@ -1,29 +0,0 @@ -package types - -type HTTPMethod string - -const ( - GET HTTPMethod = "GET" - POST HTTPMethod = "POST" - PUT HTTPMethod = "PUT" - PATCH HTTPMethod = "PATCH" - DELETE HTTPMethod = "DELETE" - OPTIONS HTTPMethod = "OPTIONS" - HEAD HTTPMethod = "HEAD" -) - -type HTTPParam struct { - Key string - Value string -} - -type Request struct { - Path string - Method string - Query []HTTPParam - Params []HTTPParam - Headers []HTTPParam - QueryString string - IP string - URL string -} diff --git a/shrine/types/hypertext/errors.go b/shrine/types/hypertext/errors.go new file mode 100644 index 0000000..f014252 --- /dev/null +++ b/shrine/types/hypertext/errors.go @@ -0,0 +1,10 @@ +package hypertext + +import "shrine/enums" + +type ServiceError struct { + Kind enums.ErrorKind + Message string +} + +func (e *ServiceError) Error() string { return e.Message }
\ No newline at end of file diff --git a/shrine/types/hypertext/request.go b/shrine/types/hypertext/request.go new file mode 100644 index 0000000..8a46071 --- /dev/null +++ b/shrine/types/hypertext/request.go @@ -0,0 +1,17 @@ +package hypertext + +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 +}
\ No newline at end of file diff --git a/shrine/types/letter/request.go b/shrine/types/letter/request.go new file mode 100644 index 0000000..2687352 --- /dev/null +++ b/shrine/types/letter/request.go @@ -0,0 +1,23 @@ +package letter + +type CreateRequest struct { + Recipients []string `json:"recipients"` + Title string `json:"title"` + Body string `json:"body"` +} + +type SendMessageRequest struct { + Body string `json:"body"` +} + +type EditMessageRequest struct { + Body string `json:"body"` +} + +type RenameRequest struct { + Title string `json:"title"` +} + +type RemoveParticipantRequest struct { + Username string `json:"username"` +}
\ No newline at end of file diff --git a/shrine/types/letter/response.go b/shrine/types/letter/response.go new file mode 100644 index 0000000..6cbd6ed --- /dev/null +++ b/shrine/types/letter/response.go @@ -0,0 +1,52 @@ +package letter + +import ( + "shrine/types/user" + "time" +) + +type ParticipantResponse struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + Role string `json:"role"` +} + +type AttachmentResponse struct { + Ref string `json:"ref"` + FileName string `json:"file_name"` + URL string `json:"url"` + FileSize int64 `json:"file_size"` + ContentType string `json:"content_type"` +} + +type MessageResponse struct { + Ref string `json:"ref"` + Sender *user.CitizenSummaryResponse `json:"sender"` + Body string `json:"body"` + Attachments []AttachmentResponse `json:"attachments"` + EditedAt *time.Time `json:"edited_at"` + CreatedAt time.Time `json:"created_at"` + Deleted bool `json:"deleted"` +} + +type LetterResponse struct { + Ref string `json:"ref"` + Title string `json:"title"` + IsSystem bool `json:"is_system"` + SystemRef string `json:"system_ref,omitempty"` + Participants []ParticipantResponse `json:"participants"` + LastMessage *MessageResponse `json:"last_message,omitempty"` + Unread bool `json:"unread"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DetailResponse struct { + Ref string `json:"ref"` + Title string `json:"title"` + IsSystem bool `json:"is_system"` + SystemRef string `json:"system_ref,omitempty"` + Participants []ParticipantResponse `json:"participants"` + Messages []MessageResponse `json:"messages"` + CreatedAt time.Time `json:"created_at"` +}
\ No newline at end of file diff --git a/shrine/types/ticket/request.go b/shrine/types/ticket/request.go new file mode 100644 index 0000000..f19ea2b --- /dev/null +++ b/shrine/types/ticket/request.go @@ -0,0 +1,30 @@ +package ticket + +type CreateRequest struct { + CategoryRef string `json:"category_ref"` + Subject string `json:"subject"` + Body string `json:"body"` + Priority string `json:"priority"` +} + +type UpdateRequest struct { + Status *string `json:"status"` + Priority *string `json:"priority"` + CategoryRef *string `json:"category_ref"` + Assignee *string `json:"assignee"` +} + +type CreateCategoryRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type UpdateCategoryRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + SortOrder *uint `json:"sort_order"` +} + +type SendMessageRequest struct { + Body string `json:"body"` +}
\ No newline at end of file diff --git a/shrine/types/ticket/response.go b/shrine/types/ticket/response.go new file mode 100644 index 0000000..c76c352 --- /dev/null +++ b/shrine/types/ticket/response.go @@ -0,0 +1,38 @@ +package ticket + +import ( + "shrine/types/user" + "time" +) + +type CategoryResponse struct { + Ref string `json:"ref"` + Name string `json:"name"` + Description string `json:"description"` + SortOrder uint `json:"sort_order"` +} + +type MessageResponse struct { + Ref string `json:"ref"` + Sender user.CitizenSummaryResponse `json:"sender"` + Body string `json:"body"` + IsStaff bool `json:"is_staff"` + CreatedAt time.Time `json:"created_at"` +} + +type TicketResponse struct { + Ref string `json:"ref"` + Subject string `json:"subject"` + Category CategoryResponse `json:"category"` + Priority string `json:"priority"` + Status string `json:"status"` + User user.CitizenSummaryResponse `json:"user"` + Assignee *user.CitizenSummaryResponse `json:"assignee"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DetailResponse struct { + TicketResponse + Messages []MessageResponse `json:"messages"` +}
\ No newline at end of file diff --git a/shrine/types/response.go b/shrine/types/user/user.go index 297a34b..4126bfc 100644 --- a/shrine/types/response.go +++ b/shrine/types/user/user.go @@ -1,13 +1,11 @@ -package types +package user import "time" -type ErrorResponse struct { - Error string `json:"error"` -} - -type MessageResponse struct { - Message string `json:"message"` +type CitizenSummaryResponse struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` } type UserResponse struct { @@ -26,45 +24,26 @@ type UserResponse struct { CreatedAt time.Time `json:"created_at"` } -type AuthResponse struct { - Token string `json:"token"` - User UserResponse `json:"user"` -} - -type CitizenSummary struct { - Username string `json:"username"` - DisplayName string `json:"display_name"` - AvatarURL string `json:"avatar_url"` -} - -type StatsResponse struct { - Citizens int64 `json:"citizens"` - Online int64 `json:"online"` - NewestCitizens []CitizenSummary `json:"newest_citizens"` - OnlineCitizens []CitizenSummary `json:"online_citizens"` -} - type AdminUserResponse struct { - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - AvatarURL string `json:"avatar_url"` - Role string `json:"role"` + UserResponse + Jade uint64 `json:"jade"` + Honor uint64 `json:"honor"` EmailVerified bool `json:"email_verified"` + WarningCount uint `json:"warning_count"` AccountBanned bool `json:"account_banned"` BannedReason string `json:"banned_reason"` BannedAt *time.Time `json:"banned_at"` AccountDisabled bool `json:"account_disabled"` DisabledReason string `json:"disabled_reason"` DisabledAt *time.Time `json:"disabled_at"` + DisabledUntil *time.Time `json:"disabled_until"` LastSeenAt *time.Time `json:"last_seen_at"` - CreatedAt time.Time `json:"created_at"` + RegistrationIP string `json:"registration_ip"` } -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"` -} +type StatsResponse struct { + Citizens int64 `json:"citizens"` + Online int64 `json:"online"` + NewestCitizens []CitizenSummaryResponse `json:"newest_citizens"` + OnlineCitizens []CitizenSummaryResponse `json:"online_citizens"` +}
\ No newline at end of file diff --git a/shrine/types/warning/request.go b/shrine/types/warning/request.go new file mode 100644 index 0000000..c127dee --- /dev/null +++ b/shrine/types/warning/request.go @@ -0,0 +1,6 @@ +package warning + +type WarnRequest struct { + Title string `json:"title"` + Message string `json:"message"` +}
\ No newline at end of file diff --git a/shrine/types/warning/response.go b/shrine/types/warning/response.go new file mode 100644 index 0000000..fb021ba --- /dev/null +++ b/shrine/types/warning/response.go @@ -0,0 +1,11 @@ +package warning + +import "time" + +type WarningResponse struct { + SystemRef string `json:"system_ref"` + Admin string `json:"admin"` + Message string `json:"message"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` +}
\ No newline at end of file diff --git a/shrine/utils/auth/auth.go b/shrine/utils/auth/auth.go index 88bce99..44db75a 100644 --- a/shrine/utils/auth/auth.go +++ b/shrine/utils/auth/auth.go @@ -1,13 +1,10 @@ package auth import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/hex" "shrine/config" "shrine/models" "shrine/repositories" + "shrine/utils/crypto" "shrine/utils/meta" "strings" "time" @@ -20,21 +17,6 @@ const ( tokenHashKey = "__auth_token_hash" ) -func GenerateToken() (string, error) { - bytes := make([]byte, 32) - _, err := rand.Read(bytes) - if err != nil { - return "", err - } - return hex.EncodeToString(bytes), nil -} - -func HashToken(rawToken string) string { - mac := hmac.New(sha256.New, []byte(config.Server.Secret)) - mac.Write([]byte(rawToken)) - return hex.EncodeToString(mac.Sum(nil)) -} - func IsAuthenticated(context *fiber.Ctx) bool { header, ok := meta.Request(context).Header("Authorization") if !ok || !strings.HasPrefix(header, "Bearer ") { @@ -42,7 +24,7 @@ func IsAuthenticated(context *fiber.Ctx) bool { } rawToken := strings.TrimPrefix(header, "Bearer ") - tokenHash := HashToken(rawToken) + tokenHash := crypto.HashToken(rawToken) token, err := repositories.FindValidToken(tokenHash) if err != nil { @@ -108,7 +90,7 @@ func GetTokenHash(context *fiber.Ctx) string { } func IssueToken(context *fiber.Ctx, userID uint) (string, error) { - token, err := GenerateToken() + token, err := crypto.GenerateToken() if err != nil { return "", err } @@ -117,7 +99,7 @@ func IssueToken(context *fiber.Ctx, userID uint) (string, error) { userAgent, _ := request.Header("User-Agent") record := models.Token{ - TokenHash: HashToken(token), + TokenHash: crypto.HashToken(token), UserID: userID, ExpiresAt: time.Now().Add(config.Server.TokenExpiry), IPAddress: request.IP, diff --git a/shrine/utils/auth/hierarchy.go b/shrine/utils/auth/hierarchy.go new file mode 100644 index 0000000..2fa6583 --- /dev/null +++ b/shrine/utils/auth/hierarchy.go @@ -0,0 +1,22 @@ +package auth + +import ( + "fmt" + "shrine/models" +) + +func ValidateHierarchy(admin *models.User, target *models.User, action string) error { + if target.ID == admin.ID { + return fmt.Errorf("You cannot %s yourself.", action) + } + + if target.IsOwner() { + return fmt.Errorf("You cannot %s the owner.", action) + } + + if target.IsAdmin() && !admin.IsOwner() { + return fmt.Errorf("Only the owner can %s an administrator.", action) + } + + return nil +}
\ No newline at end of file diff --git a/shrine/utils/collections/set.go b/shrine/utils/collections/set.go index 69e9de1..fb6b1eb 100644 --- a/shrine/utils/collections/set.go +++ b/shrine/utils/collections/set.go @@ -14,15 +14,3 @@ func (set Set[T]) Has(value T) bool { _, exists := set[value] return exists } - -func (set Set[T]) Add(value T) { - set[value] = struct{}{} -} - -func (set Set[T]) Remove(value T) { - delete(set, value) -} - -func (set Set[T]) Len() int { - return len(set) -}
\ No newline at end of file diff --git a/shrine/utils/crypto/refs.go b/shrine/utils/crypto/refs.go new file mode 100644 index 0000000..e038004 --- /dev/null +++ b/shrine/utils/crypto/refs.go @@ -0,0 +1,25 @@ +package crypto + +import ( + "crypto/rand" + "time" +) + +const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + +func randomString(length int) string { + bytes := make([]byte, length) + rand.Read(bytes) + for i := range bytes { + bytes[i] = alphabet[bytes[i]%byte(len(alphabet))] + } + return string(bytes) +} + +func SystemRef() string { + return time.Now().Format("020106") + randomString(8) +} + +func Ref() string { + return randomString(12) +}
\ No newline at end of file diff --git a/shrine/utils/crypto/token.go b/shrine/utils/crypto/token.go new file mode 100644 index 0000000..e249d38 --- /dev/null +++ b/shrine/utils/crypto/token.go @@ -0,0 +1,24 @@ +package crypto + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "shrine/config" +) + +func GenerateToken() (string, error) { + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func HashToken(rawToken string) string { + mac := hmac.New(sha256.New, []byte(config.Server.Secret)) + mac.Write([]byte(rawToken)) + return hex.EncodeToString(mac.Sum(nil)) +}
\ No newline at end of file diff --git a/shrine/utils/emails/emails.go b/shrine/utils/emails/emails.go index 7dae0bb..7b11327 100644 --- a/shrine/utils/emails/emails.go +++ b/shrine/utils/emails/emails.go @@ -9,7 +9,11 @@ import ( "github.com/flosch/pongo2/v6" ) -var activationTemplate *pongo2.Template +var ( + activationTemplate *pongo2.Template + disabledTemplate *pongo2.Template + bannedTemplate *pongo2.Template +) func init() { var err error @@ -17,6 +21,14 @@ func init() { if err != nil { panic(fmt.Sprintf("failed to load activation template: %v", err)) } + disabledTemplate, err = pongo2.FromFile("templates/account_disabled.html") + if err != nil { + panic(fmt.Sprintf("failed to load disabled template: %v", err)) + } + bannedTemplate, err = pongo2.FromFile("templates/account_banned.html") + if err != nil { + panic(fmt.Sprintf("failed to load banned template: %v", err)) + } } func Send(to string, subject string, html string) error { @@ -49,4 +61,29 @@ func SendActivation(to string, username string, token string) error { } return Send(to, "Verify your Pagoda account", html) +} + +func SendDisabledNotification(to string, username string, reason string, disabledUntil string) error { + html, err := disabledTemplate.Execute(pongo2.Context{ + "username": username, + "reason": reason, + "disabled_until": disabledUntil, + }) + if err != nil { + return err + } + + return Send(to, "Your Pagoda account has been disabled", html) +} + +func SendBannedNotification(to string, username string, reason string) error { + html, err := bannedTemplate.Execute(pongo2.Context{ + "username": username, + "reason": reason, + }) + if err != nil { + return err + } + + return Send(to, "Your Pagoda account has been banned", html) }
\ No newline at end of file diff --git a/shrine/utils/meta/builder.go b/shrine/utils/meta/builder.go index 96bf61a..00c2050 100644 --- a/shrine/utils/meta/builder.go +++ b/shrine/utils/meta/builder.go @@ -1,13 +1,13 @@ package meta import ( - "shrine/types" + "shrine/types/hypertext" "github.com/gofiber/fiber/v2" ) -func BuildRequest(context *fiber.Ctx) types.Request { - return types.Request{ +func BuildRequest(context *fiber.Ctx) hypertext.Request { + return hypertext.Request{ Path: context.Path(), Method: context.Method(), Query: buildQueryParams(context), diff --git a/shrine/utils/meta/functions.go b/shrine/utils/meta/functions.go index e36e296..d89143e 100644 --- a/shrine/utils/meta/functions.go +++ b/shrine/utils/meta/functions.go @@ -1,15 +1,15 @@ package meta import ( - "shrine/types" + "shrine/types/hypertext" "github.com/gofiber/fiber/v2" ) -func buildQueryParams(context *fiber.Ctx) []types.HTTPParam { - params := make([]types.HTTPParam, 0) +func buildQueryParams(context *fiber.Ctx) []hypertext.Param { + params := make([]hypertext.Param, 0) context.Request().URI().QueryArgs().VisitAll(func(k, v []byte) { - params = append(params, types.HTTPParam{ + params = append(params, hypertext.Param{ Key: string(k), Value: string(v), }) @@ -17,10 +17,10 @@ func buildQueryParams(context *fiber.Ctx) []types.HTTPParam { return params } -func buildRouteParams(context *fiber.Ctx) []types.HTTPParam { - params := make([]types.HTTPParam, 0) +func buildRouteParams(context *fiber.Ctx) []hypertext.Param { + params := make([]hypertext.Param, 0) for k, v := range context.AllParams() { - params = append(params, types.HTTPParam{ + params = append(params, hypertext.Param{ Key: k, Value: v, }) @@ -28,10 +28,10 @@ func buildRouteParams(context *fiber.Ctx) []types.HTTPParam { return params } -func buildHeaders(context *fiber.Ctx) []types.HTTPParam { - params := make([]types.HTTPParam, 0) +func buildHeaders(context *fiber.Ctx) []hypertext.Param { + params := make([]hypertext.Param, 0) context.Request().Header.VisitAll(func(k, v []byte) { - params = append(params, types.HTTPParam{ + params = append(params, hypertext.Param{ Key: string(k), Value: string(v), }) diff --git a/shrine/utils/meta/pagination.go b/shrine/utils/meta/pagination.go index 27f42a7..8b559bf 100644 --- a/shrine/utils/meta/pagination.go +++ b/shrine/utils/meta/pagination.go @@ -1,7 +1,7 @@ package meta import ( - "shrine/types" + "shrine/types/common" "strconv" "github.com/gofiber/fiber/v2" @@ -35,13 +35,13 @@ func (p Pagination) Apply(query *gorm.DB) *gorm.DB { return query.Offset((p.Page - 1) * p.PerPage).Limit(p.PerPage) } -func (p Pagination) Response(items any, total int64) types.PaginatedResponse { +func (p Pagination) Response(items any, total int64) common.PaginatedResponse { totalPages := int(total) / p.PerPage if int(total)%p.PerPage > 0 { totalPages++ } - return types.PaginatedResponse{ + return common.PaginatedResponse{ Items: items, Total: total, Page: p.Page, diff --git a/shrine/utils/meta/request.go b/shrine/utils/meta/request.go index 25d32a4..b759a7c 100644 --- a/shrine/utils/meta/request.go +++ b/shrine/utils/meta/request.go @@ -1,7 +1,7 @@ package meta import ( - "shrine/types" + "shrine/types/hypertext" "shrine/utils/logger" "github.com/gofiber/fiber/v2" @@ -10,7 +10,7 @@ import ( const RequestKey = "__request_context" func Request(context *fiber.Ctx) facade { - request, ok := context.Locals(RequestKey).(types.Request) + request, ok := context.Locals(RequestKey).(hypertext.Request) if !ok { logger.Errorf("META", "RequestContext missing in fiber locals") return facade{} diff --git a/shrine/utils/meta/types.go b/shrine/utils/meta/types.go index 81b3579..eb4c2cd 100644 --- a/shrine/utils/meta/types.go +++ b/shrine/utils/meta/types.go @@ -1,23 +1,23 @@ package meta import ( - "shrine/types" + "shrine/types/hypertext" "github.com/gofiber/fiber/v2" ) type facade struct { - types.Request + hypertext.Request context *fiber.Ctx } type required struct { - request types.Request + request hypertext.Request context *fiber.Ctx } type withDefault struct { - request types.Request + request hypertext.Request context *fiber.Ctx defaults string } diff --git a/shrine/utils/sanitize/sanitize.go b/shrine/utils/sanitize/sanitize.go new file mode 100644 index 0000000..5335a54 --- /dev/null +++ b/shrine/utils/sanitize/sanitize.go @@ -0,0 +1,13 @@ +package sanitize + +import ( + "strings" + + "github.com/microcosm-cc/bluemonday" +) + +var policy = bluemonday.UGCPolicy() + +func HTML(input string) string { + return policy.Sanitize(strings.TrimSpace(input)) +}
\ No newline at end of file diff --git a/shrine/utils/shortcuts/response.go b/shrine/utils/shortcuts/response.go index 70332ab..a8653fb 100644 --- a/shrine/utils/shortcuts/response.go +++ b/shrine/utils/shortcuts/response.go @@ -1,19 +1,89 @@ package shortcuts -import "github.com/gofiber/fiber/v2" +import ( + "shrine/enums" + "shrine/types/common" + "shrine/types/hypertext" -func Response(ctx *fiber.Ctx, data any) *response { - return &response{ - ctx: ctx, - data: data, - status: fiber.StatusOK, + "github.com/gofiber/fiber/v2" +) + +func Respond(context *fiber.Ctx, data any) *Response { + return &Response{ + Context: context, + Data: data, + Status: fiber.StatusOK, + } +} + +func (self *Response) As(status int) error { + self.Status = status + if self.Data == nil { + return self.Context.SendStatus(status) } + return self.Context.Status(status).JSON(self.Data) } -func (r *response) As(status int) error { - r.status = status - if r.data == nil { - return r.ctx.SendStatus(status) +func HandleError(context *fiber.Ctx, err *hypertext.ServiceError) error { + statusMap := map[enums.ErrorKind]int{ + enums.BadRequest: fiber.StatusBadRequest, + enums.Unauthorized: fiber.StatusUnauthorized, + enums.Forbidden: fiber.StatusForbidden, + enums.NotFound: fiber.StatusNotFound, + enums.Conflict: fiber.StatusConflict, + enums.Unprocessable: fiber.StatusUnprocessableEntity, + enums.TooManyRequest: fiber.StatusTooManyRequests, + enums.Internal: fiber.StatusInternalServerError, + } + + status, exists := statusMap[err.Kind] + if !exists { + status = fiber.StatusInternalServerError } - return r.ctx.Status(status).JSON(r.data) + + return Respond(context, common.ErrorResponse{ + Error: err.Message, + }).As(status) +} + +func BadRequest(context *fiber.Ctx, err error) error { + return Respond(context, common.ErrorResponse{ + Error: err.Error(), + }).As(fiber.StatusBadRequest) +} + +func Unauthorized(context *fiber.Ctx, err error) error { + return Respond(context, common.ErrorResponse{ + Error: err.Error(), + }).As(fiber.StatusUnauthorized) } + +func Forbidden(context *fiber.Ctx, err error) error { + return Respond(context, common.ErrorResponse{ + Error: err.Error(), + }).As(fiber.StatusForbidden) +} + +func NotFound(context *fiber.Ctx, err error) error { + return Respond(context, common.ErrorResponse{ + Error: err.Error(), + }).As(fiber.StatusNotFound) +} + +func InternalServerError(context *fiber.Ctx, err error) error { + return Respond(context, common.ErrorResponse{ + Error: err.Error(), + }).As(fiber.StatusInternalServerError) +} + +func Success(context *fiber.Ctx, data any) error { + return Respond(context, data).As(fiber.StatusOK) +} + +func Created(context *fiber.Ctx, data any) error { + return Respond(context, data).As(fiber.StatusCreated) +} + +func NoContent(context *fiber.Ctx) error { + return Respond(context, nil).As(fiber.StatusNoContent) +}
\ No newline at end of file diff --git a/shrine/utils/shortcuts/types.go b/shrine/utils/shortcuts/types.go index 122ad13..5d521d8 100644 --- a/shrine/utils/shortcuts/types.go +++ b/shrine/utils/shortcuts/types.go @@ -2,8 +2,8 @@ package shortcuts import "github.com/gofiber/fiber/v2" -type response struct { - ctx *fiber.Ctx - data any - status int -} +type Response struct { + Context *fiber.Ctx + Data any + Status int +}
\ No newline at end of file diff --git a/shrine/utils/urls/attach.go b/shrine/utils/urls/attach.go index e9b0a66..959fb4d 100644 --- a/shrine/utils/urls/attach.go +++ b/shrine/utils/urls/attach.go @@ -1,20 +1,20 @@ package urls import ( - "shrine/types" + "shrine/enums" "shrine/utils/logger" "github.com/gofiber/fiber/v2" ) -var methodBinders = map[types.HTTPMethod]func(fiber.Router, string, fiber.Handler) fiber.Router{ - types.GET: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Get(path, h) }, - types.POST: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Post(path, h) }, - types.PUT: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Put(path, h) }, - types.PATCH: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Patch(path, h) }, - types.DELETE: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Delete(path, h) }, - types.OPTIONS: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Options(path, h) }, - types.HEAD: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Head(path, h) }, +var methodBinders = map[enums.HTTPMethod]func(fiber.Router, string, fiber.Handler) fiber.Router{ + enums.GET: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Get(path, h) }, + enums.POST: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Post(path, h) }, + enums.PUT: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Put(path, h) }, + enums.PATCH: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Patch(path, h) }, + enums.DELETE: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Delete(path, h) }, + enums.OPTIONS: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Options(path, h) }, + enums.HEAD: func(r fiber.Router, path string, h fiber.Handler) fiber.Router { return r.Head(path, h) }, } func Attach(app *fiber.App) { diff --git a/shrine/utils/urls/path.go b/shrine/utils/urls/path.go index fe5219d..3a552ae 100644 --- a/shrine/utils/urls/path.go +++ b/shrine/utils/urls/path.go @@ -1,13 +1,13 @@ package urls import ( - "shrine/types" + "shrine/enums" "strings" "github.com/gofiber/fiber/v2" ) -func Path(method types.HTTPMethod, path string, handler fiber.Handler, name string) { +func Path(method enums.HTTPMethod, path string, handler fiber.Handler, name string) { registry.mutex.Lock() defer registry.mutex.Unlock() @@ -38,14 +38,3 @@ func Path(method types.HTTPMethod, path string, handler fiber.Handler, name stri } } -func GetFullPath(routeName string) (string, bool) { - registry.mutex.Lock() - defer registry.mutex.Unlock() - - route, ok := registry.routes[routeName] - if !ok { - return "", false - } - - return route.fullPath, true -} diff --git a/shrine/utils/urls/types.go b/shrine/utils/urls/types.go index 0913ff9..515f5ef 100644 --- a/shrine/utils/urls/types.go +++ b/shrine/utils/urls/types.go @@ -1,14 +1,14 @@ package urls import ( - "shrine/types" + "shrine/enums" "sync" "github.com/gofiber/fiber/v2" ) type registeredRoute struct { - method types.HTTPMethod + method enums.HTTPMethod path string handler fiber.Handler namespace string |
