Compare commits
20 Commits
3c2a14800e
...
master
Author | SHA1 | Date | |
---|---|---|---|
25459cf429 | |||
c96e5bffa1 | |||
7dbb858d69 | |||
ac30cb5e4d | |||
d189c5a8d8 | |||
69e9a108ef | |||
2c8fd4e89a | |||
349d768d22 | |||
3c3cbd9c29 | |||
0034de71b5 | |||
068fffc1fd | |||
6f7a2de41e | |||
ce9d663bc3 | |||
37dc92dc43 | |||
79b12624d8 | |||
4901557217 | |||
c51721505f | |||
515412d663 | |||
d6f0daca61 | |||
f91b8dadb8 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@
|
||||
/keys
|
||||
|
||||
.DS_Store
|
||||
.idea
|
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@ -1,8 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
9
.idea/Interactive.iml
generated
9
.idea/Interactive.iml
generated
@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
12
.idea/dataSources.xml
generated
12
.idea/dataSources.xml
generated
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="hy_interactive@localhost" uuid="a2f70c83-03f8-4240-bb8b-ac697502cfe2">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:5432/hy_interactive</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
6
.idea/inspectionProfiles/Project_Default.xml
generated
6
.idea/inspectionProfiles/Project_Default.xml
generated
@ -1,6 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/Interactive.iml" filepath="$PROJECT_DIR$/.idea/Interactive.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
4
go.mod
4
go.mod
@ -5,13 +5,14 @@ go 1.23.2
|
||||
require (
|
||||
git.solsynth.dev/hypernet/insight v0.0.0-20250129172551-974266b2c1d2
|
||||
git.solsynth.dev/hypernet/nexus v0.0.0-20250329075932-d5422ab5b04c
|
||||
git.solsynth.dev/hypernet/paperclip v0.0.0-20250329141722-820d7a9f42e6
|
||||
git.solsynth.dev/hypernet/paperclip v0.0.0-20250330164539-11d54c7c7874
|
||||
git.solsynth.dev/hypernet/passport v0.0.0-20250329100405-b327e0806279
|
||||
git.solsynth.dev/hypernet/pusher v0.0.0-20250216145944-5fb769823a88
|
||||
git.solsynth.dev/hypernet/wallet v0.0.0-20250323095812-468cd655f886
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/go-ap/activitypub v0.0.0-20250212090640-aeb6499ba581
|
||||
github.com/go-playground/validator/v10 v10.22.1
|
||||
github.com/goccy/go-json v0.10.3
|
||||
github.com/gofiber/fiber/v2 v2.52.6
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/pemistahl/lingua-go v1.4.0
|
||||
@ -43,7 +44,6 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
4
go.sum
4
go.sum
@ -4,10 +4,10 @@ git.solsynth.dev/hypernet/insight v0.0.0-20250129172551-974266b2c1d2 h1:dPBdssDI
|
||||
git.solsynth.dev/hypernet/insight v0.0.0-20250129172551-974266b2c1d2/go.mod h1:NKSTeRc1mgg726iaCLEBoYEcVroIrGU5w2rnGf92LWE=
|
||||
git.solsynth.dev/hypernet/nexus v0.0.0-20250329075932-d5422ab5b04c h1:XgdTgJxSAQuCbiG15hN5pY6chzcz8sX3Onm2itS+Ufs=
|
||||
git.solsynth.dev/hypernet/nexus v0.0.0-20250329075932-d5422ab5b04c/go.mod h1:5tk62VQ1DcbR0EAN2jAOqYxHiegUPEC805JlfQ/G19I=
|
||||
git.solsynth.dev/hypernet/paperclip v0.0.0-20250329095638-8f91649d2570 h1:Nmm7zNpE/9ni/JFRO331D+w0pGz6IBKiNCSyFFbqWFA=
|
||||
git.solsynth.dev/hypernet/paperclip v0.0.0-20250329095638-8f91649d2570/go.mod h1:TdFsd/W3e04GAFVOWXBP9acSYF+YpmSeSdocnvt/4IY=
|
||||
git.solsynth.dev/hypernet/paperclip v0.0.0-20250329141722-820d7a9f42e6 h1:n7MgY8/TRJZXO4EJKmRqmzJQmE0E0X02Vf/pNJjRfms=
|
||||
git.solsynth.dev/hypernet/paperclip v0.0.0-20250329141722-820d7a9f42e6/go.mod h1:TdFsd/W3e04GAFVOWXBP9acSYF+YpmSeSdocnvt/4IY=
|
||||
git.solsynth.dev/hypernet/paperclip v0.0.0-20250330164539-11d54c7c7874 h1:Bdgn9y/0qxX9+zgCtZ8UwdGe7nyy4Bifem3Qf6dK/kY=
|
||||
git.solsynth.dev/hypernet/paperclip v0.0.0-20250330164539-11d54c7c7874/go.mod h1:TdFsd/W3e04GAFVOWXBP9acSYF+YpmSeSdocnvt/4IY=
|
||||
git.solsynth.dev/hypernet/passport v0.0.0-20250329100405-b327e0806279 h1:7eL9za4zGsoKImiCXkpGFdXcSYhdegSRVsXfBJq7Q5I=
|
||||
git.solsynth.dev/hypernet/passport v0.0.0-20250329100405-b327e0806279/go.mod h1:lbE/HrtMsnplOGvkg1JNjJL6DiXAnYczayxqN72UAJc=
|
||||
git.solsynth.dev/hypernet/pusher v0.0.0-20250216145944-5fb769823a88 h1:2HEENe9KUrdaJeNBzx9lsuXQGyzWqCgnLTKQnr8xFr8=
|
||||
|
@ -16,8 +16,6 @@ var AutoMaintainRange = []any{
|
||||
&models.PollAnswer{},
|
||||
&models.PostFlag{},
|
||||
&models.PostView{},
|
||||
&models.FediverseUser{},
|
||||
&models.FediversePost{},
|
||||
}
|
||||
|
||||
func RunMigration(source *gorm.DB) error {
|
||||
|
@ -1,17 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/services"
|
||||
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func adminTriggerFediverseFetch(c *fiber.Ctx) error {
|
||||
if err := sec.EnsureGrantedPerm(c, "AdminTriggerFediverseFetch", true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go services.FetchFediverseTimedTask()
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
@ -3,8 +3,4 @@ package admin
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
|
||||
func MapControllers(app *fiber.App, baseURL string) {
|
||||
admin := app.Group(baseURL)
|
||||
{
|
||||
admin.Post("/fediverse", adminTriggerFediverseFetch)
|
||||
}
|
||||
}
|
||||
|
@ -69,9 +69,6 @@ func apUserOutbox(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
for _, post := range posts {
|
||||
if post == nil {
|
||||
continue
|
||||
}
|
||||
var content string
|
||||
if val, ok := post.Body["content"].(string); ok {
|
||||
content = val
|
||||
|
@ -59,7 +59,6 @@ func MapControllers(app *fiber.App, baseURL string) {
|
||||
posts := api.Group("/posts").Name("Posts API")
|
||||
{
|
||||
posts.Get("/", listPost)
|
||||
posts.Get("/v2", listPostV2)
|
||||
posts.Get("/search", searchPost)
|
||||
posts.Get("/minimal", listPostMinimal)
|
||||
posts.Get("/drafts", listDraftPost)
|
||||
@ -68,6 +67,7 @@ func MapControllers(app *fiber.App, baseURL string) {
|
||||
posts.Post("/:postId/flag", createFlag)
|
||||
posts.Post("/:postId/react", reactPost)
|
||||
posts.Post("/:postId/pin", pinPost)
|
||||
posts.Post("/:postId/uncollapse", uncollapsePost)
|
||||
posts.Delete("/:postId", deletePost)
|
||||
|
||||
posts.Get("/:postId/replies", listPostReplies)
|
||||
|
@ -26,17 +26,26 @@ func getPost(c *fiber.Ctx) error {
|
||||
var item models.Post
|
||||
var err error
|
||||
|
||||
tx := database.C
|
||||
var userId *uint
|
||||
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
|
||||
userId = &user.ID
|
||||
}
|
||||
|
||||
tx := database.C
|
||||
if tx, err = services.UniversalPostFilter(c, tx, services.UniversalPostFilterConfig{
|
||||
ShowReply: true,
|
||||
ShowDraft: true,
|
||||
ShowReply: true,
|
||||
ShowDraft: true,
|
||||
ShowCollapsed: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if numericId, paramErr := strconv.Atoi(id); paramErr == nil {
|
||||
item, err = services.GetPost(tx, uint(numericId))
|
||||
if c.Get("X-API-Version", "1") == "2" {
|
||||
item, err = queries.GetPost(tx, uint(numericId), userId)
|
||||
} else {
|
||||
item, err = services.GetPost(tx, uint(numericId))
|
||||
}
|
||||
} else {
|
||||
segments := strings.Split(id, ":")
|
||||
if len(segments) != 2 {
|
||||
@ -44,7 +53,11 @@ func getPost(c *fiber.Ctx) error {
|
||||
}
|
||||
area := segments[0]
|
||||
alias := segments[1]
|
||||
item, err = services.GetPostByAlias(tx, alias, area)
|
||||
if c.Get("X-API-Version", "1") == "2" {
|
||||
item, err = queries.GetPostByAlias(tx, alias, area, userId)
|
||||
} else {
|
||||
item, err = services.GetPostByAlias(tx, alias, area)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@ -88,22 +101,27 @@ func searchPost(c *fiber.Ctx) error {
|
||||
userId = &user.ID
|
||||
}
|
||||
|
||||
var count int64
|
||||
countTx := tx
|
||||
count, err := services.CountPost(countTx)
|
||||
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", userId)
|
||||
var items []models.Post
|
||||
|
||||
if c.Get("X-API-Version", "1") == "2" {
|
||||
items, err = queries.ListPost(tx, take, offset, "published_at DESC", userId)
|
||||
} else {
|
||||
items, err = services.ListPost(tx, take, offset, "published_at DESC", userId)
|
||||
}
|
||||
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))
|
||||
}
|
||||
item = services.TruncatePostContent(item)
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,54 +147,20 @@ func listPost(c *fiber.Ctx) error {
|
||||
userId = &user.ID
|
||||
}
|
||||
|
||||
var count int64
|
||||
countTx := tx
|
||||
count, err := services.CountPost(countTx)
|
||||
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", userId)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
var items []models.Post
|
||||
|
||||
if c.Get("X-API-Version", "1") == "2" {
|
||||
items, err = queries.ListPost(tx, take, offset, "published_at DESC", userId)
|
||||
} else {
|
||||
items, err = services.ListPost(tx, take, offset, "published_at DESC", userId)
|
||||
}
|
||||
|
||||
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 listPostV2(c *fiber.Ctx) error {
|
||||
take := c.QueryInt("take", 10)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
|
||||
tx := database.C
|
||||
|
||||
var err error
|
||||
if tx, err = services.UniversalPostFilter(c, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var userId *uint
|
||||
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
|
||||
userId = &user.ID
|
||||
}
|
||||
|
||||
countTx := tx
|
||||
count, err := services.CountPost(countTx)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
items, err := queries.ListPostV2(tx, take, offset, "published_at DESC", userId)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
@ -238,6 +222,7 @@ func listDraftPost(c *fiber.Ctx) error {
|
||||
}
|
||||
user := c.Locals("user").(authm.Account)
|
||||
|
||||
var err error
|
||||
tx := services.FilterPostWithAuthorDraft(database.C, user.ID)
|
||||
|
||||
var userId *uint
|
||||
@ -245,21 +230,27 @@ func listDraftPost(c *fiber.Ctx) error {
|
||||
userId = &user.ID
|
||||
}
|
||||
|
||||
count, err := services.CountPost(tx)
|
||||
var count int64
|
||||
countTx := tx
|
||||
count, err = services.CountPost(countTx)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
items, err := services.ListPost(tx, take, offset, "created_at DESC", userId, true)
|
||||
var items []models.Post
|
||||
|
||||
if c.Get("X-API-Version", "1") == "2" {
|
||||
items, err = queries.ListPost(tx, take, offset, "published_at DESC", userId)
|
||||
} else {
|
||||
items, err = services.ListPost(tx, take, offset, "published_at DESC", userId)
|
||||
}
|
||||
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))
|
||||
}
|
||||
item = services.TruncatePostContent(item)
|
||||
}
|
||||
}
|
||||
|
||||
@ -381,3 +372,17 @@ func pinPost(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func uncollapsePost(c *fiber.Ctx) error {
|
||||
id, _ := c.ParamsInt("postId", 0)
|
||||
|
||||
if err := sec.EnsureGrantedPerm(c, "UncollapsePosts", true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := database.C.Model(&models.Post{}).Where("id = ?", id).Update("is_collapsed", false).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/services"
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/services/queries"
|
||||
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/samber/lo"
|
||||
@ -14,7 +15,9 @@ import (
|
||||
func listRecommendation(c *fiber.Ctx) error {
|
||||
const featuredMax = 5
|
||||
|
||||
posts, err := services.GetFeaturedPosts(featuredMax)
|
||||
var err error
|
||||
var posts []models.Post
|
||||
posts, err = services.GetFeaturedPosts(featuredMax)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
@ -29,12 +32,17 @@ func listRecommendation(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
tx := database.C.Where("id IN ?", postIdx)
|
||||
newPosts, err := services.ListPost(tx, featuredMax, 0, "id ASC", userId)
|
||||
var newPosts []models.Post
|
||||
if c.Get("X-API-Version", "1") == "2" {
|
||||
newPosts, err = queries.ListPost(tx, featuredMax, 0, "id ASC", userId)
|
||||
} else {
|
||||
newPosts, err = services.ListPost(tx, featuredMax, 0, "id ASC", userId)
|
||||
}
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
newPostMap := lo.SliceToMap(newPosts, func(item *models.Post) (uint, models.Post) {
|
||||
return item.ID, *item
|
||||
newPostMap := lo.SliceToMap(newPosts, func(item models.Post) (uint, models.Post) {
|
||||
return item.ID, item
|
||||
})
|
||||
|
||||
// Revert the position & truncate
|
||||
@ -49,9 +57,8 @@ func listRecommendationShuffle(c *fiber.Ctx) error {
|
||||
take := c.QueryInt("take", 10)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
|
||||
tx := database.C
|
||||
|
||||
var err error
|
||||
tx := database.C
|
||||
if tx, err = services.UniversalPostFilter(c, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -61,22 +68,26 @@ func listRecommendationShuffle(c *fiber.Ctx) error {
|
||||
userId = &user.ID
|
||||
}
|
||||
|
||||
var count int64
|
||||
countTx := tx
|
||||
count, err := services.CountPost(countTx)
|
||||
count, err = services.CountPost(countTx)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
items, err := services.ListPost(tx, take, offset, "RANDOM()", userId)
|
||||
var items []models.Post
|
||||
if c.Get("X-API-Version", "1") == "2" {
|
||||
items, err = queries.ListPost(tx, take, offset, "RANDOM()", userId)
|
||||
} else {
|
||||
items, err = services.ListPost(tx, take, offset, "RANDOM()", userId)
|
||||
}
|
||||
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))
|
||||
}
|
||||
item = services.TruncatePostContent(item)
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,7 +103,7 @@ func getRecommendationFeed(c *fiber.Ctx) error {
|
||||
|
||||
var cursorTime *time.Time
|
||||
if cursor > 0 {
|
||||
cursorTime = lo.ToPtr(time.UnixMilli(int64(cursor) + 1))
|
||||
cursorTime = lo.ToPtr(time.UnixMilli(int64(cursor - 1)))
|
||||
}
|
||||
|
||||
var userId *uint
|
||||
@ -100,7 +111,7 @@ func getRecommendationFeed(c *fiber.Ctx) error {
|
||||
userId = &user.ID
|
||||
}
|
||||
|
||||
entries, err := services.GetFeed(c, limit, userId, cursorTime)
|
||||
entries, err := queries.GetFeed(c, limit, userId, cursorTime)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
@ -40,6 +40,8 @@ func createVideo(c *fiber.Ctx) error {
|
||||
VisibleUsers []uint `json:"visible_users_list"`
|
||||
InvisibleUsers []uint `json:"invisible_users_list"`
|
||||
Visibility *int8 `json:"visibility"`
|
||||
Renderer *string `json:"renderer"`
|
||||
IsLive bool `json:"is_live"`
|
||||
IsDraft bool `json:"is_draft"`
|
||||
Realm *uint `json:"realm"`
|
||||
}
|
||||
@ -57,9 +59,11 @@ func createVideo(c *fiber.Ctx) error {
|
||||
Thumbnail: data.Thumbnail,
|
||||
Video: data.Video,
|
||||
Title: data.Title,
|
||||
Renderer: data.Renderer,
|
||||
Description: data.Description,
|
||||
Location: data.Location,
|
||||
Subtitles: data.Subtitles,
|
||||
IsLive: data.IsLive,
|
||||
}
|
||||
|
||||
var bodyMapping map[string]any
|
||||
@ -136,6 +140,8 @@ func editVideo(c *fiber.Ctx) error {
|
||||
VisibleUsers []uint `json:"visible_users_list"`
|
||||
InvisibleUsers []uint `json:"invisible_users_list"`
|
||||
Visibility *int8 `json:"visibility"`
|
||||
Renderer *string `json:"renderer"`
|
||||
IsLive bool `json:"is_live"`
|
||||
IsDraft bool `json:"is_draft"`
|
||||
}
|
||||
|
||||
@ -178,6 +184,8 @@ func editVideo(c *fiber.Ctx) error {
|
||||
Description: data.Description,
|
||||
Location: data.Location,
|
||||
Subtitles: data.Subtitles,
|
||||
Renderer: data.Renderer,
|
||||
IsLive: data.IsLive,
|
||||
}
|
||||
|
||||
var bodyMapping map[string]any
|
||||
|
@ -1,29 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type FediversePost struct {
|
||||
cruda.BaseModel
|
||||
|
||||
Identifier string `json:"identifier" gorm:"uniqueIndex"`
|
||||
Origin string `json:"origin"`
|
||||
Content string `json:"content"`
|
||||
Language string `json:"language"`
|
||||
Images datatypes.JSONSlice[string] `json:"images"`
|
||||
|
||||
User FediverseUser `json:"user"`
|
||||
UserID uint `json:"user_id"`
|
||||
}
|
||||
|
||||
type FediverseUser struct {
|
||||
cruda.BaseModel
|
||||
|
||||
Identifier string `json:"identifier" gorm:"uniqueIndex"`
|
||||
Origin string `json:"origin"`
|
||||
Avatar string `json:"avatar"`
|
||||
Name string `json:"name"`
|
||||
Nick string `json:"nick"`
|
||||
}
|
@ -102,6 +102,7 @@ type PostVideoBody struct {
|
||||
Description *string `json:"description"`
|
||||
Location *string `json:"location"`
|
||||
Video string `json:"video"`
|
||||
Renderer *string `json:"renderer"`
|
||||
IsLive bool `json:"is_live"`
|
||||
IsLiveEnded bool `json:"is_live_ended"`
|
||||
Subtitles map[string]string `json:"subtitles"`
|
||||
|
@ -1,39 +0,0 @@
|
||||
package bsky
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type BlueskyPost struct {
|
||||
URI string `json:"uri"`
|
||||
CID string `json:"cid"`
|
||||
Record struct {
|
||||
Text string `json:"text"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
} `json:"record"`
|
||||
Author struct {
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"displayName"`
|
||||
} `json:"author"`
|
||||
}
|
||||
|
||||
func FetchBlueskyPublicFeed(feedURI string, limit int) ([]BlueskyPost, error) {
|
||||
url := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=%s&limit=%d", feedURI, limit)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Bluesky posts: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var response struct {
|
||||
Feed []BlueskyPost `json:"feed"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Bluesky JSON: %v", err)
|
||||
}
|
||||
|
||||
return response.Feed, nil
|
||||
}
|
@ -1,116 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/services/mastodon"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type FromFediversePost interface {
|
||||
ToFediversePost() models.FediversePost
|
||||
}
|
||||
|
||||
type FediverseFriendConfig struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Trending bool `json:"trending"`
|
||||
}
|
||||
|
||||
var fediverseFriends []FediverseFriendConfig
|
||||
|
||||
func ReadFriendConfig() {
|
||||
if err := viper.UnmarshalKey("fediverse.friends", &fediverseFriends); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to loading fediverse friend config...")
|
||||
}
|
||||
log.Info().Int("count", len(fediverseFriends)).Msg("Loaded fediverse friend config!")
|
||||
}
|
||||
|
||||
func FetchFediversePost(cfg FediverseFriendConfig) ([]models.FediversePost, error) {
|
||||
switch cfg.Type {
|
||||
case "mastodon":
|
||||
data, err := mastodon.FetchTimeline(cfg.URL, 50, cfg.Trending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts := lo.Map(data, func(item mastodon.MastodonPost, _ int) models.FediversePost {
|
||||
return item.ToFediversePost()
|
||||
})
|
||||
return posts, nil
|
||||
default:
|
||||
// TODO Other platform fetching is still under development
|
||||
// DO NOT USE THEM
|
||||
return nil, fmt.Errorf("unsupported fediverse service: %s", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func FetchFediverseTimedTask() {
|
||||
if len(fediverseFriends) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Msg("Starting fetching fediverse friends timeline...")
|
||||
|
||||
var totalPosts []models.FediversePost
|
||||
var totalUsers []models.FediverseUser
|
||||
userMap := make(map[string]models.FediverseUser)
|
||||
|
||||
for _, friend := range fediverseFriends {
|
||||
log.Info().Str("id", friend.ID).Str("url", friend.URL).Msg("Fetching fediverse friend timeline...")
|
||||
posts, err := FetchFediversePost(friend)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("id", friend.ID).Str("url", friend.URL).Msg("Failed to fetch fediverse friend timeline...")
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().Str("id", friend.ID).Str("url", friend.URL).Int("count", len(posts)).Msg("Fetched fediverse friend timeline...")
|
||||
|
||||
for _, post := range posts {
|
||||
if _, exists := userMap[post.User.Identifier]; !exists {
|
||||
userMap[post.User.Identifier] = post.User
|
||||
}
|
||||
}
|
||||
|
||||
totalPosts = append(totalPosts, posts...)
|
||||
}
|
||||
|
||||
for _, user := range userMap {
|
||||
totalUsers = append(totalUsers, user)
|
||||
}
|
||||
|
||||
if len(totalUsers) > 0 {
|
||||
if err := database.C.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "identifier"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"name", "nick", "avatar"}),
|
||||
}).Create(&totalUsers).Error; err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save fediverse users...")
|
||||
}
|
||||
|
||||
for _, user := range totalUsers {
|
||||
userMap[user.Identifier] = user
|
||||
}
|
||||
}
|
||||
|
||||
for i := range totalPosts {
|
||||
if user, exists := userMap[totalPosts[i].User.Identifier]; exists {
|
||||
totalPosts[i].UserID = user.ID
|
||||
totalPosts[i].User = user
|
||||
} else {
|
||||
log.Warn().Str("user_identifier", totalPosts[i].User.Identifier).Msg("User ID not found for post, skipping")
|
||||
totalPosts = append(totalPosts[:i], totalPosts[i+1:]...) // Remove invalid post
|
||||
i-- // Adjust index after removal
|
||||
}
|
||||
}
|
||||
|
||||
if len(totalPosts) > 0 {
|
||||
if err := database.C.Clauses(clause.OnConflict{DoNothing: true}).Create(&totalPosts).Error; err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save timeline posts...")
|
||||
}
|
||||
}
|
||||
}
|
@ -26,6 +26,10 @@ func NewFlag(post models.Post, account uint) (models.PostFlag, error) {
|
||||
}
|
||||
|
||||
func FlagCalculateCollapseStatus(post models.Post) error {
|
||||
if post.TotalViews <= 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
collapseLimit := 0.5
|
||||
|
||||
var flagCount int64
|
||||
|
@ -1,105 +0,0 @@
|
||||
package mastodon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
|
||||
"git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type MastodomAttachment struct {
|
||||
URL string `json:"url"`
|
||||
Preview string `json:"preview"`
|
||||
}
|
||||
|
||||
type MastodonPost struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
URL string `json:"url"`
|
||||
Sensitive bool `json:"sensitive"`
|
||||
Account struct {
|
||||
Acct string `json:"acct"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Avatar string `json:"avatar"`
|
||||
AccountURL string `json:"url"`
|
||||
} `json:"account"`
|
||||
Language string `json:"language"`
|
||||
MediaAttachments []MastodomAttachment `json:"media_attachments"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Server string `json:"-"`
|
||||
}
|
||||
|
||||
func (v MastodonPost) ToFediversePost() models.FediversePost {
|
||||
return models.FediversePost{
|
||||
BaseModel: cruda.BaseModel{
|
||||
CreatedAt: v.CreatedAt,
|
||||
},
|
||||
Identifier: fmt.Sprintf("%s@%s", v.ID, v.Server),
|
||||
Origin: v.Server,
|
||||
Content: v.Content,
|
||||
Language: v.Language,
|
||||
Images: lo.Map(v.MediaAttachments, func(item MastodomAttachment, _ int) string {
|
||||
return item.URL
|
||||
}), User: models.FediverseUser{
|
||||
Identifier: v.Account.Acct,
|
||||
Name: v.Account.Username,
|
||||
Nick: v.Account.DisplayName,
|
||||
Avatar: v.Account.Avatar,
|
||||
Origin: v.Server,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func FetchTimeline(server string, limit int, useTrend bool) ([]MastodonPost, error) {
|
||||
url := fmt.Sprintf(
|
||||
lo.Ternary(
|
||||
useTrend,
|
||||
"%s/api/v1/trends/statuses?limit=%d",
|
||||
"%s/api/v1/timelines/public?local=true&limit=%d",
|
||||
),
|
||||
server,
|
||||
limit,
|
||||
)
|
||||
log.Debug().Str("url", url).Msg("Fetching mastodon timeline...")
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch public timeline: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d, response: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
log.Debug().Str("url", url).Msg("Fetched mastodon timeline...")
|
||||
|
||||
var posts []MastodonPost
|
||||
if err := jsoniter.Unmarshal(body, &posts); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse timeline JSON: %v", err)
|
||||
}
|
||||
|
||||
posts = lo.Filter(posts, func(item MastodonPost, index int) bool {
|
||||
return !item.Sensitive
|
||||
})
|
||||
|
||||
for idx := range posts {
|
||||
posts[idx].Server = strings.Replace(strings.Replace(server, "https://", "", 1), "http://", "", 1)
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type MisskeyPost struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"name"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
} `json:"user"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
func FetchTimeline(server, token string, limit int) ([]MisskeyPost, error) {
|
||||
url := fmt.Sprintf("%s/api/notes/global-timeline", server)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"limit": limit,
|
||||
"i": token,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Misskey posts: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var posts []MisskeyPost
|
||||
if err := json.NewDecoder(resp.Body).Decode(&posts); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Misskey JSON: %v", err)
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
@ -339,14 +339,7 @@ func PreloadGeneral(tx *gorm.DB) *gorm.DB {
|
||||
Preload("Tags").
|
||||
Preload("Categories").
|
||||
Preload("Publisher").
|
||||
Preload("ReplyTo").
|
||||
Preload("ReplyTo.Publisher").
|
||||
Preload("ReplyTo.Tags").
|
||||
Preload("ReplyTo.Categories").
|
||||
Preload("RepostTo").
|
||||
Preload("RepostTo.Publisher").
|
||||
Preload("RepostTo.Tags").
|
||||
Preload("RepostTo.Categories")
|
||||
Preload("Poll")
|
||||
}
|
||||
|
||||
func GetPost(tx *gorm.DB, id uint) (models.Post, error) {
|
||||
@ -403,7 +396,7 @@ func CountPostReactions(id uint) int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
func ListPost(tx *gorm.DB, take int, offset int, order any, user *uint, noReact ...bool) ([]*models.Post, error) {
|
||||
func ListPost(tx *gorm.DB, take int, offset int, order any, user *uint, noReact ...bool) ([]models.Post, error) {
|
||||
if take > 100 {
|
||||
take = 100
|
||||
}
|
||||
@ -415,77 +408,64 @@ func ListPost(tx *gorm.DB, take int, offset int, order any, user *uint, noReact
|
||||
tx = tx.Offset(offset)
|
||||
}
|
||||
|
||||
var items []*models.Post
|
||||
if err := PreloadGeneral(tx).
|
||||
Order(order).
|
||||
Find(&items).Error; err != nil {
|
||||
return items, err
|
||||
tx = tx.Preload("Tags").
|
||||
Preload("Categories").
|
||||
Preload("Publisher")
|
||||
|
||||
// Fetch posts
|
||||
var posts []models.Post
|
||||
if err := tx.Order(order).Find(&posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idx := lo.Map(items, func(item *models.Post, index int) uint {
|
||||
return item.ID
|
||||
})
|
||||
// If no posts found, return early
|
||||
if len(posts) == 0 {
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// Load reactions
|
||||
if len(noReact) <= 0 || !noReact[0] {
|
||||
if mapping, err := BatchListPostReactions(database.C.Where("post_id IN ?", idx), "post_id"); err != nil {
|
||||
return items, err
|
||||
} else {
|
||||
itemMap := lo.SliceToMap(items, func(item *models.Post) (uint, *models.Post) {
|
||||
return item.ID, item
|
||||
})
|
||||
// Collect post IDs
|
||||
idx := make([]uint, len(posts))
|
||||
itemMap := make(map[uint]*models.Post, len(posts))
|
||||
for i, item := range posts {
|
||||
idx[i] = item.ID
|
||||
itemMap[item.ID] = &posts[i]
|
||||
}
|
||||
|
||||
for k, v := range mapping {
|
||||
if post, ok := itemMap[k]; ok {
|
||||
post.Metric = models.PostMetric{
|
||||
ReactionList: v,
|
||||
}
|
||||
}
|
||||
// Batch load reactions
|
||||
if mapping, err := BatchListPostReactions(database.C.Where("post_id IN ?", idx), "post_id"); err != nil {
|
||||
return posts, err
|
||||
} else {
|
||||
for postID, reactions := range mapping {
|
||||
if post, exists := itemMap[postID]; exists {
|
||||
post.Metric.ReactionList = reactions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load replies
|
||||
if len(noReact) <= 0 || !noReact[0] {
|
||||
var replies []struct {
|
||||
PostID uint
|
||||
Count int64
|
||||
}
|
||||
|
||||
if err := database.C.Model(&models.Post{}).
|
||||
Select("reply_id as post_id, COUNT(id) as count").
|
||||
Where("reply_id IN (?)", idx).
|
||||
Group("post_id").
|
||||
Scan(&replies).Error; err != nil {
|
||||
return items, err
|
||||
}
|
||||
|
||||
itemMap := lo.SliceToMap(items, func(item *models.Post) (uint, *models.Post) {
|
||||
return item.ID, item
|
||||
})
|
||||
|
||||
list := map[uint]int64{}
|
||||
for _, info := range replies {
|
||||
list[info.PostID] = info.Count
|
||||
}
|
||||
|
||||
for k, v := range list {
|
||||
if post, ok := itemMap[k]; ok {
|
||||
post.Metric = models.PostMetric{
|
||||
ReactionList: post.Metric.ReactionList,
|
||||
ReplyCount: v,
|
||||
}
|
||||
}
|
||||
// Batch load reply counts efficiently
|
||||
var replies []struct {
|
||||
PostID uint
|
||||
Count int64
|
||||
}
|
||||
if err := database.C.Model(&models.Post{}).
|
||||
Select("reply_id as post_id, COUNT(id) as count").
|
||||
Where("reply_id IN (?)", idx).
|
||||
Group("post_id").
|
||||
Find(&replies).Error; err != nil {
|
||||
return posts, err
|
||||
}
|
||||
for _, info := range replies {
|
||||
if post, exists := itemMap[info.PostID]; exists {
|
||||
post.Metric.ReplyCount = info.Count
|
||||
}
|
||||
}
|
||||
|
||||
// Add post views for the user
|
||||
if user != nil {
|
||||
AddPostViews(lo.Map(items, func(item *models.Post, index int) models.Post {
|
||||
return *item
|
||||
}), *user)
|
||||
AddPostViews(posts, *user)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func ListPostMinimal(tx *gorm.DB, take int, offset int, order any) ([]*models.Post, error) {
|
||||
@ -521,6 +501,61 @@ func EnsurePostCategoriesAndTags(item models.Post) (models.Post, error) {
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func NotifyReplying(item models.Post, user models.Publisher) error {
|
||||
content, ok := item.Body["content"].(string)
|
||||
if !ok {
|
||||
content = "Posted a post"
|
||||
} else {
|
||||
content = TruncatePostContentShort(content)
|
||||
}
|
||||
|
||||
var op models.Post
|
||||
if err := database.C.
|
||||
Where("id = ?", item.ReplyID).
|
||||
Preload("Publisher").
|
||||
First(&op).Error; err == nil {
|
||||
if op.Publisher.AccountID != nil && op.Publisher.ID != user.ID {
|
||||
log.Debug().Uint("user", *op.Publisher.AccountID).Msg("Notifying the original poster their post got replied...")
|
||||
err = NotifyPosterAccount(
|
||||
op.Publisher,
|
||||
op,
|
||||
"Post got replied",
|
||||
fmt.Sprintf("%s (%s) replied you: %s", user.Nick, user.Name, content),
|
||||
"interactive.reply",
|
||||
fmt.Sprintf("%s replied your post #%d", user.Nick, *item.ReplyID),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("An error occurred when notifying user...")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NotifySubscribers(item models.Post, user models.Publisher) error {
|
||||
content, ok := item.Body["content"].(string)
|
||||
if !ok {
|
||||
content = "Posted a post"
|
||||
}
|
||||
var title *string
|
||||
title, _ = item.Body["title"].(*string)
|
||||
item.Publisher = user
|
||||
if err := NotifyUserSubscription(user, item, content, title); err != nil {
|
||||
log.Error().Err(err).Msg("An error occurred when notifying subscriptions user by user...")
|
||||
}
|
||||
for _, tag := range item.Tags {
|
||||
if err := NotifyTagSubscription(tag, user, item, content, title); err != nil {
|
||||
log.Error().Err(err).Msg("An error occurred when notifying subscriptions user by tag...")
|
||||
}
|
||||
}
|
||||
for _, category := range item.Categories {
|
||||
if err := NotifyCategorySubscription(category, user, item, content, title); err != nil {
|
||||
log.Error().Err(err).Msg("An error occurred when notifying subscriptions user by category...")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPost(user models.Publisher, item models.Post) (models.Post, error) {
|
||||
if item.Alias != nil && len(*item.Alias) == 0 {
|
||||
item.Alias = nil
|
||||
@ -563,60 +598,12 @@ func NewPost(user models.Publisher, item models.Post) (models.Post, error) {
|
||||
}
|
||||
|
||||
// Notify the original poster its post has been replied
|
||||
if item.ReplyID != nil {
|
||||
content, ok := item.Body["content"].(string)
|
||||
if !ok {
|
||||
content = "Posted a post"
|
||||
} else {
|
||||
content = TruncatePostContentShort(content)
|
||||
}
|
||||
|
||||
var op models.Post
|
||||
if err := database.C.
|
||||
Where("id = ?", item.ReplyID).
|
||||
Preload("Publisher").
|
||||
First(&op).Error; err == nil {
|
||||
if op.Publisher.AccountID != nil && op.Publisher.ID != user.ID {
|
||||
log.Debug().Uint("user", *op.Publisher.AccountID).Msg("Notifying the original poster their post got replied...")
|
||||
err = NotifyPosterAccount(
|
||||
op.Publisher,
|
||||
op,
|
||||
"Post got replied",
|
||||
fmt.Sprintf("%s (%s) replied you: %s", user.Nick, user.Name, content),
|
||||
"interactive.reply",
|
||||
fmt.Sprintf("%s replied your post #%d", user.Nick, *item.ReplyID),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("An error occurred when notifying user...")
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.ReplyID != nil && !item.IsDraft {
|
||||
go NotifyReplying(item, user)
|
||||
}
|
||||
|
||||
// Notify the subscriptions
|
||||
if item.ReplyID == nil {
|
||||
content, ok := item.Body["content"].(string)
|
||||
if !ok {
|
||||
content = "Posted a post"
|
||||
}
|
||||
var title *string
|
||||
title, _ = item.Body["title"].(*string)
|
||||
go func() {
|
||||
item.Publisher = user
|
||||
if err := NotifyUserSubscription(user, item, content, title); err != nil {
|
||||
log.Error().Err(err).Msg("An error occurred when notifying subscriptions user by user...")
|
||||
}
|
||||
for _, tag := range item.Tags {
|
||||
if err := NotifyTagSubscription(tag, user, item, content, title); err != nil {
|
||||
log.Error().Err(err).Msg("An error occurred when notifying subscriptions user by tag...")
|
||||
}
|
||||
}
|
||||
for _, category := range item.Categories {
|
||||
if err := NotifyCategorySubscription(category, user, item, content, title); err != nil {
|
||||
log.Error().Err(err).Msg("An error occurred when notifying subscriptions user by category...")
|
||||
}
|
||||
}
|
||||
}()
|
||||
if item.ReplyID == nil && !item.IsDraft {
|
||||
go NotifySubscribers(item, user)
|
||||
}
|
||||
|
||||
log.Debug().Dur("elapsed", time.Since(start)).Msg("The post is posted.")
|
||||
@ -665,6 +652,17 @@ func EditPost(item models.Post, og models.Post) (models.Post, error) {
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("An error occurred when updating post attachment meta...")
|
||||
}
|
||||
|
||||
if og.IsDraft && !item.IsDraft {
|
||||
// Notify the original poster its post has been replied
|
||||
if item.ReplyID != nil {
|
||||
go NotifyReplying(item, item.Publisher)
|
||||
}
|
||||
// Notify the subscriptions
|
||||
if item.ReplyID == nil {
|
||||
go NotifySubscribers(item, item.Publisher)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return item, err
|
||||
|
@ -48,9 +48,6 @@ func UniversalPostFilter(c *fiber.Ctx, tx *gorm.DB, cfg ...UniversalPostFilterCo
|
||||
if c.QueryBool("noReply", true) && !config.ShowReply {
|
||||
tx = FilterPostReply(tx)
|
||||
}
|
||||
if c.QueryBool("noCollapse", true) && !config.ShowCollapsed {
|
||||
tx = tx.Where("is_collapsed = ? OR is_collapsed IS NULL", false)
|
||||
}
|
||||
|
||||
if len(c.Query("author")) > 0 {
|
||||
var author models.Publisher
|
||||
|
@ -1,4 +1,4 @@
|
||||
package services
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/services"
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/proto"
|
||||
"git.solsynth.dev/hypernet/nexus/pkg/nex"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@ -32,44 +33,32 @@ func GetFeed(c *fiber.Ctx, limit int, user *uint, cursor *time.Time) ([]FeedEntr
|
||||
|
||||
// Planing the feed
|
||||
limitF := float64(limit)
|
||||
interCount := int(math.Ceil(limitF * 0.5))
|
||||
fediCount := int(math.Ceil(limitF * 0.25))
|
||||
newsCount := int(math.Ceil(limitF * 0.25))
|
||||
interCount := int(math.Ceil(limitF * 0.7))
|
||||
readerCount := int(math.Ceil(limitF * 0.3))
|
||||
|
||||
// Internal posts
|
||||
interTx, err := UniversalPostFilter(c, database.C)
|
||||
interTx, err := services.UniversalPostFilter(c, database.C)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare load interactive posts: %v", err)
|
||||
}
|
||||
if cursor != nil {
|
||||
interTx = interTx.Where("published_at < ?", *cursor)
|
||||
}
|
||||
interPosts, err := ListPostForFeed(interTx, interCount, user)
|
||||
posts, err := ListPostForFeed(interTx, interCount, user, c.Get("X-API-Version", "1"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load interactive posts: %v", err)
|
||||
}
|
||||
feed = append(feed, interPosts...)
|
||||
|
||||
// Fediverse posts
|
||||
fediTx := database.C
|
||||
if cursor != nil {
|
||||
fediTx = fediTx.Where("created_at < ?", *cursor)
|
||||
}
|
||||
fediPosts, err := ListFediversePostForFeed(fediTx, fediCount)
|
||||
if err != nil {
|
||||
return feed, fmt.Errorf("failed to load fediverse posts: %v", err)
|
||||
}
|
||||
feed = append(feed, fediPosts...)
|
||||
feed = append(feed, posts...)
|
||||
|
||||
sort.Slice(feed, func(i, j int) bool {
|
||||
return feed[i].CreatedAt.After(feed[j].CreatedAt)
|
||||
})
|
||||
|
||||
// News today - from Reader
|
||||
if news, err := ListNewsForFeed(newsCount, cursor); err != nil {
|
||||
if news, err := ListReaderPagesForFeed(readerCount, cursor); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to load news in getting feed...")
|
||||
} else {
|
||||
feed = append(feed, news)
|
||||
feed = append(feed, news...)
|
||||
}
|
||||
|
||||
return feed, nil
|
||||
@ -78,42 +67,34 @@ func GetFeed(c *fiber.Ctx, limit int, user *uint, cursor *time.Time) ([]FeedEntr
|
||||
// We assume the database context already handled the filtering and pagination
|
||||
// Only manage to pulling the content only
|
||||
|
||||
func ListPostForFeed(tx *gorm.DB, limit int, user *uint) ([]FeedEntry, error) {
|
||||
posts, err := ListPost(tx, limit, -1, "published_at DESC", user)
|
||||
func ListPostForFeed(tx *gorm.DB, limit int, user *uint, api string) ([]FeedEntry, error) {
|
||||
var posts []models.Post
|
||||
var err error
|
||||
rankOrder := `(COALESCE(total_upvote, 0) - COALESCE(total_downvote, 0) +
|
||||
LOG(1 + COALESCE(total_aggressive_views, 0))) /
|
||||
POWER(EXTRACT(EPOCH FROM NOW() - published_at) / 3600 + 2, 1.5) DESC`
|
||||
if api == "2" {
|
||||
posts, err = ListPost(tx, limit, -1, rankOrder, user)
|
||||
} else {
|
||||
posts, err = services.ListPost(tx, limit, -1, rankOrder, user)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := lo.Map(posts, func(post *models.Post, _ int) FeedEntry {
|
||||
entries := lo.Map(posts, func(post models.Post, _ int) FeedEntry {
|
||||
return FeedEntry{
|
||||
Type: "interactive.post",
|
||||
Data: TruncatePostContent(*post),
|
||||
Data: services.TruncatePostContent(post),
|
||||
CreatedAt: post.CreatedAt,
|
||||
}
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func ListFediversePostForFeed(tx *gorm.DB, limit int) ([]FeedEntry, error) {
|
||||
var posts []models.FediversePost
|
||||
if err := tx.
|
||||
Preload("User").Limit(limit).
|
||||
Find(&posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := lo.Map(posts, func(post models.FediversePost, _ int) FeedEntry {
|
||||
return FeedEntry{
|
||||
Type: "fediverse.post",
|
||||
Data: post,
|
||||
CreatedAt: post.CreatedAt,
|
||||
}
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func ListNewsForFeed(limit int, cursor *time.Time) (FeedEntry, error) {
|
||||
func ListReaderPagesForFeed(limit int, cursor *time.Time) ([]FeedEntry, error) {
|
||||
conn, err := gap.Nx.GetClientGrpcConn("re")
|
||||
if err != nil {
|
||||
return FeedEntry{}, fmt.Errorf("failed to get grpc connection with reader: %v", err)
|
||||
return nil, fmt.Errorf("failed to get grpc connection with reader: %v", err)
|
||||
}
|
||||
client := proto.NewFeedServiceClient(conn)
|
||||
request := &proto.GetFeedRequest{
|
||||
@ -124,16 +105,16 @@ func ListNewsForFeed(limit int, cursor *time.Time) (FeedEntry, error) {
|
||||
}
|
||||
resp, err := client.GetFeed(context.Background(), request)
|
||||
if err != nil {
|
||||
return FeedEntry{}, fmt.Errorf("failed to get feed from reader: %v", err)
|
||||
return nil, fmt.Errorf("failed to get feed from reader: %v", err)
|
||||
}
|
||||
var createdAt time.Time
|
||||
return FeedEntry{
|
||||
Type: "reader.news",
|
||||
CreatedAt: createdAt,
|
||||
Data: lo.Map(resp.Items, func(item *proto.FeedItem, _ int) map[string]any {
|
||||
cta := time.UnixMilli(int64(item.CreatedAt))
|
||||
createdAt = lo.Ternary(createdAt.Before(cta), cta, createdAt)
|
||||
return nex.DecodeMap(item.Content)
|
||||
}),
|
||||
}, nil
|
||||
return lo.Map(resp.Items, func(item *proto.FeedItem, _ int) FeedEntry {
|
||||
cta := time.UnixMilli(int64(item.CreatedAt))
|
||||
createdAt = lo.Ternary(createdAt.Before(cta), cta, createdAt)
|
||||
return FeedEntry{
|
||||
Type: item.Type,
|
||||
Data: nex.DecodeMap(item.Content),
|
||||
CreatedAt: cta,
|
||||
}
|
||||
}), nil
|
||||
}
|
@ -2,6 +2,9 @@ package queries
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
|
||||
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
|
||||
@ -16,47 +19,20 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// This api still is experimental and finally with replace the old one
|
||||
// Some changes between ListPost and ListPostV2:
|
||||
// - Post reply to and repost to are not included
|
||||
func ListPostV2(tx *gorm.DB, take int, offset int, order any, user *uint) ([]models.Post, error) {
|
||||
if take > 100 {
|
||||
take = 100
|
||||
}
|
||||
|
||||
if take >= 0 {
|
||||
tx = tx.Limit(take)
|
||||
}
|
||||
if offset >= 0 {
|
||||
tx = tx.Offset(offset)
|
||||
}
|
||||
|
||||
tx = tx.Preload("Tags").
|
||||
Preload("Categories").
|
||||
Preload("Publisher")
|
||||
|
||||
// Fetch posts
|
||||
var posts []models.Post
|
||||
if err := tx.Order(order).Find(&posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no posts found, return early
|
||||
if len(posts) == 0 {
|
||||
return posts, nil
|
||||
}
|
||||
var singularAttachmentFields = []string{"video", "thumbnail"}
|
||||
|
||||
func CompletePostMeta(in ...models.Post) ([]models.Post, error) {
|
||||
// Collect post IDs
|
||||
idx := make([]uint, len(posts))
|
||||
itemMap := make(map[uint]*models.Post, len(posts))
|
||||
for i, item := range posts {
|
||||
idx := make([]uint, len(in))
|
||||
itemMap := make(map[uint]*models.Post, len(in))
|
||||
for i, item := range in {
|
||||
idx[i] = item.ID
|
||||
itemMap[item.ID] = &item
|
||||
itemMap[item.ID] = &in[i]
|
||||
}
|
||||
|
||||
// Batch load reactions
|
||||
if mapping, err := services.BatchListPostReactions(database.C.Where("post_id IN ?", idx), "post_id"); err != nil {
|
||||
return posts, err
|
||||
return in, err
|
||||
} else {
|
||||
for postID, reactions := range mapping {
|
||||
if post, exists := itemMap[postID]; exists {
|
||||
@ -75,7 +51,7 @@ func ListPostV2(tx *gorm.DB, take int, offset int, order any, user *uint) ([]mod
|
||||
Where("reply_id IN (?)", idx).
|
||||
Group("post_id").
|
||||
Find(&replies).Error; err != nil {
|
||||
return posts, err
|
||||
return in, err
|
||||
}
|
||||
for _, info := range replies {
|
||||
if post, exists := itemMap[info.PostID]; exists {
|
||||
@ -84,18 +60,30 @@ func ListPostV2(tx *gorm.DB, take int, offset int, order any, user *uint) ([]mod
|
||||
}
|
||||
|
||||
// Batch load some metadata
|
||||
var err error
|
||||
var attachmentsRid []string
|
||||
var usersId []uint
|
||||
var realmsId []uint
|
||||
|
||||
// Scan records that can be load egearly
|
||||
for _, info := range posts {
|
||||
// Scan records that can be load eagerly
|
||||
var bodies []models.PostStoryBody
|
||||
{
|
||||
raw, _ := json.Marshal(lo.Map(in, func(item models.Post, _ int) map[string]any {
|
||||
return item.Body
|
||||
}))
|
||||
json.Unmarshal(raw, &bodies)
|
||||
}
|
||||
for idx, info := range in {
|
||||
if info.Publisher.AccountID != nil {
|
||||
usersId = append(usersId, *info.Publisher.AccountID)
|
||||
}
|
||||
if raw, ok := info.Body["attachments"].([]any); ok && len(raw) > 0 {
|
||||
attachmentsRid := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if str, ok := v.(string); ok {
|
||||
if info.RealmID != nil {
|
||||
realmsId = append(realmsId, *info.RealmID)
|
||||
}
|
||||
attachmentsRid = append(attachmentsRid, bodies[idx].Attachments...)
|
||||
for _, field := range singularAttachmentFields {
|
||||
if raw, ok := info.Body[field]; ok {
|
||||
if str, ok := raw.(string); ok && !strings.HasPrefix(str, "http") {
|
||||
attachmentsRid = append(attachmentsRid, str)
|
||||
}
|
||||
}
|
||||
@ -105,43 +93,150 @@ func ListPostV2(tx *gorm.DB, take int, offset int, order any, user *uint) ([]mod
|
||||
|
||||
// Batch load attachments
|
||||
attachmentsRid = lo.Uniq(attachmentsRid)
|
||||
attachments, err := filekit.ListAttachment(gap.Nx, attachmentsRid)
|
||||
if err != nil {
|
||||
return posts, fmt.Errorf("failed to load attachments: %v", err)
|
||||
var attachments []fmodels.Attachment
|
||||
if len(attachmentsRid) > 0 {
|
||||
attachments, err = filekit.ListAttachment(gap.Nx, attachmentsRid)
|
||||
if err != nil {
|
||||
return in, fmt.Errorf("failed to load attachments: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Batch load publisher users
|
||||
usersId = lo.Uniq(usersId)
|
||||
users, err := authkit.ListUser(gap.Nx, usersId)
|
||||
if err != nil {
|
||||
return posts, fmt.Errorf("failed to load users: %v", err)
|
||||
var users []amodels.Account
|
||||
if len(users) > 0 {
|
||||
users, err = authkit.ListUser(gap.Nx, usersId)
|
||||
if err != nil {
|
||||
return in, fmt.Errorf("failed to load users: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Batch load posts realm
|
||||
realmsId = lo.Uniq(realmsId)
|
||||
var realms []amodels.Realm
|
||||
if len(realmsId) > 0 {
|
||||
realms, err = authkit.ListRealm(gap.Nx, realmsId)
|
||||
if err != nil {
|
||||
return in, fmt.Errorf("failed to load realms: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Putting information back to data
|
||||
log.Info().Int("attachments", len(attachments)).Int("users", len(users)).Msg("Batch loaded metadata for listing post...")
|
||||
for idx, item := range posts {
|
||||
for idx, item := range in {
|
||||
var this []fmodels.Attachment
|
||||
var val []string
|
||||
if raw, ok := item.Body["attachments"].([]any); ok && len(raw) > 0 {
|
||||
val = lo.Map(raw, func(v any, _ int) string {
|
||||
return v.(string) // Safe if you're sure all elements are strings
|
||||
})
|
||||
} else if raw, ok := item.Body["attachments"].([]string); ok {
|
||||
val = raw
|
||||
}
|
||||
if len(val) > 0 {
|
||||
if len(bodies[idx].Attachments) > 0 {
|
||||
this = lo.Filter(attachments, func(item fmodels.Attachment, _ int) bool {
|
||||
return lo.Contains(val, item.Rid)
|
||||
return lo.Contains(bodies[idx].Attachments, item.Rid)
|
||||
})
|
||||
}
|
||||
for _, field := range singularAttachmentFields {
|
||||
if raw, ok := item.Body[field]; ok {
|
||||
if str, ok := raw.(string); ok {
|
||||
result := lo.FindOrElse(this, fmodels.Attachment{}, func(item fmodels.Attachment) bool {
|
||||
return item.Rid == str
|
||||
})
|
||||
if result.ID != 0 {
|
||||
item.Body[field] = result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item.Body["attachments"] = this
|
||||
item.Publisher.Account = lo.FindOrElse(users, amodels.Account{}, func(acc amodels.Account) bool {
|
||||
if item.Publisher.AccountID == nil {
|
||||
return false
|
||||
}
|
||||
return acc.ID == *item.Publisher.AccountID
|
||||
})
|
||||
posts[idx] = item
|
||||
if item.Publisher.AccountID != nil {
|
||||
item.Publisher.Account = lo.FindOrElse(users, amodels.Account{}, func(acc amodels.Account) bool {
|
||||
return acc.ID == *item.Publisher.AccountID
|
||||
})
|
||||
}
|
||||
if item.RealmID != nil {
|
||||
item.Realm = lo.ToPtr(lo.FindOrElse(realms, amodels.Realm{}, func(realm amodels.Realm) bool {
|
||||
return realm.ID == *item.RealmID
|
||||
}))
|
||||
}
|
||||
in[idx] = item
|
||||
}
|
||||
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func GetPost(tx *gorm.DB, id uint, user *uint) (models.Post, error) {
|
||||
var post models.Post
|
||||
if err := tx.Preload("Tags").
|
||||
Preload("Categories").
|
||||
Preload("Publisher").
|
||||
Preload("Poll").
|
||||
First(&post, id).Error; err != nil {
|
||||
return post, err
|
||||
}
|
||||
|
||||
out, err := CompletePostMeta(post)
|
||||
if err != nil {
|
||||
return post, err
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
services.AddPostView(post, *user)
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
func GetPostByAlias(tx *gorm.DB, alias, area string, user *uint) (models.Post, error) {
|
||||
var post models.Post
|
||||
if err := tx.Preload("Tags").
|
||||
Preload("Categories").
|
||||
Preload("Publisher").
|
||||
Preload("Poll").
|
||||
Where("alias = ?", alias).
|
||||
Where("alias_prefix = ?", area).
|
||||
First(&post).Error; err != nil {
|
||||
return post, err
|
||||
}
|
||||
|
||||
out, err := CompletePostMeta(post)
|
||||
if err != nil {
|
||||
return post, err
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
services.AddPostView(post, *user)
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
func ListPost(tx *gorm.DB, take int, offset int, order any, user *uint) ([]models.Post, error) {
|
||||
if take > 100 {
|
||||
take = 100
|
||||
}
|
||||
|
||||
if take >= 0 {
|
||||
tx = tx.Limit(take)
|
||||
}
|
||||
if offset >= 0 {
|
||||
tx = tx.Offset(offset)
|
||||
}
|
||||
|
||||
tx = tx.Preload("Tags").
|
||||
Preload("Categories").
|
||||
Preload("Publisher").
|
||||
Preload("Poll")
|
||||
|
||||
// Fetch posts
|
||||
var posts []models.Post
|
||||
if err := tx.Order(order).Find(&posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no posts found, return early
|
||||
if len(posts) == 0 {
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// Load data eagerly
|
||||
posts, err := CompletePostMeta(posts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add post views for the user
|
||||
|
@ -43,7 +43,6 @@ func main() {
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
log.Panic().Err(err).Msg("An error occurred when loading settings.")
|
||||
}
|
||||
services.ReadFriendConfig()
|
||||
|
||||
// Connect to nexus
|
||||
if err := gap.InitializeToNexus(); err != nil {
|
||||
@ -68,7 +67,6 @@ func main() {
|
||||
// Configure timed tasks
|
||||
quartz := cron.New(cron.WithLogger(cron.VerbosePrintfLogger(&log.Logger)))
|
||||
quartz.AddFunc("@every 5m", services.FlushPostViews)
|
||||
quartz.AddFunc("@every 5m", services.FetchFediverseTimedTask)
|
||||
quartz.Start()
|
||||
|
||||
// App
|
||||
|
@ -14,8 +14,8 @@ print_routes = false
|
||||
[security]
|
||||
internal_public_key = "keys/internal_public_key.pem"
|
||||
|
||||
[[fediverse.friends]]
|
||||
id = "mastodon-social"
|
||||
url = "https://mastodon.social"
|
||||
type = "mastodon"
|
||||
batch_size = 50
|
||||
#[[fediverse.friends]]
|
||||
#id = "mastodon-social"
|
||||
#url = "https://mastodon.social"
|
||||
#type = "mastodon"
|
||||
#batch_size = 50
|
||||
|
Reference in New Issue
Block a user