Initial commit

This commit is contained in:
2025-05-11 20:32:14 +08:00
commit d97247159f
80 changed files with 13013 additions and 0 deletions

254
server/api/file.go Normal file
View File

@@ -0,0 +1,254 @@
package api
import (
"fmt"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/log"
"mime/multipart"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
)
func AddFileRoutes(router fiber.Router) {
fileGroup := router.Group("/files")
{
fileGroup.Post("/upload/init", initUpload)
fileGroup.Post("/upload/block/:id/:index", uploadBlock)
fileGroup.Post("/upload/finish/:id", finishUpload)
fileGroup.Post("/redirect", createRedirectFile)
fileGroup.Get("/:id", getFile)
fileGroup.Put("/:id", updateFile)
fileGroup.Delete("/:id", deleteFile)
}
}
// initUpload 初始化文件上传过程
func initUpload(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
type InitUploadRequest struct {
Filename string `json:"filename"`
Description string `json:"description"`
FileSize int64 `json:"file_size"`
ResourceID uint `json:"resource_id"`
StorageID uint `json:"storage_id"`
}
var req InitUploadRequest
if err := c.Bind().Body(&req); err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的请求参数",
})
}
result, err := service.CreateUploadingFile(uid, req.Filename, req.Description, req.FileSize, req.ResourceID, req.StorageID)
if err != nil {
return err
}
return c.JSON(model.Response[*model.UploadingFileView]{
Success: true,
Data: result,
})
}
// uploadBlock 上传文件块
func uploadBlock(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件ID",
})
}
index, err := strconv.Atoi(c.Params("index"))
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的块索引",
})
}
file, err := c.Request().MultipartForm()
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件数据",
})
}
if len(file.File["block"]) == 0 {
return c.JSON(model.Response[any]{
Success: false,
Message: "没有找到文件块",
})
}
fileHeader := file.File["block"][0]
fileContent, err := fileHeader.Open()
if err != nil {
log.Error("打开文件块失败: ", err)
return c.JSON(model.Response[any]{
Success: false,
Message: "打开文件块失败",
})
}
defer func(fileContent multipart.File) {
_ = fileContent.Close()
}(fileContent)
data := make([]byte, fileHeader.Size)
if _, err := fileContent.Read(data); err != nil {
log.Error("读取文件块失败: ", err)
return c.JSON(model.Response[any]{
Success: false,
Message: "读取文件块失败",
})
}
if err := service.UploadBlock(uid, uint(id), index, data); err != nil {
return err
}
return c.JSON(model.Response[any]{
Success: true,
Message: fmt.Sprintf("块 %d 上传成功", index),
})
}
// finishUpload 完成文件上传
func finishUpload(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件ID",
})
}
result, err := service.FinishUploadingFile(uid, uint(id))
if err != nil {
return err
}
return c.JSON(model.Response[*model.FileView]{
Success: true,
Data: result,
})
}
// createRedirectFile 创建重定向文件
func createRedirectFile(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
type CreateRedirectFileRequest struct {
Filename string `json:"filename"`
Description string `json:"description"`
ResourceID uint `json:"resource_id"`
RedirectURL string `json:"redirect_url"`
}
var req CreateRedirectFileRequest
if err := c.Bind().Body(&req); err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的请求参数",
})
}
result, err := service.CreateRedirectFile(uid, req.Filename, req.Description, req.ResourceID, req.RedirectURL)
if err != nil {
return err
}
return c.JSON(model.Response[*model.FileView]{
Success: true,
Data: result,
})
}
// getFile 获取文件信息
func getFile(c fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件ID",
})
}
file, err := service.GetFile(uint(id))
if err != nil {
return err
}
return c.JSON(model.Response[*model.FileView]{
Success: true,
Data: file,
})
}
// updateFile 更新文件信息
func updateFile(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件ID",
})
}
type UpdateFileRequest struct {
Filename string `json:"filename"`
Description string `json:"description"`
}
var req UpdateFileRequest
if err := c.Bind().Body(&req); err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的请求参数",
})
}
result, err := service.UpdateFile(uid, uint(id), req.Filename, req.Description)
if err != nil {
return err
}
return c.JSON(model.Response[*model.FileView]{
Success: true,
Data: result,
})
}
// deleteFile 删除文件
func deleteFile(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件ID",
})
}
if err := service.DeleteFile(uid, uint(id)); err != nil {
return err
}
return c.JSON(model.Response[any]{
Success: true,
Message: "文件删除成功",
})
}

86
server/api/image.go Normal file
View File

@@ -0,0 +1,86 @@
package api
import (
"github.com/gofiber/fiber/v3"
"net/http"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
"strings"
)
func handleUploadImage(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
if err := service.HavePermissionToUpload(uid); err != nil {
return err
}
data := c.Body()
contentType := http.DetectContentType(data)
if !strings.HasPrefix(contentType, "image/") {
return model.NewRequestError("Invalid image format")
}
id, err := service.CreateImage(data)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[uint]{
Success: true,
Data: id,
Message: "Image uploaded successfully",
})
}
func handleGetImage(c fiber.Ctx) error {
idStr := c.Params("id")
if idStr == "" {
return model.NewRequestError("Image ID is required")
}
id, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid image ID")
}
image, err := service.GetImage(uint(id))
if err != nil {
return err
}
contentType := http.DetectContentType(image)
c.Set("Content-Type", contentType)
return c.Send(image)
}
func handleDeleteImage(c fiber.Ctx) error {
idStr := c.Params("id")
if idStr == "" {
return model.NewRequestError("Image ID is required")
}
id, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid image ID")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
if err := service.HavePermissionToUpload(uid); err != nil {
return err
}
if err := service.DeleteImage(uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Message: "Image deleted successfully",
})
}
func AddImageRoutes(api fiber.Router) {
image := api.Group("/image")
{
image.Put("/", handleUploadImage)
image.Get("/:id", handleGetImage)
image.Delete("/:id", handleDeleteImage)
}
}

139
server/api/resource.go Normal file
View File

@@ -0,0 +1,139 @@
package api
import (
"encoding/json"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
"github.com/gofiber/fiber/v3"
)
func handleCreateResource(c fiber.Ctx) error {
var params service.ResourceCreateParams
body := c.Body()
err := json.Unmarshal(body, &params)
if err != nil {
return model.NewRequestError("Invalid request body")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You must be logged in to create a resource")
}
id, err := service.CreateResource(uid, &params)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[uint]{
Success: true,
Data: id,
Message: "Resource created successfully",
})
}
func handleGetResource(c fiber.Ctx) error {
idStr := c.Params("id")
if idStr == "" {
return model.NewRequestError("Resource ID is required")
}
id, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid resource ID")
}
resource, err := service.GetResource(uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.ResourceDetailView]{
Success: true,
Data: *resource,
Message: "Resource retrieved successfully",
})
}
func handleDeleteResource(c fiber.Ctx) error {
idStr := c.Params("id")
if idStr == "" {
return model.NewRequestError("Resource ID is required")
}
id, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid resource ID")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You must be logged in to delete a resource")
}
err = service.DeleteResource(uid, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Data: nil,
Message: "Resource deleted successfully",
})
}
func handleListResources(c fiber.Ctx) error {
pageStr := c.Query("page")
if pageStr == "" {
pageStr = "1"
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return model.NewRequestError("Invalid page number")
}
resources, maxPage, err := service.GetResourceList(page)
if err != nil {
return err
}
if resources == nil {
resources = []model.ResourceView{}
}
return c.Status(fiber.StatusOK).JSON(model.PageResponse[model.ResourceView]{
Success: true,
Data: resources,
TotalPages: maxPage,
Message: "Resources retrieved successfully",
})
}
func handleSearchResources(c fiber.Ctx) error {
query := c.Query("keyword")
if query == "" {
return model.NewRequestError("Search query is required")
}
pageStr := c.Query("page")
if pageStr == "" {
pageStr = "1"
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return model.NewRequestError("Invalid page number")
}
resources, totalPages, err := service.SearchResource(query, page)
if err != nil {
return err
}
if resources == nil {
resources = []model.ResourceView{}
}
return c.Status(fiber.StatusOK).JSON(model.PageResponse[model.ResourceView]{
Success: true,
Data: resources,
TotalPages: totalPages,
Message: "Resources retrieved successfully",
})
}
func AddResourceRoutes(api fiber.Router) {
resource := api.Group("/resource")
{
resource.Post("/", handleCreateResource)
resource.Get("/search", handleSearchResources)
resource.Get("/", handleListResources)
resource.Get("/:id", handleGetResource)
resource.Delete("/:id", handleDeleteResource)
}
}

118
server/api/storage.go Normal file
View File

