⬆️ Using Paperclip as attachment provider
This commit is contained in:
@ -41,8 +41,11 @@ func main() {
|
||||
}
|
||||
|
||||
// Connect other services
|
||||
if err := grpc.ConnectPaperclip(); err != nil {
|
||||
log.Fatal().Err(err).Msg("An error occurred when connecting to paperclip...")
|
||||
}
|
||||
if err := grpc.ConnectPassport(); err != nil {
|
||||
log.Fatal().Err(err).Msg("An error occurred when connecting to passport grpc endpoint...")
|
||||
log.Fatal().Err(err).Msg("An error occurred when connecting to passport...")
|
||||
}
|
||||
|
||||
// Configure timed tasks
|
||||
|
@ -12,7 +12,6 @@ var AutoMaintainRange = []any{
|
||||
&models.Tag{},
|
||||
&models.Post{},
|
||||
&models.Reaction{},
|
||||
&models.Attachment{},
|
||||
}
|
||||
|
||||
func RunMigration(source *gorm.DB) error {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
pcpb "git.solsynth.dev/hydrogen/paperclip/pkg/grpc/proto"
|
||||
idpb "git.solsynth.dev/hydrogen/passport/pkg/grpc/proto"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
@ -8,6 +9,19 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
var Attachments pcpb.AttachmentsClient
|
||||
|
||||
func ConnectPaperclip() error {
|
||||
addr := viper.GetString("paperclip.grpc_endpoint")
|
||||
if conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(insecure.NewCredentials())); err != nil {
|
||||
return err
|
||||
} else {
|
||||
Attachments = pcpb.NewAttachmentsClient(conn)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var Realms idpb.RealmsClient
|
||||
var Friendships idpb.FriendshipsClient
|
||||
var Notify idpb.NotifyClient
|
||||
|
@ -6,15 +6,14 @@ package models
|
||||
type Account struct {
|
||||
BaseModel
|
||||
|
||||
Name string `json:"name"`
|
||||
Nick string `json:"nick"`
|
||||
Avatar string `json:"avatar"`
|
||||
Banner string `json:"banner"`
|
||||
Description string `json:"description"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
PowerLevel int `json:"power_level"`
|
||||
Posts []Post `json:"posts" gorm:"foreignKey:AuthorID"`
|
||||
Attachments []Attachment `json:"attachments" gorm:"foreignKey:AuthorID"`
|
||||
Reactions []Reaction `json:"reactions"`
|
||||
ExternalID uint `json:"external_id"`
|
||||
Name string `json:"name"`
|
||||
Nick string `json:"nick"`
|
||||
Avatar string `json:"avatar"`
|
||||
Banner string `json:"banner"`
|
||||
Description string `json:"description"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
PowerLevel int `json:"power_level"`
|
||||
Posts []Post `json:"posts" gorm:"foreignKey:AuthorID"`
|
||||
Reactions []Reaction `json:"reactions"`
|
||||
ExternalID uint `json:"external_id"`
|
||||
}
|
||||
|
@ -1,41 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type AttachmentType = uint8
|
||||
|
||||
const (
|
||||
AttachmentOthers = AttachmentType(iota)
|
||||
AttachmentPhoto
|
||||
AttachmentVideo
|
||||
AttachmentAudio
|
||||
)
|
||||
|
||||
type Attachment struct {
|
||||
BaseModel
|
||||
|
||||
FileID string `json:"file_id"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
Filename string `json:"filename"`
|
||||
Mimetype string `json:"mimetype"`
|
||||
Hashcode string `json:"hashcode"`
|
||||
Type AttachmentType `json:"type"`
|
||||
Author Account `json:"author"`
|
||||
AuthorID uint `json:"author_id"`
|
||||
|
||||
PostID *uint `json:"post_id"`
|
||||
}
|
||||
|
||||
func (v Attachment) GetStoragePath() string {
|
||||
basepath := viper.GetString("content")
|
||||
return filepath.Join(basepath, v.FileID)
|
||||
}
|
||||
|
||||
func (v Attachment) GetAccessPath() string {
|
||||
return fmt.Sprintf("/api/attachments/o/%s", v.FileID)
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/datatypes"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -15,19 +16,19 @@ type PostReactInfo struct {
|
||||
type Post struct {
|
||||
BaseModel
|
||||
|
||||
Alias string `json:"alias" gorm:"uniqueIndex"`
|
||||
Content string `json:"content"`
|
||||
Tags []Tag `json:"tags" gorm:"many2many:post_tags"`
|
||||
Categories []Category `json:"categories" gorm:"many2many:post_categories"`
|
||||
Reactions []Reaction `json:"reactions"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
Replies []Post `json:"replies" gorm:"foreignKey:ReplyID"`
|
||||
ReplyID *uint `json:"reply_id"`
|
||||
RepostID *uint `json:"repost_id"`
|
||||
RealmID *uint `json:"realm_id"`
|
||||
ReplyTo *Post `json:"reply_to" gorm:"foreignKey:ReplyID"`
|
||||
RepostTo *Post `json:"repost_to" gorm:"foreignKey:RepostID"`
|
||||
Realm *Realm `json:"realm"`
|
||||
Alias string `json:"alias" gorm:"uniqueIndex"`
|
||||
Content string `json:"content"`
|
||||
Tags []Tag `json:"tags" gorm:"many2many:post_tags"`
|
||||
Categories []Category `json:"categories" gorm:"many2many:post_categories"`
|
||||
Reactions []Reaction `json:"reactions"`
|
||||
Replies []Post `json:"replies" gorm:"foreignKey:ReplyID"`
|
||||
Attachments datatypes.JSONSlice[string] `json:"attachments"`
|
||||
ReplyID *uint `json:"reply_id"`
|
||||
RepostID *uint `json:"repost_id"`
|
||||
RealmID *uint `json:"realm_id"`
|
||||
ReplyTo *Post `json:"reply_to" gorm:"foreignKey:ReplyID"`
|
||||
RepostTo *Post `json:"repost_to" gorm:"foreignKey:RepostID"`
|
||||
Realm *Realm `json:"realm"`
|
||||
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
|
||||
|
@ -1,61 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"git.solsynth.dev/hydrogen/interactive/pkg/models"
|
||||
"git.solsynth.dev/hydrogen/interactive/pkg/services"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func readAttachment(c *fiber.Ctx) error {
|
||||
id := c.Params("fileId")
|
||||
basepath := viper.GetString("content")
|
||||
|
||||
return c.SendFile(filepath.Join(basepath, id), true)
|
||||
}
|
||||
|
||||
func uploadAttachment(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
hashcode := c.FormValue("hashcode")
|
||||
if len(hashcode) != 64 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "please provide a SHA256 hashcode, length should be 64 characters")
|
||||
}
|
||||
file, err := c.FormFile("attachment")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attachment, err := services.NewAttachment(user, file, hashcode)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
if err := c.SaveFile(file, attachment.GetStoragePath()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"info": attachment,
|
||||
"url": attachment.GetAccessPath(),
|
||||
})
|
||||
}
|
||||
|
||||
func deleteAttachment(c *fiber.Ctx) error {
|
||||
id, _ := c.ParamsInt("id", 0)
|
||||
user := c.Locals("principal").(models.Account)
|
||||
|
||||
attachment, err := services.GetAttachmentByID(uint(id))
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
} else if attachment.AuthorID != user.ID {
|
||||
return fiber.NewError(fiber.StatusNotFound, "record not created by you")
|
||||
}
|
||||
|
||||
if err := services.DeleteAttachment(attachment); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"strings"
|
||||
"time"
|
||||
@ -9,7 +10,6 @@ import (
|
||||
"git.solsynth.dev/hydrogen/interactive/pkg/database"
|
||||
"git.solsynth.dev/hydrogen/interactive/pkg/models"
|
||||
"git.solsynth.dev/hydrogen/interactive/pkg/services"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
@ -76,15 +76,15 @@ func createPost(c *fiber.Ctx) error {
|
||||
user := c.Locals("principal").(models.Account)
|
||||
|
||||
var data struct {
|
||||
Alias string `json:"alias" form:"alias"`
|
||||
Content string `json:"content" form:"content" validate:"required,max=4096"`
|
||||
Tags []models.Tag `json:"tags" form:"tags"`
|
||||
Categories []models.Category `json:"categories" form:"categories"`
|
||||
Attachments []models.Attachment `json:"attachments" form:"attachments"`
|
||||
PublishedAt *time.Time `json:"published_at" form:"published_at"`
|
||||
RealmAlias string `json:"realm" form:"realm"`
|
||||
ReplyTo *uint `json:"reply_to" form:"reply_to"`
|
||||
RepostTo *uint `json:"repost_to" form:"repost_to"`
|
||||
Alias string `json:"alias" form:"alias"`
|
||||
Content string `json:"content" form:"content" validate:"required,max=4096"`
|
||||
Tags []models.Tag `json:"tags" form:"tags"`
|
||||
Categories []models.Category `json:"categories" form:"categories"`
|
||||
Attachments []string `json:"attachments" form:"attachments"`
|
||||
PublishedAt *time.Time `json:"published_at" form:"published_at"`
|
||||
RealmAlias string `json:"realm" form:"realm"`
|
||||
ReplyTo *uint `json:"reply_to" form:"reply_to"`
|
||||
RepostTo *uint `json:"repost_to" form:"repost_to"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
@ -93,6 +93,12 @@ func createPost(c *fiber.Ctx) error {
|
||||
data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
|
||||
for _, attachment := range data.Attachments {
|
||||
if _, err := services.GetAttachmentByUUID(attachment); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("attachment %s not found: %v", attachment, err))
|
||||
}
|
||||
}
|
||||
|
||||
item := models.Post{
|
||||
Alias: data.Alias,
|
||||
PublishedAt: data.PublishedAt,
|
||||
@ -143,12 +149,12 @@ func editPost(c *fiber.Ctx) error {
|
||||
id, _ := c.ParamsInt("postId", 0)
|
||||
|
||||
var data struct {
|
||||
Alias string `json:"alias" form:"alias" validate:"required"`
|
||||
Content string `json:"content" form:"content" validate:"required,max=1024"`
|
||||
PublishedAt *time.Time `json:"published_at" form:"published_at"`
|
||||
Tags []models.Tag `json:"tags" form:"tags"`
|
||||
Categories []models.Category `json:"categories" form:"categories"`
|
||||
Attachments []models.Attachment `json:"attachments" form:"attachments"`
|
||||
Alias string `json:"alias" form:"alias" validate:"required"`
|
||||
Content string `json:"content" form:"content" validate:"required,max=1024"`
|
||||
PublishedAt *time.Time `json:"published_at" form:"published_at"`
|
||||
Tags []models.Tag `json:"tags" form:"tags"`
|
||||
Categories []models.Category `json:"categories" form:"categories"`
|
||||
Attachments []string `json:"attachments" form:"attachments"`
|
||||
}
|
||||
|
||||
if err := BindAndValidate(c, &data); err != nil {
|
||||
@ -163,6 +169,12 @@ func editPost(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
for _, attachment := range data.Attachments {
|
||||
if _, err := services.GetAttachmentByUUID(attachment); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("attachment %s not found: %v", attachment, err))
|
||||
}
|
||||
}
|
||||
|
||||
item.Alias = data.Alias
|
||||
item.Content = data.Content
|
||||
item.PublishedAt = data.PublishedAt
|
||||
|
@ -63,10 +63,6 @@ func NewServer() {
|
||||
api.Get("/users/me", authMiddleware, getUserinfo)
|
||||
api.Get("/users/:accountId", getOthersInfo)
|
||||
|
||||
api.Get("/attachments/o/:fileId", readAttachment)
|
||||
api.Post("/attachments", authMiddleware, uploadAttachment)
|
||||
api.Delete("/attachments/:id", authMiddleware, deleteAttachment)
|
||||
|
||||
api.Get("/feed", listFeed)
|
||||
|
||||
posts := api.Group("/posts").Name("Posts API")
|
||||
|
@ -1,127 +1,20 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.solsynth.dev/hydrogen/interactive/pkg/database"
|
||||
"git.solsynth.dev/hydrogen/interactive/pkg/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/viper"
|
||||
"context"
|
||||
"git.solsynth.dev/hydrogen/interactive/pkg/grpc"
|
||||
pcpb "git.solsynth.dev/hydrogen/paperclip/pkg/grpc/proto"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func GetAttachmentByID(id uint) (models.Attachment, error) {
|
||||
var attachment models.Attachment
|
||||
if err := database.C.Where(models.Attachment{
|
||||
BaseModel: models.BaseModel{ID: id},
|
||||
}).First(&attachment).Error; err != nil {
|
||||
return attachment, err
|
||||
}
|
||||
return attachment, nil
|
||||
func GetAttachmentByID(id uint) (*pcpb.Attachment, error) {
|
||||
return grpc.Attachments.GetAttachment(context.Background(), &pcpb.AttachmentLookupRequest{
|
||||
Id: lo.ToPtr(uint64(id)),
|
||||
})
|
||||
}
|
||||
|
||||
func GetAttachmentByUUID(fileId string) (models.Attachment, error) {
|
||||
var attachment models.Attachment
|
||||
if err := database.C.Where(models.Attachment{
|
||||
FileID: fileId,
|
||||
}).First(&attachment).Error; err != nil {
|
||||
return attachment, err
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
func GetAttachmentByHashcode(hashcode string) (models.Attachment, error) {
|
||||
var attachment models.Attachment
|
||||
if err := database.C.Where(models.Attachment{
|
||||
Hashcode: hashcode,
|
||||
}).First(&attachment).Error; err != nil {
|
||||
return attachment, err
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
func NewAttachment(user models.Account, header *multipart.FileHeader, hashcode string) (models.Attachment, error) {
|
||||
var attachment models.Attachment
|
||||
existsAttachment, err := GetAttachmentByHashcode(hashcode)
|
||||
if err != nil {
|
||||
// Upload the new file
|
||||
attachment = models.Attachment{
|
||||
FileID: uuid.NewString(),
|
||||
Filesize: header.Size,
|
||||
Filename: header.Filename,
|
||||
Hashcode: hashcode,
|
||||
Mimetype: "unknown/unknown",
|
||||
Type: models.AttachmentOthers,
|
||||
AuthorID: user.ID,
|
||||
}
|
||||
|
||||
// Open file
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
return attachment, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Detect mimetype
|
||||
fileHeader := make([]byte, 512)
|
||||
_, err = file.Read(fileHeader)
|
||||
if err != nil {
|
||||
return attachment, err
|
||||
}
|
||||
attachment.Mimetype = http.DetectContentType(fileHeader)
|
||||
|
||||
switch strings.Split(attachment.Mimetype, "/")[0] {
|
||||
case "image":
|
||||
attachment.Type = models.AttachmentPhoto
|
||||
case "video":
|
||||
attachment.Type = models.AttachmentVideo
|
||||
case "audio":
|
||||
attachment.Type = models.AttachmentAudio
|
||||
default:
|
||||
attachment.Type = models.AttachmentOthers
|
||||
}
|
||||
} else {
|
||||
// Instant upload, build link with the exists file
|
||||
attachment = models.Attachment{
|
||||
FileID: existsAttachment.FileID,
|
||||
Filesize: header.Size,
|
||||
Filename: header.Filename,
|
||||
Hashcode: hashcode,
|
||||
Mimetype: existsAttachment.Mimetype,
|
||||
Type: existsAttachment.Type,
|
||||
AuthorID: user.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// Save into database
|
||||
err = database.C.Save(&attachment).Error
|
||||
|
||||
return attachment, err
|
||||
}
|
||||
|
||||
func DeleteAttachment(item models.Attachment) error {
|
||||
var dupeCount int64
|
||||
if err := database.C.
|
||||
Where(&models.Attachment{Hashcode: item.Hashcode}).
|
||||
Model(&models.Attachment{}).
|
||||
Count(&dupeCount).Error; err != nil {
|
||||
dupeCount = -1
|
||||
}
|
||||
|
||||
if err := database.C.Delete(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if dupeCount != -1 && dupeCount <= 1 {
|
||||
// Safe for deletion the physics file
|
||||
basepath := viper.GetString("content")
|
||||
fullpath := filepath.Join(basepath, item.FileID)
|
||||
|
||||
os.Remove(fullpath)
|
||||
}
|
||||
|
||||
return nil
|
||||
func GetAttachmentByUUID(uuid string) (*pcpb.Attachment, error) {
|
||||
return grpc.Attachments.GetAttachment(context.Background(), &pcpb.AttachmentLookupRequest{
|
||||
Uuid: &uuid,
|
||||
})
|
||||
}
|
||||
|
Reference in New Issue
Block a user