summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--shrine/config/env.go14
-rw-r--r--shrine/controllers/audit.go29
-rw-r--r--shrine/controllers/auth.go168
-rw-r--r--shrine/controllers/council.go189
-rw-r--r--shrine/controllers/letter.go165
-rw-r--r--shrine/controllers/responses.go56
-rw-r--r--shrine/controllers/stats.go24
-rw-r--r--shrine/controllers/ticket.go163
-rw-r--r--shrine/controllers/warning.go55
-rw-r--r--shrine/database/migrate.go9
-rw-r--r--shrine/enums/error.go14
-rw-r--r--shrine/enums/http.go13
-rw-r--r--shrine/enums/ticket.go19
-rw-r--r--shrine/go.mod3
-rw-r--r--shrine/go.sum6
-rw-r--r--shrine/messages/auth.go24
-rw-r--r--shrine/messages/common.go5
-rw-r--r--shrine/messages/council.go26
-rw-r--r--shrine/messages/letter.go32
-rw-r--r--shrine/messages/ticket.go19
-rw-r--r--shrine/messages/warning.go13
-rw-r--r--shrine/models/audit.go38
-rw-r--r--shrine/models/letter.go125
-rw-r--r--shrine/models/ticket.go101
-rw-r--r--shrine/models/token.go4
-rw-r--r--shrine/models/user.go225
-rw-r--r--shrine/models/warning.go30
-rw-r--r--shrine/repositories/audit.go52
-rw-r--r--shrine/repositories/letter.go263
-rw-r--r--shrine/repositories/ticket.go92
-rw-r--r--shrine/repositories/warning.go93
-rw-r--r--shrine/router/auth.go16
-rw-r--r--shrine/router/council.go33
-rw-r--r--shrine/router/letter.go23
-rw-r--r--shrine/router/router.go20
-rw-r--r--shrine/router/stats.go4
-rw-r--r--shrine/router/ticket.go17
-rw-r--r--shrine/services/audit.go31
-rw-r--r--shrine/services/auth.go108
-rw-r--r--shrine/services/council.go297
-rw-r--r--shrine/services/errors.go10
-rw-r--r--shrine/services/functions.go141
-rw-r--r--shrine/services/letter.go277
-rw-r--r--shrine/services/stats.go28
-rw-r--r--shrine/services/ticket.go296
-rw-r--r--shrine/services/verification.go35
-rw-r--r--shrine/services/warning.go84
-rw-r--r--shrine/templates/account_banned.html8
-rw-r--r--shrine/templates/account_disabled.html11
-rw-r--r--shrine/types/account/request.go (renamed from shrine/types/request.go)14
-rw-r--r--shrine/types/account/response.go8
-rw-r--r--shrine/types/audit/details.go37
-rw-r--r--shrine/types/audit/response.go18
-rw-r--r--shrine/types/common/common.go17
-rw-r--r--shrine/types/council/request.go32
-rw-r--r--shrine/types/http.go29
-rw-r--r--shrine/types/hypertext/errors.go10
-rw-r--r--shrine/types/hypertext/request.go17
-rw-r--r--shrine/types/letter/request.go23
-rw-r--r--shrine/types/letter/response.go52
-rw-r--r--shrine/types/ticket/request.go30
-rw-r--r--shrine/types/ticket/response.go38
-rw-r--r--shrine/types/user/user.go (renamed from shrine/types/response.go)55
-rw-r--r--shrine/types/warning/request.go6
-rw-r--r--shrine/types/warning/response.go11
-rw-r--r--shrine/utils/auth/auth.go26
-rw-r--r--shrine/utils/auth/hierarchy.go22
-rw-r--r--shrine/utils/collections/set.go12
-rw-r--r--shrine/utils/crypto/refs.go25
-rw-r--r--shrine/utils/crypto/token.go24
-rw-r--r--shrine/utils/emails/emails.go39
-rw-r--r--shrine/utils/meta/builder.go6
-rw-r--r--shrine/utils/meta/functions.go20
-rw-r--r--shrine/utils/meta/pagination.go6
-rw-r--r--shrine/utils/meta/request.go4
-rw-r--r--shrine/utils/meta/types.go8
-rw-r--r--shrine/utils/sanitize/sanitize.go13
-rw-r--r--shrine/utils/shortcuts/response.go92
-rw-r--r--shrine/utils/shortcuts/types.go10
-rw-r--r--shrine/utils/urls/attach.go18
-rw-r--r--shrine/utils/urls/path.go15
-rw-r--r--shrine/utils/urls/types.go4
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