@@ -0,0 +1,118 @@
package api
import (
"nysoure/server/model"
"nysoure/server/service"
"strconv"
"github.com/gofiber/fiber/v3"
)
func handleCreateS3Storage(c fiber.Ctx) error {
var params service.CreateS3StorageParams
if err := c.Bind().JSON(&params); err != nil {
return model.NewRequestError("Invalid request body")
}
if params.Name == "" || params.EndPoint == "" || params.AccessKeyID == "" ||
params.SecretAccessKey == "" || params.BucketName == "" {
return model.NewRequestError("All fields are required")
}
if params.MaxSizeInMB <= 0 {
return model.NewRequestError("Max size must be greater than 0")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You are not authorized to perform this action")
}
err := service.CreateS3Storage(uid, params)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(model.Response[any]{
Success: true,
Message: "S3 storage created successfully",
})
}
func handleCreateLocalStorage(c fiber.Ctx) error {
var params service.CreateLocalStorageParams
if err := c.Bind().JSON(&params); err != nil {
return model.NewRequestError("Invalid request body")
}
if params.Name == "" || params.Path == "" {
return model.NewRequestError("All fields are required")
}
if params.MaxSizeInMB <= 0 {
return model.NewRequestError("Max size must be greater than 0")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You are not authorized to perform this action")
}
err := service.CreateLocalStorage(uid, params)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(model.Response[any]{
Success: true,
Message: "Local storage created successfully",
})
}
func handleListStorages(c fiber.Ctx) error {
storages, err := service.ListStorages()
if err != nil {
return err
}
if storages == nil {
storages = []model.StorageView{}
}
return c.Status(fiber.StatusOK).JSON(model.Response[*[]model.StorageView]{
Success: true,
Data: &storages,
Message: "Storages retrieved successfully",
})
}
func handleDeleteStorage(c fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
return model.NewRequestError("Invalid storage ID")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You are not authorized to perform this action")
}
err = service.DeleteStorage(uid, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Message: "Storage deleted successfully",
})
}
func AddStorageRoutes(r fiber.Router) {
s := r.Group("storage")
s.Post("/s3", handleCreateS3Storage)
s.Post("/local", handleCreateLocalStorage)
s.Get("/", handleListStorages)
s.Delete("/:id", handleDeleteStorage)
}

68
server/api/tag.go Normal file
View File

@@ -0,0 +1,68 @@
package api
import (
"github.com/gofiber/fiber/v3"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
)
func handleCreateTag(c fiber.Ctx) error {
tag := c.FormValue("name")
if tag == "" {
return model.NewRequestError("name is required")
}
t, err := service.CreateTag(tag)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.TagView]{
Success: true,
Data: *t,
Message: "Tag created successfully",
})
}
func handleSearchTag(c fiber.Ctx) error {
keyword := c.Query("keyword")
if keyword == "" {
return model.NewRequestError("Keyword is required")
}
tags, err := service.SearchTag(keyword)
if tags == nil {
tags = []model.TagView{}
}
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[*[]model.TagView]{
Success: true,
Data: &tags,
Message: "Tags retrieved successfully",
})
}
func handleDeleteTag(c fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return model.NewRequestError("Invalid tag ID")
}
err = service.DeleteTag(uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Data: nil,
Message: "Tag deleted successfully",
})
}
func AddTagRoutes(api fiber.Router) {
tag := api.Group("/tag")
{
tag.Post("/", handleCreateTag)
tag.Get("/search", handleSearchTag)
tag.Delete("/:id", handleDeleteTag)
}
}

282
server/api/user.go Normal file
View File

@@ -0,0 +1,282 @@
package api
import (
"io"
"net/http"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
"github.com/gofiber/fiber/v3"
)
func handleUserRegister(c fiber.Ctx) error {
username := c.FormValue("username")
password := c.FormValue("password")
if username == "" || password == "" {
return model.NewRequestError("Username and password are required")
}
if len(password) < 6 {
return model.NewRequestError("Password must be at least 6 characters long")
}
if len(username) < 3 {
return model.NewRequestError("Username must be at least 3 characters long")
}
user, err := service.CreateUser(username, password)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserViewWithToken]{
Success: true,
Data: user,
Message: "User created successfully",
})
}
func handleUserLogin(c fiber.Ctx) error {
username := c.FormValue("username")
password := c.FormValue("password")
if username == "" || password == "" {
return model.NewRequestError("Username and password are required")
}
user, err := service.Login(username, password)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserViewWithToken]{
Success: true,
Data: user,
Message: "Login successful",
})
}
func handleUserChangePassword(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
oldPassword := c.FormValue("old_password")
newPassword := c.FormValue("new_password")
if oldPassword == "" || newPassword == "" {
return model.NewRequestError("Old and new passwords are required")
}
user, err := service.ChangePassword(uid, oldPassword, newPassword)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserViewWithToken]{
Success: true,
Data: user,
Message: "Password changed successfully",
})
}
func handleUserChangeAvatar(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
file, err := c.FormFile("avatar")
if err != nil {
return model.NewRequestError("Avatar file is required")
}
f, err := file.Open()
if err != nil {
return err
}
imageData, err := io.ReadAll(f)
_ = f.Close()
if err != nil {
return err
}
user, err := service.ChangeAvatar(uid, imageData)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserView]{
Success: true,
Data: user,
Message: "Avatar changed successfully",
})
}
func handleGetUserAvatar(c fiber.Ctx) error {
idStr := c.Params("id")
uid, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid user ID")
}
avatar, err := service.GetAvatar(uint(uid))
if err != nil {
return err
}
contentType := http.DetectContentType(avatar)
c.Set("Content-Type", contentType)
c.Set("Cache-Control", "public, max-age=31536000")
return c.Send(avatar)
}
func handleSetUserAdmin(c fiber.Ctx) error {
adminID, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
userIDStr := c.FormValue("user_id")
if userIDStr == "" {
return model.NewRequestError("User ID is required")
}
userID, err := strconv.Atoi(userIDStr)
if err != nil {
return model.NewRequestError("Invalid user ID")
}
isAdminStr := c.FormValue("is_admin")
if isAdminStr == "" {
return model.NewRequestError("is_admin parameter is required")
}
isAdmin := isAdminStr == "true" || isAdminStr == "1"
user, err := service.SetUserAdmin(adminID, uint(userID), isAdmin)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserView]{
Success: true,
Data: user,
Message: "User admin status updated successfully",
})
}
func handleSetUserUploadPermission(c fiber.Ctx) error {
adminID, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
userIDStr := c.FormValue("user_id")
if userIDStr == "" {
return model.NewRequestError("User ID is required")
}
userID, err := strconv.Atoi(userIDStr)
if err != nil {
return model.NewRequestError("Invalid user ID")
}
canUploadStr := c.FormValue("can_upload")
if canUploadStr == "" {
return model.NewRequestError("can_upload parameter is required")
}
canUpload := canUploadStr == "true" || canUploadStr == "1"
user, err := service.SetUserUploadPermission(adminID, uint(userID), canUpload)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserView]{
Success: true,
Data: user,
Message: "User upload permission updated successfully",
})
}
func handleListUsers(c fiber.Ctx) error {
adminID, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
pageStr := c.Query("page", "1")
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
users, totalPages, err := service.ListUsers(adminID, page)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.PageResponse[model.UserView]{
Success: true,
TotalPages: totalPages,
Data: users,
Message: "Users retrieved successfully",
})
}
func handleSearchUsers(c fiber.Ctx) error {
adminID, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
username := c.Query("username", "")
if username == "" {
return model.NewRequestError("Username search parameter is required")
}
pageStr := c.Query("page", "1")
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
users, totalPages, err := service.SearchUsers(adminID, username, page)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.PageResponse[model.UserView]{
Success: true,
TotalPages: totalPages,
Data: users,
Message: "Users found successfully",
})
}
func handleDeleteUser(c fiber.Ctx) error {
adminID, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
userIDStr := c.FormValue("user_id")
if userIDStr == "" {
return model.NewRequestError("User ID is required")
}
userID, err := strconv.Atoi(userIDStr)
if err != nil {
return model.NewRequestError("Invalid user ID")
}
if err := service.DeleteUser(adminID, uint(userID)); err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Message: "User deleted successfully",
})
}
func AddUserRoutes(r fiber.Router) {
u := r.Group("user")
u.Post("/register", handleUserRegister)
u.Post("/login", handleUserLogin)
u.Put("/avatar", handleUserChangeAvatar)
u.Post("/password", handleUserChangePassword)
u.Get("/avatar/:id", handleGetUserAvatar)
u.Post("/set_admin", handleSetUserAdmin)
u.Post("/set_upload_permission", handleSetUserUploadPermission)
u.Get("/list", handleListUsers)
u.Get("/search", handleSearchUsers)
u.Post("/delete", handleDeleteUser)
}

23
server/dao/db.go Normal file
View File

@@ -0,0 +1,23 @@
package dao
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"nysoure/server/model"
)
var db *gorm.DB
func init() {
var err error
db, err = gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
_ = db.AutoMigrate(&model.User{}, &model.Resource{}, &model.Image{}, &model.Tag{}, &model.Storage{}, &model.File{}, &model.UploadingFile{}, &model.Statistic{})
}
func GetDB() *gorm.DB {
return db
}

137
server/dao/file.go Normal file
View File

