♻️ Moved account-based post to publisher-based post

This commit is contained in:
2024-10-31 22:41:32 +08:00
parent d889d22d11
commit 001c9a8140
39 changed files with 559 additions and 924 deletions

View File

@ -0,0 +1,203 @@
package api
import (
"git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"strconv"
"time"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/database"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/gap"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/models"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/services"
"github.com/gofiber/fiber/v2"
jsoniter "github.com/json-iterator/go"
"github.com/samber/lo"
)
func createArticle(c *fiber.Ctx) error {
if err := sec.EnsureGrantedPerm(c, "CreatePosts", true); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var data struct {
Publisher uint `json:"publisher"`
Alias *string `json:"alias"`
Title string `json:"title" validate:"required,max=1024"`
Description *string `json:"description"`
Content string `json:"content" validate:"required"`
Thumbnail *uint `json:"thumbnail"`
Attachments []string `json:"attachments"`
Tags []models.Tag `json:"tags"`
Categories []models.Category `json:"categories"`
PublishedAt *time.Time `json:"published_at"`
PublishedUntil *time.Time `json:"published_until"`
VisibleUsers []uint `json:"visible_users_list"`
InvisibleUsers []uint `json:"invisible_users_list"`
Visibility *int8 `json:"visibility"`
IsDraft bool `json:"is_draft"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
publisher, err := services.GetPublisher(data.Publisher, user.ID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
body := models.PostArticleBody{
Thumbnail: data.Thumbnail,
Title: data.Title,
Description: data.Description,
Content: data.Content,
Attachments: data.Attachments,
}
var bodyMapping map[string]any
rawBody, _ := jsoniter.Marshal(body)
_ = jsoniter.Unmarshal(rawBody, &bodyMapping)
item := models.Post{
Alias: data.Alias,
Type: models.PostTypeArticle,
Body: bodyMapping,
Language: services.DetectLanguage(data.Content),
Tags: data.Tags,
Categories: data.Categories,
IsDraft: data.IsDraft,
PublishedAt: data.PublishedAt,
PublishedUntil: data.PublishedUntil,
VisibleUsers: data.VisibleUsers,
InvisibleUsers: data.InvisibleUsers,
PublisherID: publisher.ID,
}
if item.PublishedAt == nil {
item.PublishedAt = lo.ToPtr(time.Now())
}
if data.Visibility != nil {
item.Visibility = *data.Visibility
} else {
item.Visibility = models.PostVisibilityAll
}
item, err = services.NewPost(publisher, item)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.new",
strconv.Itoa(int(item.ID)),
c,
)
}
return c.JSON(item)
}
func editArticle(c *fiber.Ctx) error {
id, _ := c.ParamsInt("postId", 0)
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var data struct {
Publisher uint `json:"publisher"`
Alias *string `json:"alias"`
Title string `json:"title" validate:"required,max=1024"`
Description *string `json:"description"`
Content string `json:"content" validate:"required"`
Thumbnail *uint `json:"thumbnail"`
Attachments []string `json:"attachments"`
Tags []models.Tag `json:"tags"`
Categories []models.Category `json:"categories"`
PublishedAt *time.Time `json:"published_at"`
PublishedUntil *time.Time `json:"published_until"`
VisibleUsers []uint `json:"visible_users_list"`
InvisibleUsers []uint `json:"invisible_users_list"`
Visibility *int8 `json:"visibility"`
IsDraft bool `json:"is_draft"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
publisher, err := services.GetPublisher(data.Publisher, user.ID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
var item models.Post
if err := database.C.Where(models.Post{
BaseModel: cruda.BaseModel{ID: uint(id)},
PublisherID: publisher.ID,
}).First(&item).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if item.LockedAt != nil {
return fiber.NewError(fiber.StatusForbidden, "post was locked")
}
if !item.IsDraft && !data.IsDraft {
item.EditedAt = lo.ToPtr(time.Now())
}
if item.IsDraft && !data.IsDraft && data.PublishedAt == nil {
item.PublishedAt = lo.ToPtr(time.Now())
} else {
item.PublishedAt = data.PublishedAt
}
body := models.PostArticleBody{
Thumbnail: data.Thumbnail,
Title: data.Title,
Description: data.Description,
Content: data.Content,
Attachments: data.Attachments,
}
var bodyMapping map[string]any
rawBody, _ := jsoniter.Marshal(body)
_ = jsoniter.Unmarshal(rawBody, &bodyMapping)
item.Alias = data.Alias
item.Body = bodyMapping
item.Language = services.DetectLanguage(data.Content)
item.Tags = data.Tags
item.Categories = data.Categories
item.IsDraft = data.IsDraft
item.PublishedUntil = data.PublishedUntil
item.VisibleUsers = data.VisibleUsers
item.InvisibleUsers = data.InvisibleUsers
// Preload publisher data
item.Publisher = publisher
if data.Visibility != nil {
item.Visibility = *data.Visibility
}
if item, err = services.EditPost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.edit",
strconv.Itoa(int(item.ID)),
c,
)
}
return c.JSON(item)
}