@@ -0,0 +1,137 @@
package dao
import (
"errors"
"gorm.io/gorm"
"nysoure/server/model"
"time"
)
func CreateUploadingFile(filename string, description string, fileSize int64, blockSize int64, tempPath string, resourceID, storageID, userID uint) (*model.UploadingFile, error) {
blocksCount := (fileSize + blockSize - 1) / blockSize
uf := &model.UploadingFile{
Filename: filename,
Description: description,
TotalSize: fileSize,
BlockSize: blockSize,
TempPath: tempPath,
Blocks: make(model.UploadingFileBlocks, blocksCount),
TargetResourceID: resourceID,
TargetStorageID: storageID,
UserID: userID,
}
if err := db.Create(uf).Error; err != nil {
return nil, err
}
return uf, nil
}
func GetUploadingFile(id uint) (*model.UploadingFile, error) {
uf := &model.UploadingFile{}
if err := db.Where("id = ?", id).First(uf).Error; err != nil {
return nil, err
}
return uf, nil
}
func UpdateUploadingBlock(id uint, blockIndex int) error {
uf := &model.UploadingFile{}
if err := db.Where("id = ?", id).First(uf).Error; err != nil {
return err
}
if blockIndex < 0 || blockIndex >= uf.BlocksCount() {
return nil
}
uf.Blocks[blockIndex] = true
return db.Save(uf).Error
}
func DeleteUploadingFile(id uint) error {
uf := &model.UploadingFile{}
if err := db.Where("id = ?", id).First(uf).Error; err != nil {
return err
}
if err := db.Delete(uf).Error; err != nil {
return err
}
return nil
}
func GetUploadingFilesOlderThan(time time.Time) ([]model.UploadingFile, error) {
var files []model.UploadingFile
if err := db.Where("updated_at < ?", time).Find(&files).Error; err != nil {
return nil, err
}
return files, nil
}
func CreateFile(filename string, description string, resourceID uint, storageID *uint, storageKey string, redirectUrl string) (*model.File, error) {
if storageID == nil && redirectUrl == "" {
return nil, errors.New("storageID and redirectUrl cannot be both empty")
}
f := &model.File{
Filename: filename,
Description: description,
ResourceID: resourceID,
StorageID: storageID,
RedirectUrl: redirectUrl,
StorageKey: storageKey,
}
if err := db.Create(f).Error; err != nil {
return nil, err
}
return f, nil
}
func GetFile(id uint) (*model.File, error) {
f := &model.File{}
if err := db.Where("id = ?", id).First(f).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, model.NewNotFoundError("file not found")
}
return nil, err
}
return f, nil
}
func GetFilesByResourceID(rID uint) ([]model.File, error) {
var files []model.File
if err := db.Where("resource_id = ?", rID).Find(&files).Error; err != nil {
return nil, err
}
return files, nil
}
func DeleteFile(id uint) error {
f := &model.File{}
if err := db.Where("id = ?", id).First(f).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.NewNotFoundError("file not found")
}
return err
}
if err := db.Delete(f).Error; err != nil {
return err
}
return nil
}
func UpdateFile(id uint, filename string, description string) (*model.File, error) {
f := &model.File{}
if err := db.Where("id = ?", id).First(f).Error; err != nil {
return nil, err
}
if filename != "" {
f.Filename = filename
}
if description != "" {
f.Description = description
}
if err := db.Save(f).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, model.NewNotFoundError("file not found")
}
return nil, err
}
return f, nil
}

56
server/dao/image.go Normal file
View File

@@ -0,0 +1,56 @@
package dao
import (
"errors"
"nysoure/server/model"
"time"
"gorm.io/gorm"
)
func CreateImage(name string, width, height int) (model.Image, error) {
// Create a new image in the database
i := model.Image{FileName: name, Width: width, Height: height}
if err := db.Create(&i).Error; err != nil {
return model.Image{}, err
}
return i, nil
}
func GetImageByID(id uint) (model.Image, error) {
// Retrieve an image by its ID from the database
var i model.Image
if err := db.First(&i, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Image{}, model.NewNotFoundError("Image not found")
}
return model.Image{}, err
}
return i, nil
}
func DeleteImage(id uint) error {
// Delete an image from the database
i := model.Image{}
i.ID = id
if err := db.Delete(&i).Error; err != nil {
return err
}
return nil
}
func GetUnusedImages() ([]model.Image, error) {
// Retrieve all images that are not used in any post
var images []model.Image
oneDayAgo := time.Now().Add(-24 * time.Hour)
if err := db.
Where("NOT EXISTS (SELECT 1 FROM resource_images WHERE image_id = images.id)").
Where("created_at < ?", oneDayAgo).
Find(&images).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return images, nil
}

169
server/dao/resource.go Normal file
View File

@@ -0,0 +1,169 @@
package dao
import (
"errors"
"nysoure/server/model"
"strings"
"gorm.io/gorm"
)
func CreateResource(r model.Resource) (model.Resource, error) {
// Create a new resource in the database
if err := db.Create(&r).Error; err != nil {
return model.Resource{}, err
}
return r, nil
}
func GetResourceByID(id uint) (model.Resource, error) {
// Retrieve a resource by its ID from the database
var r model.Resource
if err := db.Preload("User").Preload("Images").Preload("Tags").Preload("Files").First(&r, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Resource{}, model.NewNotFoundError("Resource not found")
}
return model.Resource{}, err
}
return r, nil
}
func GetResourceList(page, pageSize int) ([]model.Resource, int, error) {
// Retrieve a list of resources with pagination
var resources []model.Resource
var total int64
if err := db.Model(&model.Resource{}).Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Offset((page - 1) * pageSize).Limit(pageSize).Preload("User").Preload("Images").Preload("Tags").Order("created_at DESC").Find(&resources).Error; err != nil {
return nil, 0, err
}
totalPages := int(total) / pageSize
return resources, int(totalPages), nil
}
func UpdateResource(r model.Resource) error {
// Update a resource in the database
if err := db.Save(&r).Error; err != nil {
return err
}
return nil
}
func DeleteResource(id uint) error {
// Delete a resource from the database
r := model.Resource{}
r.ID = id
if err := db.Delete(&r).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return nil
}
func Search(query string, page, pageSize int) ([]model.Resource, int, error) {
query = strings.TrimSpace(query)
keywords := strings.Split(query, " ")
resource, err := searchWithKeyword(keywords[0])
if err != nil {
return nil, 0, err
}
if len(keywords) > 1 {
for _, keyword := range keywords[1:] {
r := make([]model.Resource, 0, len(resource))
for _, res := range resource {
if strings.Contains(res.Title, keyword) {
r = append(r, res)
continue
}
ok := false
for _, at := range res.AlternativeTitles {
if strings.Contains(at, keyword) {
r = append(r, res)
ok = true
break
}
}
if ok {
continue
}
for _, tag := range res.Tags {
if tag.Name == keyword {
r = append(r, res)
ok = true
break
}
}
}
resource = r
}
}
startIndex := (page - 1) * pageSize
endIndex := startIndex + pageSize
if startIndex > len(resource) {
return nil, 0, nil
}
if endIndex > len(resource) {
endIndex = len(resource)
}
totalPages := len(resource) / pageSize
result := make([]model.Resource, 0, endIndex-startIndex)
for i := startIndex; i < endIndex; i++ {
var r model.Resource
if err := db.Model(&r).Preload("User").Preload("Images").Preload("Tags").Where("id=?", resource[i].ID).First(&r).Error; err != nil {
return nil, 0, err
}
result = append(result, r)
}
return result, totalPages, nil
}
func searchWithKeyword(keyword string) ([]model.Resource, error) {
if len(keyword) == 0 {
return nil, nil
}
if len([]rune(keyword)) < 20 {
var tag model.Tag
if err := db.Where("name = ?", keyword).First(&tag).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
} else {
if err := db.Model(&tag).Preload("Resources").Find(&tag).Error; err != nil {
return nil, err
}
return tag.Resources, nil
}
}
if len([]rune(keyword)) < 80 {
var resources []model.Resource
if err := db.Where("title LIKE ?", "%"+keyword+"%").Or("alternative_titles LIKE ?", "%"+keyword+"%").Find(&resources).Error; err != nil {
return nil, err
}
return resources, nil
}
return nil, model.NewRequestError("Keyword too long")
}
func GetResourceByTag(tagID uint, page int, pageSize int) ([]model.Resource, int, error) {
var tag model.Tag
var total int64
total = db.Model(&model.Tag{}).Where("id = ?", tagID).Association("Resources").Count()
if err := db.Model(&model.Tag{}).Where("id = ?", tagID).Preload("User").Preload("Resources", func(tx *gorm.DB) *gorm.DB {
return tx.Offset((page - 1) * pageSize).Limit(pageSize)
}).First(&tag).Error; err != nil {
return nil, 0, err
}
totalPages := int(total) / pageSize
return tag.Resources, totalPages, nil
}

22
server/dao/statistic.go Normal file
View File

@@ -0,0 +1,22 @@
package dao
import "nysoure/server/model"
func SetStatistic(key string, value int64) error {
statistic := &model.Statistic{
Key: key,
Value: value,
}
if err := db.Save(statistic).Error; err != nil {
return err
}
return nil
}
func GetStatistic(key string) int64 {
statistic := &model.Statistic{}
if err := db.Where("key = ?", key).First(statistic).Error; err != nil {
return 0
}
return statistic.Value
}

24
server/dao/storage.go Normal file
View File

@@ -0,0 +1,24 @@
package dao
import "nysoure/server/model"
func CreateStorage(s model.Storage) (model.Storage, error) {
err := db.Model(&s).Create(&s).Error
return s, err
}
func DeleteStorage(id uint) error {
return db.Model(&model.Storage{}).Where("id = ?", id).Delete(&model.Storage{}).Error
}
func GetStorages() ([]model.Storage, error) {
var storages []model.Storage
err := db.Model(&model.Storage{}).Find(&storages).Error
return storages, err
}
func GetStorage(id uint) (model.Storage, error) {
var storage model.Storage
err := db.Model(&model.Storage{}).Where("id = ?", id).First(&storage).Error
return storage, err
}

60
server/dao/tag.go Normal file
View File

@@ -0,0 +1,60 @@
package dao
import (
"errors"
"nysoure/server/model"
"gorm.io/gorm"
)
func CreateTag(tag string) (model.Tag, error) {
// Create a new tag in the database
t := model.Tag{Name: tag}
if err := db.Create(&t).Error; err != nil {
return model.Tag{}, err
}
return t, nil
}
func SearchTag(keyword string) ([]model.Tag, error) {
// Search for a tag by its name in the database
var t []model.Tag
if err := db.Model(&model.Tag{}).Where("name Like ?", "%"+keyword+"%").Limit(10).Find(&t).Error; err != nil {
return nil, err
}
return t, nil
}
func DeleteTag(id uint) error {
// Delete a tag from the database
t := model.Tag{}
t.ID = id
if err := db.Delete(&t).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return nil
}
func GetTagByID(id uint) (model.Tag, error) {
// Retrieve a tag by its ID from the database
var t model.Tag
if err := db.First(&t, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Tag{}, model.NewNotFoundError("Tag not found")
}
return model.Tag{}, err
}
return t, nil
}
func GetTagByName(name string) (model.Tag, error) {
// Retrieve a tag by its name from the database
var t model.Tag
if err := db.Where("name = ?", name).First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Tag{}, model.NewNotFoundError("Tag not found")
}
return model.Tag{}, err
}
return t, nil
}

120
server/dao/user.go Normal file
View File

@@ -0,0 +1,120 @@
package dao
import (
"errors"
"gorm.io/gorm"
"nysoure/server/model"
)
func CreateUser(username string, hashedPassword []byte) (model.User, error) {
isEmpty, err := IsUserDataBaseEmpty()
if err != nil {
return model.User{}, err
}
user := model.User{
Username: username,
PasswordHash: hashedPassword,
IsAdmin: isEmpty,
}
exists, err := ExistsUser(username)
if err != nil {
return user, err
}
if exists {
return user, &model.RequestError{
Message: "User already exists",
}
}
if err := db.Create(&user).Error; err != nil {
return user, err
}
return user, nil
}
func ExistsUser(username string) (bool, error) {
var count int64
if err := db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func GetUserByUsername(username string) (model.User, error) {
var user model.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return user, model.NewNotFoundError("User not found")
}
return user, err
}
return user, nil
}
func GetUserByID(id uint) (model.User, error) {
var user model.User
if err := db.First(&user, id).Error; err != nil {
return user, err
}
return user, nil
}
func UpdateUser(user model.User) error {
if err := db.Save(&user).Error; err != nil {
return err
}
return nil
}
func IsUserDataBaseEmpty() (bool, error) {
var user model.User
if err := db.First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, nil
}
return false, err
}
return false, nil
}
// 获取分页用户列表
func ListUsers(page, pageSize int) ([]model.User, int64, error) {
var users []model.User
var total int64
// 获取总数
if err := db.Model(&model.User{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页获取用户
offset := (page - 1) * pageSize
if err := db.Offset(offset).Limit(pageSize).Order("id desc").Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
// 根据用户名搜索用户
func SearchUsersByUsername(username string, page, pageSize int) ([]model.User, int64, error) {
var users []model.User
var total int64
// 获取符合条件的总数
if err := db.Model(&model.User{}).Where("username LIKE ?", "%"+username+"%").Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页获取符合条件的用户
offset := (page - 1) * pageSize
if err := db.Where("username LIKE ?", "%"+username+"%").Offset(offset).Limit(pageSize).Order("id desc").Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
// 删除用户
func DeleteUser(id uint) error {
return db.Delete(&model.User{}, id).Error
}

View File

@@ -0,0 +1,69 @@
package middleware
import (
"errors"
"github.com/gofiber/fiber/v3/log"
"nysoure/server/model"
"github.com/gofiber/fiber/v3"
)
func ErrorHandler(c fiber.Ctx) error {
err := c.Next()
if err != nil {
var requestErr *model.RequestError
var unauthorizedErr *model.UnAuthorizedError
var notFoundErr *model.NotFoundError
if errors.As(err, &requestErr) {
log.Error("Request Error: ", err)
return c.Status(fiber.StatusBadRequest).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: requestErr.Error(),
})
} else if errors.As(err, &unauthorizedErr) {
log.Error("Unauthorized Error: ", err)
return c.Status(fiber.StatusUnauthorized).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: unauthorizedErr.Error(),
})
} else if errors.As(err, &notFoundErr) {
log.Error("Not Found Error: ", err)
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: notFoundErr.Error(),
})
} else if errors.Is(err, fiber.ErrNotFound) {
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Not found",
})
} else if errors.Is(err, fiber.ErrMethodNotAllowed) {
return c.Status(fiber.StatusMethodNotAllowed).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Method not allowed",
})
} else {
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
if fiberErr.Code == fiber.StatusNotFound {
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Not found",
})
}
}
return c.Status(fiber.StatusInternalServerError).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Internal server error",
})
}
}
return nil
}

View File

@@ -0,0 +1,19 @@
package middleware
import (
"github.com/gofiber/fiber/v3"
"nysoure/server/model"
"nysoure/server/utils"
)
func JwtMiddleware(c fiber.Ctx) error {
token := c.Get("Authorization")
if token != "" {
id, err := utils.ParseToken(token)
if err != nil {
return model.NewUnAuthorizedError("Invalid token")
}
c.Locals("uid", id)
}
return c.Next()
}

79
server/model/error.go Normal file
View File

@@ -0,0 +1,79 @@
package model
import (
"errors"
)
type RequestError struct {
Message string `json:"message"`
}
func (e *RequestError) Error() string {
return e.Message
}
func NewRequestError(message string) *RequestError {
return &RequestError{
Message: message,
}
}
func IsRequestError(err error) bool {
var requestError *RequestError
ok := errors.As(err, &requestError)
return ok
}
type UnAuthorizedError struct {
Message string `json:"message"`
}
func (e *UnAuthorizedError) Error() string {
return e.Message
}
func NewUnAuthorizedError(message string) *UnAuthorizedError {
return &UnAuthorizedError{
Message: message,
}
}
func IsUnAuthorizedError(err error) bool {
var unAuthorizedError *UnAuthorizedError
ok := errors.As(err, &unAuthorizedError)
return ok
}
type NotFoundError struct {
Message string `json:"message"`
}
func (e *NotFoundError) Error() string {
return e.Message
}
func NewNotFoundError(message string) *NotFoundError {
return &NotFoundError{
Message: message,
}
}
func IsNotFoundError(err error) bool {
var notFoundError *NotFoundError
ok := errors.As(err, &notFoundError)
return ok
}
type InternalServerError struct {
Message string `json:"message"`
}
func (e *InternalServerError) Error() string {
return e.Message
}
func NewInternalServerError(message string) *InternalServerError {
return &InternalServerError{
Message: message,
}
}

33
server/model/file.go Normal file
View File

@@ -0,0 +1,33 @@
package model
import (
"gorm.io/gorm"
)
type File struct {
gorm.Model
Filename string
Description string
StorageKey string
StorageID *uint `gorm:"default:null"`
Storage Storage
ResourceID uint
RedirectUrl string
Resource Resource `gorm:"foreignKey:ResourceID"`
UserID uint
User User `gorm:"foreignKey:UserID"`
}
type FileView struct {
ID uint `json:"id"`
Filename string `json:"filename"`
Description string `json:"description"`
}
func (f *File) ToView() *FileView {
return &FileView{
ID: f.ID,
Filename: f.Filename,
Description: f.Description,
}
}

26
server/model/image.go Normal file
View File

@@ -0,0 +1,26 @@
package model
import "gorm.io/gorm"
type Image struct {
gorm.Model
FileName string
Width int
Height int
// An image can only belong to one resource, or it doesn't belong to any resource and is waiting for usage.
Resource []Resource `gorm:"many2many:resource_images;"`
}
type ImageView struct {
ID uint `json:"id"`
Width int `json:"width"`
Height int `json:"height"`
}
func (i *Image) ToView() ImageView {
return ImageView{
ID: i.ID,
Width: i.Width,
Height: i.Height,
}
}

89
server/model/resource.go Normal file
View File