View File

@ -0,0 +1,98 @@
package api
import (
"git.solsynth.dev/hydrogen/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/services"
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
"github.com/gofiber/fiber/v2"
)
func getCategory(c *fiber.Ctx) error {
alias := c.Params("category")
category, err := services.GetCategory(alias)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(category)
}
func listCategories(c *fiber.Ctx) error {
categories, err := services.ListCategory()
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(categories)
}
func newCategory(c *fiber.Ctx) error {
if err := sec.EnsureGrantedPerm(c, "CreatePostCategories", true); err != nil {
return err
}
var data struct {
Alias string `json:"alias" validate:"required"`
Name string `json:"name" validate:"required"`
Description string `json:"description"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
category, err := services.NewCategory(data.Alias, data.Name, data.Description)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(category)
}
func editCategory(c *fiber.Ctx) error {
if err := sec.EnsureGrantedPerm(c, "CreatePostCategories", true); err != nil {
return err
}
id, _ := c.ParamsInt("categoryId", 0)
category, err := services.GetCategoryWithID(uint(id))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
var data struct {
Alias string `json:"alias" validate:"required"`
Name string `json:"name" validate:"required"`
Description string `json:"description"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
category, err = services.EditCategory(category, data.Alias, data.Name, data.Description)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(category)
}
func deleteCategory(c *fiber.Ctx) error {
if err := sec.EnsureGrantedPerm(c, "CreatePostCategories", true); err != nil {
return err
}
id, _ := c.ParamsInt("categoryId", 0)
category, err := services.GetCategoryWithID(uint(id))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.DeleteCategory(category); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(category)
}

View File

@ -0,0 +1,71 @@
package api
import (
"github.com/gofiber/fiber/v2"
)
func MapAPIs(app *fiber.App, baseURL string) {
api := app.Group(baseURL).Name("API")
{
api.Get("/users/:account/pins", listUserPinnedPost)
api.Get("/publishers/:name", getPublisher)
recommendations := api.Group("/recommendations").Name("Recommendations API")
{
recommendations.Get("/", listRecommendationNews)
recommendations.Get("/friends", listRecommendationFriends)
recommendations.Get("/shuffle", listRecommendationShuffle)
}
stories := api.Group("/stories").Name("Story API")
{
stories.Post("/", createStory)
stories.Put("/:postId", editStory)
}
articles := api.Group("/articles").Name("Article API")
{
articles.Post("/", createArticle)
articles.Put("/:postId", editArticle)
}
posts := api.Group("/posts").Name("Posts API")
{
posts.Get("/", listPost)
posts.Get("/search", searchPost)
posts.Get("/minimal", listPostMinimal)
posts.Get("/drafts", listDraftPost)
posts.Get("/:postId", getPost)
posts.Post("/:postId/react", reactPost)
posts.Post("/:postId/pin", pinPost)
posts.Delete("/:postId", deletePost)
posts.Get("/:postId/replies", listPostReplies)
posts.Get("/:postId/replies/featured", listPostFeaturedReply)
}
subscriptions := api.Group("/subscriptions").Name("Subscriptions API")
{
subscriptions.Get("/users/:userId", getSubscriptionOnUser)
subscriptions.Get("/tags/:tagId", getSubscriptionOnTag)
subscriptions.Get("/categories/:categoryId", getSubscriptionOnCategory)
subscriptions.Post("/users/:userId", subscribeToUser)
subscriptions.Post("/tags/:tagId", subscribeToTag)
subscriptions.Post("/categories/:categoryId", subscribeToCategory)
subscriptions.Delete("/users/:userId", unsubscribeFromUser)
subscriptions.Delete("/tags/:tagId", unsubscribeFromTag)
subscriptions.Delete("/categories/:categoryId", unsubscribeFromCategory)
}
api.Get("/categories", listCategories)
api.Get("/categories/:category", getCategory)
api.Post("/categories", newCategory)
api.Put("/categories/:categoryId", editCategory)
api.Delete("/categories/:categoryId", deleteCategory)
api.Get("/tags", listTags)
api.Get("/tags/:tag", getTag)
api.Get("/whats-new", getWhatsNew)
}
}

View File

@ -0,0 +1,357 @@
package api
import (
"fmt"
"git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"gorm.io/gorm"
"strconv"
"strings"
"git.solsynth.dev/hydrogen/dealer/pkg/hyper"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/database"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/gap"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/models"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/services"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
)
func universalPostFilter(c *fiber.Ctx, tx *gorm.DB) (*gorm.DB, error) {
tx = services.FilterPostDraft(tx)
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
tx = services.FilterPostWithUserContext(tx, &user)
} else {
tx = services.FilterPostWithUserContext(tx, nil)
}
if c.QueryBool("noReply", true) {
tx = services.FilterPostReply(tx)
}
if len(c.Query("author")) > 0 {
var author models.Publisher
if err := database.C.Where(&hyper.BaseUser{Name: c.Query("author")}).First(&author).Error; err != nil {
return tx, fiber.NewError(fiber.StatusNotFound, err.Error())
}
tx = tx.Where("author_id = ?", author.ID)
}
if len(c.Query("category")) > 0 {
tx = services.FilterPostWithCategory(tx, c.Query("category"))
}
if len(c.Query("tag")) > 0 {
tx = services.FilterPostWithTag(tx, c.Query("tag"))
}
return tx, nil
}
func getPost(c *fiber.Ctx) error {
id := c.Params("postId")
var item models.Post
var err error
tx := services.FilterPostDraft(database.C)
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
tx = services.FilterPostWithUserContext(tx, &user)
} else {
tx = services.FilterPostWithUserContext(tx, nil)
}
if numericId, paramErr := strconv.Atoi(id); paramErr == nil {
item, err = services.GetPost(tx, uint(numericId))
} else {
segments := strings.Split(id, ":")
if len(segments) != 2 {
return fiber.NewError(fiber.StatusBadRequest, "invalid post id, must be a number or a string with two segment divided by a colon")
}
area := segments[0]
alias := segments[1]
item, err = services.GetPostByAlias(tx, alias, area)
}
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
item.Metric = models.PostMetric{
ReplyCount: services.CountPostReply(item.ID),
ReactionCount: services.CountPostReactions(item.ID),
}
item.Metric.ReactionList, err = services.ListPostReactions(database.C.Where("post_id = ?", item.ID))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(item)
}
func searchPost(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
tx := database.C
probe := c.Query("probe")
if len(probe) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "probe is required")
}
tx = services.FilterPostWithFuzzySearch(tx, probe)
var err error
if tx, err = universalPostFilter(c, tx); err != nil {
return err
}
countTx := tx
count, err := services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
items, err := services.ListPost(tx, take, offset, "published_at DESC")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if c.QueryBool("truncate", true) {
for _, item := range items {
if item != nil {
item = lo.ToPtr(services.TruncatePostContent(*item))
}
}
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}
func listPost(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
tx := database.C
var err error
if tx, err = universalPostFilter(c, tx); err != nil {
return err
}
countTx := tx
count, err := services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
items, err := services.ListPost(tx, take, offset, "published_at DESC")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if c.QueryBool("truncate", true) {
for _, item := range items {
if item != nil {
item = lo.ToPtr(services.TruncatePostContent(*item))
}
}
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}
func listPostMinimal(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
tx := database.C
var err error
if tx, err = universalPostFilter(c, tx); err != nil {
return err
}
countTx := tx
count, err := services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
items, err := services.ListPostMinimal(tx, take, offset, "published_at DESC")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if c.QueryBool("truncate", false) {
for _, item := range items {
if item != nil {
item = lo.ToPtr(services.TruncatePostContent(*item))
}
}
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}
func listDraftPost(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
tx := services.FilterPostWithAuthorDraft(database.C, user.ID)
count, err := services.CountPost(tx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
items, err := services.ListPost(tx, take, offset, "created_at DESC", true)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if c.QueryBool("truncate", true) {
for _, item := range items {
if item != nil {
item = lo.ToPtr(services.TruncatePostContent(*item))
}
}
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}
func deletePost(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
id, _ := c.ParamsInt("postId", 0)
publisherId := c.QueryInt("publisherId", 0)
if publisherId <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "missing publisher id in request")
}
publisher, err := services.GetPublisher(uint(publisherId), user.ID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
var item models.Post
if err := database.C.Where(models.Post{
BaseModel: cruda.BaseModel{ID: uint(id)},
PublisherID: publisher.ID,
}).First(&item).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.DeletePost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.delete",
strconv.Itoa(int(item.ID)),
c,
)
}
return c.SendStatus(fiber.StatusOK)
}
func reactPost(c *fiber.Ctx) error {
if err := sec.EnsureGrantedPerm(c, "CreateReactions", true); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var data struct {
Symbol string `json:"symbol"`
Attitude models.ReactionAttitude `json:"attitude"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
reaction := models.Reaction{
Symbol: data.Symbol,
Attitude: data.Attitude,
AccountID: user.ID,
}
var res models.Post
if err := database.C.Where("id = ?", c.Params("postId")).Select("id").First(&res).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to find post to react: %v", err))
} else {
reaction.PostID = res.ID
}
if positive, reaction, err := services.ReactPost(user, reaction); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.react",
strconv.Itoa(int(res.ID)),
c,
)
return c.Status(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent)).JSON(reaction)
}
}
func pinPost(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var res models.Post
if err := database.C.Where("id = ? AND author_id = ?", c.Params("postId"), user.ID).First(&res).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to find post in your posts to pin: %v", err))
}
if status, err := services.PinPost(res); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else if status {
_ = authkit.AddEventExt(
gap.Nx,
"posts.pin",
strconv.Itoa(int(res.ID)),
c,
)
return c.SendStatus(fiber.StatusOK)
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.unpin",
strconv.Itoa(int(res.ID)),
c,
)
return c.SendStatus(fiber.StatusNoContent)
}
}

View File

@ -0,0 +1,9 @@
package api
import (
"github.com/gofiber/fiber/v2"
)
func getPublisher(c *fiber.Ctx) error {
panic("TODO")
}

View File

@ -0,0 +1,143 @@
package api
import (
"git.solsynth.dev/hydrogen/interactive/pkg/internal/database"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/gap"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/services"
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
"git.solsynth.dev/hypernet/nexus/pkg/proto"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
)
func listRecommendationNews(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
tx := database.C
var err error
if tx, err = universalPostFilter(c, tx); err != nil {
return err
}
countTx := tx
count, err := services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
order := "published_at DESC"
if c.QueryBool("featured", false) {
order = "published_at DESC, (COALESCE(total_upvote, 0) - COALESCE(total_downvote, 0)) DESC"
}
items, err := services.ListPost(tx, take, offset, order)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if c.QueryBool("truncate", true) {
for _, item := range items {
if item != nil {
item = lo.ToPtr(services.TruncatePostContent(*item))
}
}
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}
func listRecommendationFriends(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
tx := database.C
var err error
if tx, err = universalPostFilter(c, tx); err != nil {
return err
}
friends, _ := authkit.ListRelative(gap.Nx, user.ID, int32(authm.RelationshipFriend), true)
friendList := lo.Map(friends, func(item *proto.UserInfo, index int) uint {
return uint(item.GetId())
})
tx = tx.Where("author_id IN ?", friendList)
countTx := tx
count, err := services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
order := "published_at DESC"
if c.QueryBool("featured", false) {
order = "published_at DESC, (COALESCE(total_upvote, 0) - COALESCE(total_downvote, 0)) DESC"
}
items, err := services.ListPost(tx, take, offset, order)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if c.QueryBool("truncate", true) {
for _, item := range items {
if item != nil {
item = lo.ToPtr(services.TruncatePostContent(*item))
}
}
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}
func listRecommendationShuffle(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
tx := database.C
var err error
if tx, err = universalPostFilter(c, tx); err != nil {
return err
}
countTx := tx
count, err := services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
items, err := services.ListPost(tx, take, offset, "RANDOM()")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if c.QueryBool("truncate", true) {
for _, item := range items {
if item != nil {
item = lo.ToPtr(services.TruncatePostContent(*item))
}
}
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}

View File

@ -0,0 +1,89 @@
package api
import (
"fmt"
"git.solsynth.dev/hydrogen/dealer/pkg/hyper"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/database"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/models"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/services"
"github.com/gofiber/fiber/v2"
)
func listPostReplies(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
tx := database.C
var post models.Post
if err := database.C.Where("id = ?", c.Params("postId")).First(&post).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to find post: %v", err))
} else {
tx = services.FilterPostReply(tx, post.ID)
}
if len(c.Query("author")) > 0 {
var author models.Publisher
if err := database.C.Where(&hyper.BaseUser{Name: c.Query("author")}).First(&author).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
tx = tx.Where("author_id = ?", author.ID)
}
if len(c.Query("category")) > 0 {
tx = services.FilterPostWithCategory(tx, c.Query("category"))
}
if len(c.Query("tag")) > 0 {
tx = services.FilterPostWithTag(tx, c.Query("tag"))
}
count, err := services.CountPost(tx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
items, err := services.ListPost(tx, take, offset, "published_at DESC")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}
func listPostFeaturedReply(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
take = max(1, min(take, 3))
tx := database.C
var post models.Post
if err := database.C.Where("id = ?", c.Params("postId")).First(&post).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to find post: %v", err))
} else {
tx = services.FilterPostReply(tx, post.ID)
}
if len(c.Query("author")) > 0 {
var author models.Publisher
if err := database.C.Where(&hyper.BaseUser{Name: c.Query("author")}).First(&author).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
tx = tx.Where("author_id = ?", author.ID)
}
if len(c.Query("category")) > 0 {
tx = services.FilterPostWithCategory(tx, c.Query("category"))
}
if len(c.Query("tag")) > 0 {
tx = services.FilterPostWithTag(tx, c.Query("tag"))
}
items, err := services.ListPost(tx, take, 0, "(COALESCE(total_upvote, 0) - COALESCE(total_downvote, 0)) DESC, published_at DESC")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(items)
}

View File

@ -0,0 +1,223 @@
package api
import (
"fmt"
"git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"strconv"
"time"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/database"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/gap"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/models"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/services"
"github.com/gofiber/fiber/v2"
jsoniter "github.com/json-iterator/go"
"github.com/samber/lo"
)
func createStory(c *fiber.Ctx) error {
if err := sec.EnsureGrantedPerm(c, "CreatePosts", true); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var data struct {
Publisher uint `json:"publisher"`
Alias *string `json:"alias"`
Title *string `json:"title"`
Content string `json:"content" validate:"required,max=4096"`
Location *string `json:"location"`
Thumbnail *uint `json:"thumbnail"`
Attachments []string `json:"attachments"`
Tags []models.Tag `json:"tags"`
Categories []models.Category `json:"categories"`
PublishedAt *time.Time `json:"published_at"`
PublishedUntil *time.Time `json:"published_until"`
VisibleUsers []uint `json:"visible_users_list"`
InvisibleUsers []uint `json:"invisible_users_list"`
Visibility *int8 `json:"visibility"`
IsDraft bool `json:"is_draft"`
ReplyTo *uint `json:"reply_to"`
RepostTo *uint `json:"repost_to"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
publisher, err := services.GetPublisher(data.Publisher, user.ID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
body := models.PostStoryBody{
Thumbnail: data.Thumbnail,
Title: data.Title,
Content: data.Content,
Location: data.Location,
Attachments: data.Attachments,
}
var bodyMapping map[string]any
rawBody, _ := jsoniter.Marshal(body)
_ = jsoniter.Unmarshal(rawBody, &bodyMapping)
item := models.Post{
Alias: data.Alias,
Type: models.PostTypeStory,
Body: bodyMapping,
Language: services.DetectLanguage(data.Content),
Tags: data.Tags,
Categories: data.Categories,
PublishedAt: data.PublishedAt,
PublishedUntil: data.PublishedUntil,
IsDraft: data.IsDraft,
VisibleUsers: data.VisibleUsers,
InvisibleUsers: data.InvisibleUsers,
PublisherID: publisher.ID,
}
if item.PublishedAt == nil {
item.PublishedAt = lo.ToPtr(time.Now())
}
if data.Visibility != nil {
item.Visibility = *data.Visibility
} else {
item.Visibility = models.PostVisibilityAll
}
if data.ReplyTo != nil {
var replyTo models.Post
if err := database.C.Where("id = ?", data.ReplyTo).First(&replyTo).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("related post was not found: %v", err))
} else {
item.ReplyID = &replyTo.ID
}
}
if data.RepostTo != nil {
var repostTo models.Post
if err := database.C.Where("id = ?", data.RepostTo).First(&repostTo).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("related post was not found: %v", err))
} else {
item.RepostID = &repostTo.ID
}
}
item, err = services.NewPost(publisher, item)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.new",
strconv.Itoa(int(item.ID)),
c,
)
}
return c.JSON(item)
}
func editStory(c *fiber.Ctx) error {
id, _ := c.ParamsInt("postId", 0)
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var data struct {
Publisher uint `json:"publisher"`
Alias *string `json:"alias"`
Title *string `json:"title"`
Content string `json:"content" validate:"required,max=4096"`
Thumbnail *uint `json:"thumbnail"`
Location *string `json:"location"`
Attachments []string `json:"attachments"`
Tags []models.Tag `json:"tags"`
Categories []models.Category `json:"categories"`
PublishedAt *time.Time `json:"published_at"`
PublishedUntil *time.Time `json:"published_until"`
VisibleUsers []uint `json:"visible_users_list"`
InvisibleUsers []uint `json:"invisible_users_list"`
Visibility *int8 `json:"visibility"`
IsDraft bool `json:"is_draft"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
publisher, err := services.GetPublisher(data.Publisher, user.ID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
var item models.Post
if err := database.C.Where(models.Post{
BaseModel: cruda.BaseModel{ID: uint(id)},
PublisherID: publisher.ID,
}).First(&item).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if item.LockedAt != nil {
return fiber.NewError(fiber.StatusForbidden, "post was locked")
}
if !item.IsDraft && !data.IsDraft {
item.EditedAt = lo.ToPtr(time.Now())
}
if item.IsDraft && !data.IsDraft && data.PublishedAt == nil {
item.PublishedAt = lo.ToPtr(time.Now())
} else {
item.PublishedAt = data.PublishedAt
}
body := models.PostStoryBody{
Thumbnail: data.Thumbnail,
Title: data.Title,
Content: data.Content,
Location: data.Location,
Attachments: data.Attachments,
}
var bodyMapping map[string]any
rawBody, _ := jsoniter.Marshal(body)
_ = jsoniter.Unmarshal(rawBody, &bodyMapping)
item.Alias = data.Alias
item.Body = bodyMapping
item.Language = services.DetectLanguage(data.Content)
item.Tags = data.Tags
item.Categories = data.Categories
item.PublishedUntil = data.PublishedUntil
item.IsDraft = data.IsDraft
item.VisibleUsers = data.VisibleUsers
item.InvisibleUsers = data.InvisibleUsers
// Preload publisher data
item.Publisher = publisher
if data.Visibility != nil {
item.Visibility = *data.Visibility
}
if item, err = services.EditPost(item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.edit",
strconv.Itoa(int(item.ID)),
c,
)
}
return c.JSON(item)
}

View File

@ -0,0 +1,241 @@
package api
import (
"fmt"
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"strconv"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/gap"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/services"
"github.com/gofiber/fiber/v2"
)
func getSubscriptionOnUser(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
otherUserId, err := c.ParamsInt("userId", 0)
otherUser, err := services.GetAccountWithID(uint(otherUserId))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to get user: %v", err))
}
subscription, err := services.GetSubscriptionOnUser(user, otherUser)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to get subscription: %v", err))
} else if subscription == nil {
return fiber.NewError(fiber.StatusNotFound, "subscription does not exist")
}
return c.JSON(subscription)
}
func getSubscriptionOnTag(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
tagId, err := c.ParamsInt("tagId", 0)
tag, err := services.GetTagWithID(uint(tagId))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to get tag: %v", err))
}
subscription, err := services.GetSubscriptionOnTag(user, tag)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to get subscription: %v", err))
} else if subscription == nil {
return fiber.NewError(fiber.StatusNotFound, "subscription does not exist")
}
return c.JSON(subscription)
}
func getSubscriptionOnCategory(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
categoryId, err := c.ParamsInt("categoryId", 0)
category, err := services.GetCategoryWithID(uint(categoryId))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to get category: %v", err))
}
subscription, err := services.GetSubscriptionOnCategory(user, category)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to get subscription: %v", err))
} else if subscription == nil {
return fiber.NewError(fiber.StatusNotFound, "subscription does not exist")
}
return c.JSON(subscription)
}
func subscribeToUser(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
otherUserId, err := c.ParamsInt("userId", 0)
otherUser, err := services.GetAccountWithID(uint(otherUserId))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to get user: %v", err))
}
subscription, err := services.SubscribeToUser(user, otherUser)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to subscribe to user: %v", err))
}
_ = authkit.AddEventExt(
gap.Nx,
"posts.subscribe.users",
strconv.Itoa(int(otherUser.ID)),
c,
)
return c.JSON(subscription)
}
func subscribeToTag(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
tagId, err := c.ParamsInt("tagId", 0)
tag, err := services.GetTagWithID(uint(tagId))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to get tag: %v", err))
}
subscription, err := services.SubscribeToTag(user, tag)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to subscribe to tag: %v", err))
}
_ = authkit.AddEventExt(
gap.Nx,
"posts.subscribe.tags",
strconv.Itoa(int(tag.ID)),
c,
)
return c.JSON(subscription)
}
func subscribeToCategory(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
categoryId, err := c.ParamsInt("categoryId", 0)
category, err := services.GetCategoryWithID(uint(categoryId))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to get category: %v", err))
}
subscription, err := services.SubscribeToCategory(user, category)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to subscribe to category: %v", err))
}
_ = authkit.AddEventExt(
gap.Nx,
"posts.subscribe.categories",
strconv.Itoa(int(category.ID)),
c,
)
return c.JSON(subscription)
}
func unsubscribeFromUser(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
otherUserId, err := c.ParamsInt("userId", 0)
otherUser, err := services.GetAccountWithID(uint(otherUserId))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to get user: %v", err))
}
err = services.UnsubscribeFromUser(user, otherUser)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to unsubscribe from user: %v", err))
}
_ = authkit.AddEventExt(
gap.Nx,
"posts.unsubscribe.users",
strconv.Itoa(int(otherUser.ID)),
c,
)
return c.SendStatus(fiber.StatusOK)
}
func unsubscribeFromTag(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
tagId, err := c.ParamsInt("tagId", 0)
tag, err := services.GetTagWithID(uint(tagId))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to get tag: %v", err))
}
err = services.UnsubscribeFromTag(user, tag)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to unsubscribe from tag: %v", err))
}
_ = authkit.AddEventExt(
gap.Nx,
"posts.unsubscribe.tags",
strconv.Itoa(int(tag.ID)),
c,
)
return c.SendStatus(fiber.StatusOK)
}
func unsubscribeFromCategory(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
categoryId, err := c.ParamsInt("categoryId", 0)
category, err := services.GetCategoryWithID(uint(categoryId))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to get category: %v", err))
}
err = services.UnsubscribeFromCategory(user, category)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to unsubscribe from category: %v", err))
}
_ = authkit.AddEventExt(
gap.Nx,
"posts.unsubscribe.categories",
strconv.Itoa(int(category.ID)),
c,
)
return c.SendStatus(fiber.StatusOK)
}

View File

@ -0,0 +1,41 @@
package api
import (
"git.solsynth.dev/hydrogen/interactive/pkg/internal/models"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/services"
"github.com/gofiber/fiber/v2"
)
func getTag(c *fiber.Ctx) error {
alias := c.Params("tag")
tag, err := services.GetTag(alias)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(tag)
}
func listTags(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
probe := c.Query("probe")
if take > 100 {
take = 100
}
var tags []models.Tag
var err error
if len(probe) > 0 {
tags, err = services.SearchTags(take, offset, probe)
} else {
tags, err = services.ListTags(take, offset)
}
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(tags)
}

View File

@ -0,0 +1,31 @@
package api
import (
"git.solsynth.dev/hydrogen/dealer/pkg/hyper"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/database"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/models"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/services"
"github.com/gofiber/fiber/v2"
)
func listUserPinnedPost(c *fiber.Ctx) error {
account := c.Params("account")
var user models.Publisher
if err := database.C.
Where(&hyper.BaseUser{Name: account}).
First(&user).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
tx := services.FilterPostDraft(database.C)
tx = tx.Where("author_id = ?", user.ID)
tx = tx.Where("pinned_at IS NOT NULL")
items, err := services.ListPost(tx, 100, 0, "published_at DESC")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(items)
}

View File

@ -0,0 +1,47 @@
package api
import (
"git.solsynth.dev/hydrogen/interactive/pkg/internal/database"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/services"
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"github.com/gofiber/fiber/v2"
)
func getWhatsNew(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
pivot := c.QueryInt("pivot", 0)
if pivot < 0 {
return fiber.NewError(fiber.StatusBadRequest, "pivot must be greater than zero")
}
tx := services.FilterPostDraft(database.C)
tx = services.FilterPostWithUserContext(tx, &user)
tx = tx.Where("id > ?", pivot)
countTx := tx
count, err := services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
order := "published_at DESC"
if c.QueryBool("featured", false) {
order = "published_at DESC, (COALESCE(total_upvote, 0) - COALESCE(total_downvote, 0)) DESC"
}
items, err := services.ListPost(tx, 10, 0, order)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}

View File

@ -0,0 +1,18 @@
package exts
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
var validation = validator.New(validator.WithRequiredStructEnabled())
func BindAndValidate(c *fiber.Ctx, out any) error {
if err := c.BodyParser(out); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if err := validation.Struct(out); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return nil
}

View File

@ -0,0 +1,73 @@
package http
import (
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
"strings"
"git.solsynth.dev/hydrogen/interactive/pkg/internal/http/api"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/idempotency"
"github.com/gofiber/fiber/v2/middleware/logger"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
var IReader *sec.InternalTokenReader
type App struct {
app *fiber.App
}
func NewServer() *App {
app := fiber.New(fiber.Config{
DisableStartupMessage: true,
EnableIPValidation: true,
ServerHeader: "Hydrogen.Interactive",
AppName: "Hydrogen.Interactive",
ProxyHeader: fiber.HeaderXForwardedFor,
JSONEncoder: jsoniter.ConfigCompatibleWithStandardLibrary.Marshal,
JSONDecoder: jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal,
BodyLimit: 50 * 1024 * 1024,
EnablePrintRoutes: viper.GetBool("debug.print_routes"),
})
app.Use(idempotency.New())
app.Use(cors.New(cors.Config{
AllowCredentials: true,
AllowMethods: strings.Join([]string{
fiber.MethodGet,
fiber.MethodPost,
fiber.MethodHead,
fiber.MethodOptions,
fiber.MethodPut,
fiber.MethodDelete,
fiber.MethodPatch,
}, ","),
AllowOriginsFunc: func(origin string) bool {
return true
},
}))
app.Use(logger.New(logger.Config{
Format: "${status} | ${latency} | ${method} ${path}\n",
Output: log.Logger,
}))
app.Use(sec.ContextMiddleware(IReader))
app.Use(authkit.GetAccountFromUserInfo)
api.MapAPIs(app, "/api")
return &App{
app: app,
}
}
func (v *App) Listen() {
if err := v.app.Listen(viper.GetString("bind")); err != nil {
log.Fatal().Err(err).Msg("An error occurred when starting http...")
}
}