@@ -0,0 +1,89 @@
package model
import (
"time"
"gorm.io/gorm"
)
type Resource struct {
gorm.Model
Title string
AlternativeTitles []string `gorm:"serializer:json"`
Article string
Images []Image `gorm:"many2many:resource_images;"`
Tags []Tag `gorm:"many2many:resource_tags;"`
Files []File `gorm:"foreignKey:ResourceID"`
UserID uint
User User
}
type ResourceView struct {
ID uint `json:"id"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
Tags []TagView `json:"tags"`
Image *ImageView `json:"image"`
Author UserView `json:"author"`
}
type ResourceDetailView struct {
ID uint `json:"id"`
Title string `json:"title"`
AlternativeTitles []string `json:"alternativeTitles"`
Article string `json:"article"`
CreatedAt time.Time `json:"createdAt"`
Tags []TagView `json:"tags"`
Images []ImageView `json:"images"`
Files []FileView `json:"files"`
Author UserView `json:"author"`
}
func (r *Resource) ToView() ResourceView {
tags := make([]TagView, len(r.Tags))
for i, tag := range r.Tags {
tags[i] = *tag.ToView()
}
var image *ImageView
if len(r.Images) > 0 {
v := r.Images[0].ToView()
image = &v
}
return ResourceView{
ID: r.ID,
Title: r.Title,
CreatedAt: r.CreatedAt,
Tags: tags,
Image: image,
Author: r.User.ToView(),
}
}
func (r *Resource) ToDetailView() ResourceDetailView {
images := make([]ImageView, len(r.Images))
for i, image := range r.Images {
images[i] = image.ToView()
}
tags := make([]TagView, len(r.Tags))
for i, tag := range r.Tags {
tags[i] = *tag.ToView()
}
files := make([]FileView, len(r.Files))
for i, file := range r.Files {
files[i] = *file.ToView()
}
return ResourceDetailView{
ID: r.ID,
Title: r.Title,
AlternativeTitles: r.AlternativeTitles,
Article: r.Article,
CreatedAt: r.CreatedAt,
Tags: tags,
Images: images,
Files: files,
Author: r.User.ToView(),
}
}

14
server/model/response.go Normal file
View File

@@ -0,0 +1,14 @@
package model
type Response[T any] struct {
Success bool `json:"success"`
Data T `json:"data,omitempty"`
Message string `json:"message,omitempty"`
}
type PageResponse[T any] struct {
Success bool `json:"success"`
TotalPages int `json:"totalPages"`
Data []T `json:"data"`
Message string `json:"message,omitempty"`
}

View File

@@ -0,0 +1,6 @@
package model
type Statistic struct {
Key string `gorm:"primaryKey"`
Value int64
}

35
server/model/storage.go Normal file
View File

@@ -0,0 +1,35 @@
package model
import (
"gorm.io/gorm"
"time"
)
type Storage struct {
gorm.Model
Name string
Type string
Config string
MaxSize int64
CurrentSize int64
}
type StorageView struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
MaxSize int64 `json:"maxSize"`
CurrentSize int64 `json:"currentSize"`
CreatedAt time.Time `json:"createdAt"`
}
func (s *Storage) ToView() StorageView {
return StorageView{
ID: s.ID,
Name: s.Name,
Type: s.Type,
MaxSize: s.MaxSize,
CurrentSize: s.CurrentSize,
CreatedAt: s.CreatedAt,
}
}

21
server/model/tag.go Normal file
View File

@@ -0,0 +1,21 @@
package model
import "gorm.io/gorm"
type Tag struct {
gorm.Model
Name string `gorm:"unique"`
Resources []Resource `gorm:"many2many:resource_tags;"`
}
type TagView struct {
ID uint `json:"id"`
Name string `json:"name"`
}
func (t *Tag) ToView() *TagView {
return &TagView{
ID: t.ID,
Name: t.Name,
}
}

View File

@@ -0,0 +1,77 @@
package model
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"reflect"
)
type UploadingFile struct {
gorm.Model
Filename string
Description string
TargetResourceID uint
TargetStorageID uint
UserID uint
BlockSize int64
TotalSize int64
Blocks UploadingFileBlocks `gorm:"type:blob"`
TempPath string
Resource Resource `gorm:"foreignKey:TargetResourceID"`
Storage Storage `gorm:"foreignKey:TargetStorageID"`
}
func (uf *UploadingFile) BlocksCount() int {
return int((uf.TotalSize + uf.BlockSize - 1) / uf.BlockSize)
}
type UploadingFileBlocks []bool
func (ufb *UploadingFileBlocks) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue interface{}) (err error) {
data, ok := dbValue.([]byte)
if !ok {
return nil
}
*ufb = make([]bool, len(data)*8)
for i, b := range data {
for j := 0; j < 8; j++ {
(*ufb)[i*8+j] = (b>>j)&1 == 1
}
}
return nil
}
func (ufb UploadingFileBlocks) Value(ctx context.Context, field *schema.Field, dbValue reflect.Value) (value interface{}, err error) {
data := make([]byte, (len(ufb)+7)/8)
for i, b := range ufb {
if b {
data[i/8] |= 1 << (i % 8)
}
}
return data, nil
}
type UploadingFileView struct {
ID uint `json:"id"`
Filename string `json:"filename"`
Description string `json:"description"`
TotalSize int64 `json:"totalSize"`
BlockSize int64 `json:"blockSize"`
BlocksCount int `json:"blocksCount"`
StorageID uint `json:"storageId"`
ResourceID uint `json:"resourceId"`
}
func (uf *UploadingFile) ToView() *UploadingFileView {
return &UploadingFileView{
ID: uf.ID,
Filename: uf.Filename,
Description: uf.Description,
TotalSize: uf.TotalSize,
BlockSize: uf.BlockSize,
BlocksCount: uf.BlocksCount(),
StorageID: uf.TargetStorageID,
ResourceID: uf.TargetResourceID,
}
}

49
server/model/user.go Normal file
View File

@@ -0,0 +1,49 @@
package model
import (
"fmt"
"gorm.io/gorm"
"time"
)
type User struct {
gorm.Model
Username string
PasswordHash []byte
IsAdmin bool
CanUpload bool
AvatarVersion int
Resources []Resource `gorm:"foreignKey:UserID"`
}
type UserView struct {
ID uint `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
AvatarPath string `json:"avatar_path"`
IsAdmin bool `json:"is_admin"`
CanUpload bool `json:"can_upload"`
}
type UserViewWithToken struct {
UserView
Token string `json:"token"`
}
func (u User) ToView() UserView {
return UserView{
ID: u.ID,
Username: u.Username,
CreatedAt: u.CreatedAt,
AvatarPath: fmt.Sprintf("/api/user/avatar/%d?v=%d", u.ID, u.AvatarVersion),
IsAdmin: u.IsAdmin,
CanUpload: u.CanUpload || u.IsAdmin,
}
}
func (u UserView) WithToken(token string) UserViewWithToken {
return UserViewWithToken{
UserView: u,
Token: token,
}
}

320
server/service/file.go Normal file
View File

@@ -0,0 +1,320 @@
package service
import (
"github.com/gofiber/fiber/v3/log"
"github.com/google/uuid"
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/storage"
"nysoure/server/utils"
"os"
"path/filepath"
"strconv"
"time"
)
const (
blockSize = 4 * 1024 * 1024 // 4MB
)
var (
maxUploadingSize = int64(1024 * 1024 * 1024 * 20) // TODO: make this configurable
maxFileSize = int64(1024 * 1024 * 1024 * 8) // TODO: make this configurable
)
func getUploadingSize(uid uint) int64 {
return dao.GetStatistic("uploading_size")
}
func updateUploadingSize(offset int64) {
c := dao.GetStatistic("uploading_size")
c += offset
_ = dao.SetStatistic("uploading_size", c)
}
func getTempDir() (string, error) {
name := uuid.NewString()
path := filepath.Join(utils.GetStoragePath(), "uploading", name)
if err := os.MkdirAll(path, os.ModePerm); err != nil {
return "", err
}
return path, nil
}
func init() {
go func() {
// Wait for 1 minute to ensure the database is ready
time.Sleep(time.Minute)
for {
oneDayAgo := time.Now().Add(-24 * time.Hour)
oldFiles, err := dao.GetUploadingFilesOlderThan(oneDayAgo)
if err != nil {
log.Error("failed to get old uploading files: ", err)
} else {
for _, file := range oldFiles {
if err := os.RemoveAll(file.TempPath); err != nil {
log.Error("failed to remove temp dir: ", err)
}
if err := dao.DeleteUploadingFile(file.ID); err != nil {
log.Error("failed to delete uploading file: ", err)
}
updateUploadingSize(-file.TotalSize)
}
}
// Sleep for 1 hour
time.Sleep(1 * time.Hour)
}
}()
}
func CreateUploadingFile(uid uint, filename string, description string, fileSize int64, resourceID, storageID uint) (*model.UploadingFileView, error) {
if filename == "" {
return nil, model.NewRequestError("filename is empty")
}
canUpload, err := checkUserCanUpload(uid)
if err != nil {
log.Error("failed to check user permission: ", err)
return nil, model.NewInternalServerError("failed to check user permission")
}
if !canUpload {
return nil, model.NewUnAuthorizedError("user cannot upload file")
}
if fileSize > maxFileSize {
return nil, model.NewRequestError("file size exceeds the limit")
}
currentUploadingSize := getUploadingSize(uid)
if currentUploadingSize+fileSize > maxUploadingSize {
log.Info("A new uploading file is rejected due to max uploading size limit")
return nil, model.NewRequestError("server is busy, please try again later")
}
tempPath, err := getTempDir()
if err != nil {
log.Error("failed to create temp dir: ", err)
return nil, model.NewInternalServerError("failed to create temp dir")
}
uploadingFile, err := dao.CreateUploadingFile(filename, description, fileSize, blockSize, tempPath, resourceID, storageID, uid)
if err != nil {
log.Error("failed to create uploading file: ", err)
_ = os.Remove(tempPath)
return nil, model.NewInternalServerError("failed to create uploading file")
}
updateUploadingSize(fileSize)
return uploadingFile.ToView(), nil
}
func UploadBlock(uid uint, fid uint, index int, data []byte) error {
uploadingFile, err := dao.GetUploadingFile(fid)
if err != nil {
log.Error("failed to get uploading file: ", err)
return model.NewNotFoundError("file not found")
}
if uploadingFile.UserID != uid {
return model.NewUnAuthorizedError("user cannot upload file")
}
if len(data) > int(uploadingFile.BlockSize) {
return model.NewRequestError("block size exceeds the limit")
}
if index != uploadingFile.BlocksCount()-1 && len(data) != int(uploadingFile.BlockSize) {
return model.NewRequestError("block size is not correct")
}
if index < 0 || index >= uploadingFile.BlocksCount() {
return model.NewRequestError("block index is not correct")
}
if uploadingFile.Blocks[index] {
return model.NewRequestError("block already uploaded")
}
path := filepath.Join(uploadingFile.TempPath, strconv.Itoa(index))
if err := os.WriteFile(path, data, os.ModePerm); err != nil {
log.Error("failed to write block file: ", err)
return model.NewInternalServerError("failed to write block file")
}
uploadingFile.Blocks[index] = true
if err := dao.UpdateUploadingBlock(fid, index); err != nil {
log.Error("failed to update uploading file: ", err)
_ = os.Remove(path)
return model.NewInternalServerError("failed to update uploading file")
}
return nil
}
func FinishUploadingFile(uid uint, fid uint) (*model.FileView, error) {
uploadingFile, err := dao.GetUploadingFile(fid)
if err != nil {
log.Error("failed to get uploading file: ", err)
return nil, model.NewNotFoundError("file not found")
}
if uploadingFile.UserID != uid {
return nil, model.NewUnAuthorizedError("user cannot upload file")
}
for i := 0; i < uploadingFile.BlocksCount(); i++ {
if !uploadingFile.Blocks[i] {
return nil, model.NewRequestError("file is not completely uploaded")
}
}
tempRemoved := false
defer func() {
if !tempRemoved {
if err := os.RemoveAll(uploadingFile.TempPath); err != nil {
log.Error("failed to remove temp dir: ", err)
}
}
if err := dao.DeleteUploadingFile(fid); err != nil {
log.Error("failed to delete uploading file: ", err)
}
updateUploadingSize(-uploadingFile.TotalSize)
}()
resultFilePath := filepath.Join(utils.GetStoragePath(), uuid.NewString())
file, err := os.OpenFile(resultFilePath, os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
log.Error("failed to open result file: ", err)
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
defer func() {
_ = os.Remove(resultFilePath)
}()
for i := 0; i < uploadingFile.BlocksCount(); i++ {
blockPath := filepath.Join(uploadingFile.TempPath, strconv.Itoa(i))
data, err := os.ReadFile(blockPath)
if err != nil {
log.Error("failed to read block file: ", err)
_ = file.Close()
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
if _, err := file.Write(data); err != nil {
log.Error("failed to write result file: ", err)
_ = file.Close()
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
}
_ = file.Close()
_ = os.RemoveAll(uploadingFile.TempPath)
tempRemoved = true
s, err := dao.GetStorage(uploadingFile.TargetStorageID)
if err != nil {
log.Error("failed to get storage: ", err)
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
iStorage := storage.NewStorage(s)
if iStorage == nil {
log.Error("failed to find storage: ", err)
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
storageKey, err := iStorage.Upload(resultFilePath)
if err != nil {
log.Error("failed to upload file: ", err)
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
dbFile, err := dao.CreateFile(uploadingFile.Filename, uploadingFile.Description, uploadingFile.TargetResourceID, &uploadingFile.TargetStorageID, storageKey, "")
if err != nil {
log.Error("failed to create file in db: ", err)
_ = iStorage.Delete(storageKey)
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
return dbFile.ToView(), nil
}
func CreateRedirectFile(uid uint, filename string, description string, resourceID uint, redirectUrl string) (*model.FileView, error) {
canUpload, err := checkUserCanUpload(uid)
if err != nil {
log.Error("failed to check user permission: ", err)
return nil, model.NewInternalServerError("failed to check user permission")
}
if !canUpload {
return nil, model.NewUnAuthorizedError("user cannot upload file")
}
file, err := dao.CreateFile(filename, description, resourceID, nil, "", redirectUrl)
if err != nil {
log.Error("failed to create file in db: ", err)
return nil, model.NewInternalServerError("failed to create file in db")
}
return file.ToView(), nil
}
func DeleteFile(uid uint, fid uint) error {
file, err := dao.GetFile(fid)
if err != nil {
log.Error("failed to get file: ", err)
return model.NewNotFoundError("file not found")
}
isAdmin, err := checkUserIsAdmin(uid)
if err != nil {
log.Error("failed to check user permission: ", err)
return model.NewInternalServerError("failed to check user permission")
}
if !isAdmin && file.UserID != uid {
return model.NewUnAuthorizedError("user cannot delete file")
}
iStorage := storage.NewStorage(file.Storage)
if iStorage == nil {
log.Error("failed to find storage: ", err)
return model.NewInternalServerError("failed to find storage")
}
if err := iStorage.Delete(file.StorageKey); err != nil {
log.Error("failed to delete file from storage: ", err)
return model.NewInternalServerError("failed to delete file from storage")
}
if err := dao.DeleteFile(fid); err != nil {
log.Error("failed to delete file from db: ", err)
return model.NewInternalServerError("failed to delete file from db")
}
return nil
}
func UpdateFile(uid uint, fid uint, filename string, description string) (*model.FileView, error) {
file, err := dao.GetFile(fid)
if err != nil {
log.Error("failed to get file: ", err)
return nil, model.NewNotFoundError("file not found")
}
isAdmin, err := checkUserIsAdmin(uid)
if err != nil {
log.Error("failed to check user permission: ", err)
return nil, model.NewInternalServerError("failed to check user permission")
}
if !isAdmin && file.UserID != uid {
return nil, model.NewUnAuthorizedError("user cannot update file")
}
file, err = dao.UpdateFile(fid, filename, description)
if err != nil {
log.Error("failed to update file in db: ", err)
return nil, model.NewInternalServerError("failed to update file in db")
}
return file.ToView(), nil
}
func GetFile(fid uint) (*model.FileView, error) {
file, err := dao.GetFile(fid)
if err != nil {
log.Error("failed to get file: ", err)
return nil, model.NewNotFoundError("file not found")
}
return file.ToView(), nil
}

129
server/service/image.go Normal file
View File

@@ -0,0 +1,129 @@
package service
import (
"bytes"
"errors"
"github.com/gofiber/fiber/v3/log"
"github.com/google/uuid"
"image"
"net/http"
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/utils"
"os"
"time"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
_ "golang.org/x/image/webp"
"github.com/chai2010/webp"
)
func init() {
// Start a goroutine to delete unused images every hour
go func() {
// Wait for 1 minute to ensure the database is ready
time.Sleep(time.Minute)
for {
images, err := dao.GetUnusedImages()
if err != nil {
log.Errorf("Failed to get unused images: %v", err)
}
if len(images) > 0 {
for _, i := range images {
err := DeleteImage(i.ID)
if err != nil {
log.Errorf("Failed to delete unused image %d: %v", i.ID, err)
}
}
}
time.Sleep(time.Hour)
}
}()
}
func CreateImage(data []byte) (uint, error) {
if len(data) == 0 {
return 0, model.NewRequestError("Image data is empty")
} else if len(data) > 1024*1024*5 {
return 0, model.NewRequestError("Image data is too large")
}
imageDir := utils.GetStoragePath() + "/images/"
if _, err := os.Stat(imageDir); os.IsNotExist(err) {
if err := os.MkdirAll(imageDir, 0755); err != nil {
return 0, err
}
}
contentType := http.DetectContentType(data)
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/gif" && contentType != "image/webp" {
return 0, model.NewRequestError("Invalid image format")
}
// Reformat the image data to webp format if necessary
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return 0, errors.New("failed to decode image data")
}
if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
return 0, errors.New("invalid image dimensions")
}
if contentType != "image/webp" {
buf := new(bytes.Buffer)
if err := webp.Encode(buf, img, &webp.Options{Quality: 80}); err != nil {
return 0, errors.New("failed to encode image data to webp format")
}
data = buf.Bytes()
contentType = "image/webp"
}
filename := uuid.New().String()
if err := os.WriteFile(imageDir+filename, data, 0644); err != nil {
return 0, errors.New("failed to save image file")
}
i, err := dao.CreateImage(filename, img.Bounds().Dx(), img.Bounds().Dy())
if err != nil {
_ = os.Remove(imageDir + filename)
return 0, err
}
return i.ID, nil
}
func GetImage(id uint) ([]byte, error) {
i, err := dao.GetImageByID(id)
if err != nil {
return nil, err
}
imageDir := utils.GetStoragePath() + "/images/"
if _, err := os.Stat(imageDir); os.IsNotExist(err) {
return nil, model.NewNotFoundError("Image not found")
}
data, err := os.ReadFile(imageDir + i.FileName)
if err != nil {
return nil, errors.New("Failed to read image file")
}
return data, nil
}
func DeleteImage(id uint) error {
i, err := dao.GetImageByID(id)
if err != nil {
return err
}
imageDir := utils.GetStoragePath() + "/images/"
_ = os.Remove(imageDir + i.FileName)
if err := dao.DeleteImage(id); err != nil {
return err
}
return nil
}

129
server/service/resource.go Normal file
View File

@@ -0,0 +1,129 @@
package service
import (
"nysoure/server/dao"
"nysoure/server/model"
"gorm.io/gorm"
)
const (
pageSize = 20
)
type ResourceCreateParams struct {
Title string `json:"title" binding:"required"`
AlternativeTitles []string `json:"alternative_titles"`
Tags []uint `json:"tags"`
Article string `json:"article"`
Images []uint `json:"images"`
}
func CreateResource(uid uint, params *ResourceCreateParams) (uint, error) {
canUpload, err := checkUserCanUpload(uid)
if err != nil {
return 0, err
}
if !canUpload {
return 0, model.NewUnAuthorizedError("You have not permission to upload resources")
}
images := make([]model.Image, len(params.Images))
for i, id := range params.Images {
images[i] = model.Image{
Model: gorm.Model{
ID: id,
},
}
}
tags := make([]model.Tag, len(params.Tags))
for i, id := range params.Tags {
tags[i] = model.Tag{
Model: gorm.Model{
ID: id,
},
}
}
r := model.Resource{
Title: params.Title,
AlternativeTitles: params.AlternativeTitles,
Article: params.Article,
Images: images,
Tags: tags,
UserID: uid,
}
if r, err = dao.CreateResource(r); err != nil {
return 0, err
}
return r.ID, nil
}
func GetResource(id uint) (*model.ResourceDetailView, error) {
r, err := dao.GetResourceByID(id)
if err != nil {
return nil, err
}
v := r.ToDetailView()
return &v, nil
}
func GetResourceList(page int) ([]model.ResourceView, int, error) {
resources, totalPages, err := dao.GetResourceList(page, pageSize)
if err != nil {
return nil, 0, err
}
var views []model.ResourceView
for _, r := range resources {
views = append(views, r.ToView())
}
return views, totalPages, nil
}
func SearchResource(keyword string, page int) ([]model.ResourceView, int, error) {
resources, totalPages, err := dao.Search(keyword, page, pageSize)
if err != nil {
return nil, 0, err
}
var views []model.ResourceView
for _, r := range resources {
views = append(views, r.ToView())
}
return views, totalPages, nil
}
func DeleteResource(uid, id uint) error {
isAdmin, err := checkUserIsAdmin(uid)
if err != nil {
return err
}
if !isAdmin {
r, err := dao.GetResourceByID(id)
if err != nil {
return err
}
if r.UserID != uid {
return model.NewUnAuthorizedError("You have not permission to delete this resource")
}
}
if err := dao.DeleteResource(id); err != nil {
return err
}
return nil
}
func GetResourcesWithTag(tag string, page int) ([]model.ResourceView, int, error) {
t, err := dao.GetTagByName(tag)
if err != nil {
return nil, 0, err
}
tagID := t.ID
resources, totalPages, err := dao.GetResourceByTag(tagID, page, pageSize)
if err != nil {
return nil, 0, err
}
var views []model.ResourceView
for _, r := range resources {
views = append(views, r.ToView())
}
return views, totalPages, nil
}

104
server/service/storage.go Normal file
View File

@@ -0,0 +1,104 @@
package service
import (
"github.com/gofiber/fiber/v3/log"
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/storage"
"os"
)
type CreateS3StorageParams struct {
Name string `json:"name"`
EndPoint string `json:"endPoint"`
AccessKeyID string `json:"accessKeyID"`
SecretAccessKey string `json:"secretAccessKey"`
BucketName string `json:"bucketName"`
MaxSizeInMB uint `json:"maxSizeInMB"`
}
func CreateS3Storage(uid uint, params CreateS3StorageParams) error {
isAdmin, err := checkUserIsAdmin(uid)
if err != nil {
log.Errorf("check user is admin failed: %s", err)
return model.NewInternalServerError("check user is admin failed")
}
if !isAdmin {
return model.NewUnAuthorizedError("only admin can create s3 storage")
}
s3 := storage.S3Storage{
EndPoint: params.EndPoint,
AccessKeyID: params.AccessKeyID,
SecretAccessKey: params.SecretAccessKey,
BucketName: params.BucketName,
}
s := model.Storage{
Name: params.Name,
Type: s3.Type(),
Config: s3.ToString(),
MaxSize: int64(params.MaxSizeInMB) * 1024 * 1024,
}
_, err = dao.CreateStorage(s)
return err
}
type CreateLocalStorageParams struct {
Name string `json:"name"`
Path string `json:"path"`
MaxSizeInMB uint `json:"maxSizeInMB"`
}
func CreateLocalStorage(uid uint, params CreateLocalStorageParams) error {
isAdmin, err := checkUserIsAdmin(uid)
if err != nil {
log.Errorf("check user is admin failed: %s", err)
return model.NewInternalServerError("check user is admin failed")
}
if !isAdmin {
return model.NewUnAuthorizedError("only admin can create local storage")
}
local := storage.LocalStorage{
Path: params.Path,
}
err = os.MkdirAll(params.Path, os.ModePerm)
if err != nil {
log.Errorf("create local storage dir failed: %s", err)
return model.NewInternalServerError("create local storage dir failed")
}
s := model.Storage{
Name: params.Name,
Type: local.Type(),
Config: local.ToString(),
MaxSize: int64(params.MaxSizeInMB) * 1024 * 1024,
}
_, err = dao.CreateStorage(s)
return err
}
func ListStorages() ([]model.StorageView, error) {
storages, err := dao.GetStorages()
if err != nil {
return nil, err
}
var result []model.StorageView
for _, s := range storages {
result = append(result, s.ToView())
}
return result, nil
}
func DeleteStorage(uid, id uint) error {
isAdmin, err := checkUserIsAdmin(uid)
if err != nil {
log.Errorf("check user is admin failed: %s", err)
return model.NewInternalServerError("check user is admin failed")
}
if !isAdmin {
return model.NewUnAuthorizedError("only admin can delete storage")
}
err = dao.DeleteStorage(id)
if err != nil {
return err
}
return nil
}

38
server/service/tag.go Normal file
View File

@@ -0,0 +1,38 @@
package service
import (
"nysoure/server/dao"
"nysoure/server/model"
)
func CreateTag(name string) (*model.TagView, error) {
t, err := dao.CreateTag(name)
if err != nil {
return nil, err
}
return t.ToView(), nil
}
func GetTag(id uint) (*model.TagView, error) {
t, err := dao.GetTagByID(id)
if err != nil {
return nil, err
}
return t.ToView(), nil
}
func SearchTag(name string) ([]model.TagView, error) {
tags, err := dao.SearchTag(name)
if err != nil {
return nil, err
}
var tagViews []model.TagView
for _, t := range tags {
tagViews = append(tagViews, *t.ToView())
}
return tagViews, nil
}
func DeleteTag(id uint) error {
return dao.DeleteTag(id)
}

263
server/service/user.go Normal file
View File

@@ -0,0 +1,263 @@
package service
import (
"errors"
"fmt"
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/static"
"nysoure/server/utils"
"os"
"strconv"
"golang.org/x/crypto/bcrypt"
)
const (
embedAvatarCount = 1
)
func CreateUser(username, password string) (model.UserViewWithToken, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return model.UserViewWithToken{}, err
}
user, err := dao.CreateUser(username, hashedPassword)
if err != nil {
return model.UserViewWithToken{}, err
}
token, err := utils.GenerateToken(user.ID)
if err != nil {
return model.UserViewWithToken{}, err
}
return user.ToView().WithToken(token), nil
}
func Login(username, password string) (model.UserViewWithToken, error) {
user, err := dao.GetUserByUsername(username)
if err != nil {
if model.IsNotFoundError(err) {
return model.UserViewWithToken{}, model.NewRequestError("User not found")
}
return model.UserViewWithToken{}, err
}
if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(password)); err != nil {
return model.UserViewWithToken{}, model.NewRequestError("Invalid password")
}
token, err := utils.GenerateToken(user.ID)
if err != nil {
return model.UserViewWithToken{}, err
}
return user.ToView().WithToken(token), nil
}
func ChangePassword(id uint, oldPassword, newPassword string) (model.UserViewWithToken, error) {
user, err := dao.GetUserByID(id)
if err != nil {
return model.UserViewWithToken{}, err
}
if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(oldPassword)); err != nil {
return model.UserViewWithToken{}, err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return model.UserViewWithToken{}, err
}
user.PasswordHash = hashedPassword
if err := dao.UpdateUser(user); err != nil {
return model.UserViewWithToken{}, err
}
token, err := utils.GenerateToken(user.ID)
if err != nil {
return model.UserViewWithToken{}, err
}
return user.ToView().WithToken(token), nil
}
func ChangeAvatar(id uint, image []byte) (model.UserView, error) {
user, err := dao.GetUserByID(id)
if err != nil {
return model.UserView{}, err
}
if len(image) > 4*1024*1024 {
return model.UserView{}, errors.New("image size is too large")
}
avatarDir := utils.GetStoragePath() + "/avatar"
if _, err := os.Stat(avatarDir); os.IsNotExist(err) {
if err := os.MkdirAll(avatarDir, os.ModePerm); err != nil {
return model.UserView{}, errors.New("failed to create avatar directory")
}
}
avatarPath := avatarDir + "/" + strconv.Itoa(int(user.ID))
if err := os.WriteFile(avatarPath, image, 0644); err != nil {
return model.UserView{}, errors.New("failed to save avatar")
}
user.AvatarVersion++
if err := dao.UpdateUser(user); err != nil {
return model.UserView{}, err
}
return user.ToView(), nil
}
func GetAvatar(id uint) ([]byte, error) {
avatarPath := utils.GetStoragePath() + "/avatar/" + strconv.Itoa(int(id))
if _, err := os.Stat(avatarPath); os.IsNotExist(err) {
return getEmbedAvatar(id)
}
image, err := os.ReadFile(avatarPath)
if err != nil {
return nil, errors.New("failed to read avatar")
}
return image, nil
}
func getEmbedAvatar(id uint) ([]byte, error) {
fileIndex := id%embedAvatarCount + 1
fileName := fmt.Sprintf("avatars/%d.png", fileIndex)
return static.Static.ReadFile(fileName)
}
func HavePermissionToUpload(id uint) error {
user, err := dao.GetUserByID(id)
if err != nil {
return err
}
if !user.IsAdmin && !user.CanUpload {
return model.NewUnAuthorizedError("User does not have permission to upload")
}
return nil
}
func SetUserAdmin(adminID uint, targetUserID uint, isAdmin bool) (model.UserView, error) {
if adminID == targetUserID {
return model.UserView{}, model.NewRequestError("You cannot modify your own admin status")
}
adminUser, err := dao.GetUserByID(adminID)
if err != nil {
return model.UserView{}, err
}
if !adminUser.IsAdmin {
return model.UserView{}, model.NewUnAuthorizedError("Only administrators can modify admin status")
}
targetUser, err := dao.GetUserByID(targetUserID)
if err != nil {
return model.UserView{}, err
}
targetUser.IsAdmin = isAdmin
if err := dao.UpdateUser(targetUser); err != nil {
return model.UserView{}, err
}
return targetUser.ToView(), nil
}
func SetUserUploadPermission(adminID uint, targetUserID uint, canUpload bool) (model.UserView, error) {
adminUser, err := dao.GetUserByID(adminID)
if err != nil {
return model.UserView{}, err
}
if !adminUser.IsAdmin {
return model.UserView{}, model.NewUnAuthorizedError("Only administrators can modify upload permissions")
}
targetUser, err := dao.GetUserByID(targetUserID)
if err != nil {
return model.UserView{}, err
}
targetUser.CanUpload = canUpload
if err := dao.UpdateUser(targetUser); err != nil {
return model.UserView{}, err
}
return targetUser.ToView(), nil
}
func ListUsers(adminID uint, page int) ([]model.UserView, int, error) {
admin, err := dao.GetUserByID(adminID)
if err != nil {
return nil, 0, err
}
if !admin.IsAdmin {
return nil, 0, model.NewUnAuthorizedError("Only administrators can list users")
}
if page < 1 {
page = 1
}
pageSize := 10
users, total, err := dao.ListUsers(page, pageSize)
if err != nil {
return nil, 0, err
}
userViews := make([]model.UserView, len(users))
for i, user := range users {
userViews[i] = user.ToView()
}
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
return userViews, totalPages, nil
}
func SearchUsers(adminID uint, username string, page int) ([]model.UserView, int, error) {
admin, err := dao.GetUserByID(adminID)
if err != nil {
return nil, 0, err
}
if !admin.IsAdmin {
return nil, 0, model.NewUnAuthorizedError("Only administrators can search users")
}
if page < 1 {
page = 1
}
pageSize := 10
users, total, err := dao.SearchUsersByUsername(username, page, pageSize)
if err != nil {
return nil, 0, err
}
userViews := make([]model.UserView, len(users))
for i, user := range users {
userViews[i] = user.ToView()
}
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
return userViews, totalPages, nil
}
func DeleteUser(adminID uint, targetUserID uint) error {
admin, err := dao.GetUserByID(adminID)
if err != nil {
return err
}
if !admin.IsAdmin {
return model.NewUnAuthorizedError("Only administrators can delete users")
}
// Check if user is trying to delete themselves
if adminID == targetUserID {
return model.NewRequestError("You cannot delete your own account")
}
// Check if target user exists
_, err = dao.GetUserByID(targetUserID)
if err != nil {
return err
}
return dao.DeleteUser(targetUserID)
}

19
server/service/utils.go Normal file
View File

@@ -0,0 +1,19 @@
package service
import "nysoure/server/dao"
func checkUserCanUpload(uid uint) (bool, error) {
user, err := dao.GetUserByID(uid)
if err != nil {
return false, err
}
return user.IsAdmin || user.CanUpload, nil
}
func checkUserIsAdmin(uid uint) (bool, error) {
user, err := dao.GetUserByID(uid)
if err != nil {
return false, err
}
return user.IsAdmin, nil
}

BIN
server/static/avatars/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

6
server/static/static.go Normal file
View File

@@ -0,0 +1,6 @@
package static
import "embed"
//go:embed *
var Static embed.FS

61
server/storage/local.go Normal file
View File

@@ -0,0 +1,61 @@
package storage
import (
"io"
"os"
"github.com/google/uuid"
)
type LocalStorage struct {
Path string
}
func (s *LocalStorage) Upload(filePath string) (string, error) {
id := uuid.New().String()
inputPath := s.Path + "/" + id
input, err := os.OpenFile(inputPath, os.O_RDWR|os.O_CREATE, 0755)
if err != nil {
return "", err
}
defer input.Close()
output, err := os.Open(filePath)
if err != nil {
return "", err
}
defer output.Close()
_, err = io.Copy(input, output)
if err != nil {
return "", err
}
return id, nil
}
func (s *LocalStorage) Download(storageKey string) (string, error) {
path := s.Path + "/" + storageKey
if _, err := os.Stat(path); os.IsNotExist(err) {
return "", ErrFileUnavailable
}
return path, nil
}
func (s *LocalStorage) Delete(storageKey string) error {
path := s.Path + "/" + storageKey
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil
}
return os.Remove(path)
}
func (s *LocalStorage) ToString() string {
return s.Path
}
func (s *LocalStorage) FromString(config string) error {
s.Path = config
return nil
}
func (s *LocalStorage) Type() string {
return "local"
}

52
server/storage/s3.go Normal file
View File

@@ -0,0 +1,52 @@
package storage
import (
"encoding/json"
"errors"
)
type S3Storage struct {
EndPoint string
AccessKeyID string
SecretAccessKey string
BucketName string
}
func (s *S3Storage) Upload(filePath string) (string, error) {
// TODO: Implement S3 upload logic here
return "", nil
}
func (s *S3Storage) Download(storageKey string) (string, error) {
// TODO: Implement S3 download logic here
return "", nil
}
func (s *S3Storage) Delete(storageKey string) error {
// TODO: Implement S3 delete logic here
return nil
}
func (s *S3Storage) ToString() string {
data, _ := json.Marshal(s)
return string(data)
}
func (s *S3Storage) FromString(config string) error {
var s3Config S3Storage
if err := json.Unmarshal([]byte(config), &s3Config); err != nil {
return err
}
s.EndPoint = s3Config.EndPoint
s.AccessKeyID = s3Config.AccessKeyID
s.SecretAccessKey = s3Config.SecretAccessKey
s.BucketName = s3Config.BucketName
if s.EndPoint == "" || s.AccessKeyID == "" || s.SecretAccessKey == "" || s.BucketName == "" {
return errors.New("invalid S3 configuration")
}
return nil
}
func (s *S3Storage) Type() string {
return "s3"
}

48
server/storage/storage.go Normal file
View File

@@ -0,0 +1,48 @@
package storage
import (
"errors"
"nysoure/server/model"
)
var (
// ErrFileUnavailable is returned when the file is unavailable.
// When this error is returned, it is required to delete the file info from the database.
ErrFileUnavailable = errors.New("file unavailable")
)
type IStorage interface {
// Upload uploads a file to the storage and returns the storage key.
Upload(filePath string) (string, error)
// Download return the download url of the file with the given storage key.
Download(storageKey string) (string, error)
// Delete deletes the file with the given storage key.
Delete(storageKey string) error
// ToString returns the storage configuration as a string.
ToString() string
// FromString initializes the storage configuration from a string.
FromString(config string) error
// Type returns the type of the storage.
Type() string
}
func NewStorage(s model.Storage) IStorage {
switch s.Type {
case "s3":
r := S3Storage{}
err := r.FromString(s.Config)
if err != nil {
return nil
}
return &r
case "local":
r := LocalStorage{}
err := r.FromString(s.Config)
if err != nil {
return nil
}
return &r
}
return nil
}

62
server/utils/jwt.go Normal file
View File

@@ -0,0 +1,62 @@
package utils
import (
"errors"
"github.com/golang-jwt/jwt/v5"
"math/rand"
"os"
"time"
)
var (
key []byte
)
func init() {
secretFilePath := GetStoragePath() + "/jwt_secret.key"
secret, err := os.ReadFile(secretFilePath)
if err != nil {
key = secret
} else {
// Initialize the key with a random value
chars := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
key = make([]byte, 32)
for i := range key {
r := rand.Intn(len(chars))
key[i] = byte(chars[r])
}
err = os.WriteFile(secretFilePath, key, 0644)
}
}
func GenerateToken(userID uint) (string, error) {
t := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{
"id": userID,
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(),
})
s, err := t.SignedString(key)
if err != nil {
return "", err
}
return s, nil
}
func ParseToken(token string) (uint, error) {
t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
return key, nil
})
if err != nil {
return 0, err
}
if claims, ok := t.Claims.(jwt.MapClaims); ok && t.Valid {
id := uint(claims["id"].(float64))
expF := claims["exp"].(float64)
exp := time.Unix(int64(expF), 0)
if time.Now().After(exp) {
return 0, errors.New("token expired")
}
return id, nil
}
return 0, errors.New("invalid token")
}

26
server/utils/storage.go Normal file
View File

@@ -0,0 +1,26 @@
package utils
import (
"os"
"runtime"
)
var path string
func GetStoragePath() string {
if path != "" {
return path
}
if runtime.GOOS == "linux" {
path = "/var/lib/nysoure"
} else {
userDir, _ := os.UserHomeDir()
path = userDir + "/.nysoure"
}
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.MkdirAll(path, os.ModePerm); err != nil {
panic("failed to create storage directory")
}
}
return path
}