Compare commits

..

457 Commits

Author SHA1 Message Date
25459cf429 💥 Attachments not found in singular field now remains string 2025-04-06 23:23:54 +08:00
c96e5bffa1 Allow user to embed live stream webpage 2025-04-06 22:54:21 +08:00
7dbb858d69 Ranked in mixed feed 2025-04-06 14:11:27 +08:00
ac30cb5e4d Community mod able to uncollapse post 2025-04-05 12:17:48 +08:00
d189c5a8d8 👔 Updated collapse post 2025-04-05 12:12:57 +08:00
69e9a108ef 🐛 Fix compile 2025-04-05 01:22:13 +08:00
2c8fd4e89a 🗑️ Remove unused admin api 2025-04-05 01:13:20 +08:00
349d768d22 ♻️ Upgrade to new feed reader 2025-04-05 01:12:03 +08:00
3c3cbd9c29 🐛 Fix notify original poster wrongly 2025-04-01 23:38:48 +08:00
0034de71b5 ⬆️ Upgrade paperclip 2025-03-31 01:22:48 +08:00
068fffc1fd 🐛 Fix notified draft post 2025-03-31 00:55:50 +08:00
6f7a2de41e Eager to load realms
🐛 Trying to fix empty reactions list
2025-03-30 15:05:04 +08:00
ce9d663bc3 🚨 Fix compile issue 2025-03-30 14:47:53 +08:00
37dc92dc43 ♻️ Refactor all ListPost to versionable 2025-03-30 14:30:44 +08:00
79b12624d8 🚨 Fix compiling failed 2025-03-30 13:18:28 +08:00
4901557217 ♻️ Globally apply the V2 api 2025-03-30 13:15:00 +08:00
c51721505f 🐛 Fixed attachment loading in ListPostV2 2025-03-30 00:26:24 +08:00
515412d663 🐛 Fix compile issue 2025-03-29 23:54:44 +08:00
d6f0daca61 ListPostV2 can load singular attachment field 2025-03-29 23:51:58 +08:00
f91b8dadb8 🐛 Fix the ListPostV2 loading attachments again... 2025-03-29 23:47:50 +08:00
3c2a14800e 🐛 Fix attachments in body issue 2025-03-29 23:37:20 +08:00
fbe8e9b270 🐛 Fix scan attachments in ListPostV2 2025-03-29 23:28:46 +08:00
e588f2c87a 🐛 Trying to fix user eager loading in ListPostV2 2025-03-29 23:15:16 +08:00
bef5aa5c61 🔊 Add some logs in ListPostV2
🐛 Fix json tag
2025-03-29 23:08:53 +08:00
9fe0962c4a 🐛 Trying to fix list post v2 didn't get the data 2025-03-29 23:00:55 +08:00
b2466bdee1 🐛 Fix auto migrate issue 2025-03-29 22:48:10 +08:00
3cd102046a ⚗️ Testing with the new post listing v2 2025-03-29 22:38:43 +08:00
7a7d57c0d7 ⬆️ Upgrade nexus to fix bug 2025-03-29 16:00:24 +08:00
c8abf6647a ♻️ Rebuilt cache system with nexus cache 2025-03-29 13:49:52 +08:00
60d8c0b766 🐛 Fix large JWT header 2025-03-23 00:07:52 +08:00
8e33b99bca 🐛 Try to fix cursor return last same content 2025-03-17 00:33:30 +08:00
1b4d7822ed Realm post include public realm 2025-03-16 12:13:48 +08:00
1c927122c2 👽 Upgrade to new events audit system 2025-03-15 17:18:35 +08:00
ce257f83b8 💄 News in a hortizontal scroll pack 2025-03-15 14:24:13 +08:00
b4c78cd10c 🐛 Bug fixes in feed & recommendations 2025-03-15 14:14:55 +08:00
0fa75406ed Feed mixin with news 2025-03-15 14:07:44 +08:00
43447a1286 Allow authorization in get feed api 2025-03-15 12:47:54 +08:00
6e75f69263 Feed provider SDK 2025-03-15 12:40:58 +08:00
c4720934fc 🐛 Fix some bugs 2025-03-15 00:06:25 +08:00
93435b9ace 🐛 Fix url formatting issue 2025-03-14 23:18:16 +08:00
78d9bb5fc2 🐛 Allow masotdon fetch to decide to use trending or not 2025-03-14 23:07:56 +08:00
5126855b1d 🐛 Fix building issue 2025-03-13 22:29:28 +08:00
d39e34b256 🐛 Fix post in feed did not got turncated 2025-03-13 22:27:49 +08:00
f769c5c5b5 🐛 Fix cursor get wrong date 2025-03-13 21:39:25 +08:00
7a12d375c1 👔 Drop sensitive posts from mastodon 2025-03-13 21:26:34 +08:00
14335084a1 🐛 Fix feed pagination 2025-03-13 21:12:47 +08:00
32f2e3f1ed ♻️ Use trends statues api instead of public timeline in mastodon fetching 2025-03-13 13:11:37 +08:00
5f58a85892 🐛 Fix SQL query conflict 2025-03-12 23:30:21 +08:00
3dd6b7b6f7 ♻️ Refactor save fediverse data logic 2025-03-12 23:19:40 +08:00
02da524aaa 🐛 Fix mastodon fetch didn't have user avatar 2025-03-12 23:05:54 +08:00
95d12a0a2d 🐛 Fix the feed isn't sorted 2025-03-12 22:11:05 +08:00
d6fa3bb15d Universal reading feed
♻️ Refactor post listing
2025-03-12 22:02:33 +08:00
26dfd25763 🐛 Fix mastodon timeline identifier 2025-03-12 21:26:11 +08:00
74de589513 🔇 Disable logging of mastodon timeline response 2025-03-12 00:57:28 +08:00
d28669f527 🐛 Trying to fix mastodon timeline fetching 2025-03-12 00:54:07 +08:00
7e5d75998d 🐛 Try to fix config parse error 2025-03-12 00:47:13 +08:00
11322370b1 🔊 Add some logging in fediverse fetching 2025-03-12 00:43:17 +08:00
8a062bf07a 🐛 Fix didn't read friend config 2025-03-12 00:33:02 +08:00
359cb59bfe 🐛 Fix auto migration error 2025-03-12 00:24:59 +08:00
f0216bf770 Trigger fediverse scan by API 2025-03-12 00:17:55 +08:00
951713b37f 📝 Update example config 2025-03-12 00:12:36 +08:00
4b1953c8e7 Fetching other fediverse timeline 2025-03-12 00:09:26 +08:00
673e7a69f5 User actor in activitypub 2025-03-11 22:19:57 +08:00
b151acd6ac 🐛 Fix activitypub 2025-03-11 22:08:28 +08:00
2f5d2a9938 🐛 Fix webfinger api 2025-03-11 21:53:53 +08:00
15e38a9baf Support webfinger 2025-03-11 21:51:14 +08:00
abb0295858 Activitypub outbox pagination 2025-03-11 21:32:49 +08:00
e127a72850 ⚗️ Activitypub user outbox experimental 2025-03-11 21:26:59 +08:00
8f1605e1e9 🗑️ Clean up cleaner code 2025-03-11 20:49:45 +08:00
da38e224bb Optimize marking usage code 2025-03-11 13:15:58 +08:00
67a0219cf7 🐛 Fix panic 2025-03-11 00:36:52 +08:00
0126361b8d 🐛 Fix didn't unmark publisher avatar 2025-03-11 00:07:45 +08:00
76c78e0c01 🐛 Fix publisher info did not marked used 2025-03-10 23:58:25 +08:00
f06bc2d382 👽 Update attachment related call due to usage check update 2025-03-10 23:48:42 +08:00
10bf4fdf77 Recycle realm post when realm was deleted 2025-03-10 21:58:31 +08:00
cc47ff4583 🐛 Fix deletion 2025-03-10 21:44:15 +08:00
ba6e827cf8 🐛 Fix flags 2025-03-09 20:59:09 +08:00
63c42befb4 🗑️ Clean up code 2025-03-09 00:20:27 +08:00
5630988ac2 🐛 Fix some visibility issues 2025-03-08 23:14:47 +08:00
390ac7e883 🐛 Trying to fix some bugs 2025-03-08 22:56:36 +08:00
973ebe6d6d 🐛 Fix published at condition
👔 Auto set published at when post edited from draft
2025-03-08 22:25:42 +08:00
5f5f2bd1a5 🐛 Fix user cannot get draft posts 2025-03-08 21:38:41 +08:00
dd5ce8074b 🐛 Fix wrongly count aggressive view counts 2025-03-01 18:25:01 +08:00
4a7e41d4a9 👽 Update account delete handler 2025-03-01 14:10:45 +08:00
625e86a7fd 🐛 Trying to fix deleting post issue 2025-02-26 21:09:08 +08:00
94decc6bad Subscription now will contains related_post meta 2025-02-25 23:55:13 +08:00
fd31120312 🐛 Fix user has no publisher will causing filtering by visibility error 2025-02-22 23:19:11 +08:00
6228c186d7 🐛 Hotfix sql issue 2025-02-22 23:10:17 +08:00
14f4da6c91 🐛 Fixed post visibility 2025-02-22 23:03:40 +08:00
ed77a443b8 🐛 Fix getting publisher 2025-02-22 22:50:38 +08:00
87b6bee1f3 🐛 Fix visibility issue cause by searching 2025-02-22 22:38:38 +08:00
e35171ab81 🐛 Try to fix visibility 2025-02-22 22:19:50 +08:00
a3cb407d89 Post channel modes 2025-02-22 13:29:42 +08:00
be1beb713c 🐛 Fix filter with realm 2025-02-22 13:02:38 +08:00
a15a0d1c11 🐛 Fix realm-related fetch 2025-02-22 12:54:27 +08:00
4b89474534 🔊 Add more realm related verbose logging 2025-02-22 12:39:40 +08:00
ed8afe8324 Filtering post with realm in querystring 2025-02-20 21:36:47 +08:00
ecc2bff9a0 Filtering posts by user joined realms 2025-02-20 21:35:31 +08:00
e1f1cd5130 Create post with realm 2025-02-20 21:30:21 +08:00
5ce0e33359 🐛 Bug fixes on showing less content 2025-02-18 23:24:34 +08:00
b5f91b17e0 Increase the gap between flush post views being called 2025-02-18 21:46:21 +08:00
350cab0ff0 💄 Optimize reply notification 2025-02-18 00:29:39 +08:00
c738cff381 🐛 Fix flag endpoint uses Get method 2025-02-18 00:27:28 +08:00
c1567f713a 👔 Not showing collapsed post by default 2025-02-17 23:45:39 +08:00
12ffcb7c5b 🐛 Trying fix views count 2025-02-17 17:58:26 +08:00
3a4948d590 Collapse post according to flag, view ratio 2025-02-17 17:30:42 +08:00
269e79ca58 Count post views 2025-02-17 17:21:15 +08:00
14c17eded8 Flag post 2025-02-17 15:30:55 +08:00
db59826253 ⚗️ Play with activitypub 2025-02-16 18:21:48 +08:00
60fa7a5bff 👔 Increase the amount of featured post 2025-02-16 17:14:45 +08:00
a16c4c0764 🐛 Fix delete publisher will auto delete the posts posted by it 2025-02-16 17:11:36 +08:00
0e3b1b6a37 Reply notification now shows which post & content 2025-02-15 20:10:42 +08:00
9e8480640b 👔 Reply post no longer push subscriptions 2025-02-15 20:01:58 +08:00
a48553fff9 🐛 Trying to fix friend only 2025-02-15 18:02:12 +08:00
46d4082e38 🐛 Disable showing of friend only post currently 2025-02-15 16:23:48 +08:00
c454bd0d5c 🐛 Fix subscriptions 2025-02-15 16:07:58 +08:00
79c34c1225 🐛 Fix subscriptions wrong fk 2025-02-15 14:14:30 +08:00
d7ed6ee33b 🐛 Fix calculate percentage 2025-02-14 23:05:19 +08:00
ffb05154f9 🐛 Fix update current answer bug 2025-02-14 22:37:28 +08:00
a2633e6494 👔 Update answer if the poll answered 2025-02-13 22:19:37 +08:00
e16201a3ad Able to get own poll answer & poll answer percentage 2025-02-13 21:56:54 +08:00
429ca64f9d 🐛 Bug fix for getting none poll metric 2025-02-13 21:30:14 +08:00
39218d19f3 🐛 Fix migrate wrong model 2025-02-12 22:53:33 +08:00
9e2f2eedf9 Story post has poll 2025-02-12 22:40:48 +08:00
0904f91b01 Polls 2025-02-12 21:26:04 +08:00
1d92b8945e 🐛 Try to fix bugs 2025-02-10 00:11:29 +08:00
59f0288c27 Videos API 2025-02-08 15:11:52 +08:00
b756a9f19c 🐛 Fix edit question lost it answer 2025-02-08 13:27:25 +08:00
748f442cbc 🐛 Fix edit question panic 2025-02-08 13:20:34 +08:00
d8dabd002b 🐛 Fix answer question got select wont got paid 2025-02-07 22:49:11 +08:00
b888cf5acb 🐛 Fix go mod sum 2025-02-07 22:36:29 +08:00
57b908e5a0 Question APIs 2025-02-07 19:25:55 +08:00
9d9b2ac866 Save post insight result in database 2025-01-30 22:29:03 +08:00
39ef6f9e0a Add cache after respond 2025-01-30 02:04:26 +08:00
644c0e6afb 🐛 Fix insight cause crash 2025-01-30 02:03:58 +08:00
2ca6c615ff 🐛 Fix did not bind path for insight handler 2025-01-30 01:43:28 +08:00
e90458c049 Use HyperNet.Insight to generate insights 2025-01-30 01:43:05 +08:00
7a6dfeb9e6 🐛 Trying to fix update post attachment visibility issue 2025-01-27 00:39:39 +08:00
2bb979668c 🐛 Trying to fix bugs 2025-01-27 00:31:15 +08:00
2558b89b63 🔊 Verbose post editing logging 2025-01-26 23:56:05 +08:00
f609ac878a 🔊 Verbose updating visibility logs 2025-01-26 21:33:25 +08:00
86db065117 🔊 Verbose updating image visibility 2025-01-26 20:06:35 +08:00
ef8759e51d Post will update related attachments meta & policy 2025-01-24 17:32:27 +08:00
62d09717bb 🐛 Trying fix cannot edit tags & categories after published 2025-01-08 22:30:51 +08:00
19e1b5f7c6 🐛 Fix repost to & reply to content did not got truncated 2024-12-25 22:35:31 +08:00
1a3e43a12f 🐛 Fix editing post got rejected 2024-12-22 23:44:51 +08:00
8cd7dca8aa 🐛 Fix TimeZone issue 2024-12-22 23:34:53 +08:00
0e4c0e5017 🐛 Bug fixes on editing post 2024-12-22 23:25:25 +08:00
3c7ae284ac 🐛 Fix category filter 2024-12-22 15:49:02 +08:00
e37238436f 🐛 Trying fix category filter 2024-12-22 15:35:43 +08:00
6de2ef00a2 Better categories api 2024-12-22 14:35:19 +08:00
0ceee18524 🐛 Fix cannot get post by alias 2024-12-20 23:56:56 +08:00
c31d390a0b 🐛 Fix setting post total upvote wrong 2024-12-17 21:07:26 +08:00
34d166bec8 🐛 Bug fixes 2024-12-17 21:01:00 +08:00
710e2dc040 🐛 Bug fixes on post freezed data didn't update 2024-12-16 21:37:19 +08:00
ade3dbdeee 🐛 Fix post got truncated saved into db, close #5 2024-12-15 23:59:26 +08:00
3213a3969e 🐛 Fix post visibility didn't apply when notifying close #3 2024-12-15 23:50:54 +08:00
76e7dd7a07 Optimize user context filter query 2024-12-15 23:41:02 +08:00
79a61cc732 🐛 Fix posts filter with user context filtered all the posts 2024-12-11 23:05:31 +08:00
25eb0585d5 🐛 Fix list related publisher api ignore filter 2024-12-11 22:54:06 +08:00
050d3e3f89 Hide user blocked user's publisher's post 2024-12-11 22:46:04 +08:00
2543e1d125 Able to lookup publisher via user / realm 2024-12-11 22:17:18 +08:00
c125565896 👔 Prevent user from setting published at before it real published date 2024-12-10 22:27:35 +08:00
851c8e2f70 🐛 Only pick featured posts in posts which visibility is all 2024-12-10 21:12:35 +08:00
45d5ea5a68 🐛 Fix featured post query statement issue 2024-12-10 00:04:01 +08:00
fb33f4abc9 🐛 Trying to fix get featured posts failed 2024-12-10 00:02:31 +08:00
03f72d548d Add cache into post filter by user context 2024-12-09 22:38:44 +08:00
39889fe43d 🗑️ Remove the useless FilterWithRealm method 2024-12-09 22:24:55 +08:00
8d1db481de ♻️ Refactored user context visibility filter 2024-12-09 22:24:14 +08:00
d2ffde8469 Featured posts 2024-12-09 22:14:30 +08:00
bca0f9025c 👔 Allow post only with attachments
🐛 Fix thumbnail issue
2024-12-08 23:51:11 +08:00
08ba9ae758 Notify poster with avatar 2024-12-08 21:04:13 +08:00
d49c960be5 Better search api allow searching with tags or categories only 2024-12-08 14:09:34 +08:00
e1aad5519c 🐛 Fix thumbnail setting 2024-12-07 17:19:49 +08:00
2c138d06ee Allow filter post via tags 2024-12-01 22:51:56 +08:00
9dd03e0734 Customize publisher meta when creating
👔 Now user and org can have multiple publishers
2024-12-01 12:53:46 +08:00
09335ea99f 🐛 Fix count post content length 2024-11-25 00:33:57 +08:00
e906874b2e Count content length when truncate 2024-11-13 21:59:53 +08:00
615fe3ff6c 💥 Unified the publisher api 2024-11-09 00:47:19 +08:00
f9a01bff70 🐛 Bug fixes on query statement 2024-11-02 23:47:44 +08:00
553a87ab78 🐛 Fix publisher api didn't ensure auth cause crash 2024-11-02 14:05:42 +08:00
f14a9be3ec 🚚 Move from hydrogen to hypernet 2024-11-02 13:41:51 +08:00
ca64e056b9 🐛 Alloc wrong database 2024-11-02 13:41:11 +08:00
0132b91394 🐛 Fix api path 2024-11-02 10:35:36 +08:00
c185fde553 Publisher CRUD 2024-10-31 23:06:37 +08:00
1bd2da2850 🐛 Bug fixes on publishers and removal of dealer 2024-10-31 22:48:51 +08:00
001c9a8140 ♻️ Moved account-based post to publisher-based post 2024-10-31 22:41:32 +08:00
d889d22d11 Truncate notification embed posts 2024-10-17 20:05:05 +08:00
89b495577e 👔 Update search post include range 2024-10-16 21:01:17 +08:00
9bb55f10ce Add metadata to post related notification 2024-10-16 01:04:35 +08:00
4d602220d2 Able to subscribe to realm 2024-10-14 21:40:28 +08:00
6f26a44838 Record events into audit logs 2024-10-14 21:30:02 +08:00
362e691b37 Search posts 2024-10-13 21:25:15 +08:00
1e16e5c343 🐛 Fix truncate content panic 2024-10-13 20:32:32 +08:00
6c25f14189 🐛 Fix truncate make utf8 word garbled 2024-10-13 20:26:03 +08:00
d1bbf751d3 Truncate too content when listing 2024-10-13 20:12:23 +08:00
d6b6f2e399 🐛 Fix counting reactions in batch 2024-10-13 19:48:19 +08:00
db85cebe06 🐛 Trying to fix batch list reactions of post miscounted 2024-10-13 13:37:11 +08:00
f86a7527a9 Get the replying chain (conversation) (wip) (skip ci) 2024-10-13 13:33:22 +08:00
f5544e73f3 🐛 Bug fixes on visibility 2024-10-11 00:04:41 +08:00
aca0aa97aa Support broadcast deletion 2024-09-19 21:57:09 +08:00
8191ad8187 Permission requirement of posting in realm 2024-09-17 19:31:54 +08:00
277f1ee05f ⬆️ Upgrade dealer 2024-09-17 16:53:43 +08:00
b226409caf 🐛 Fix fix fix 2024-09-17 02:06:04 +08:00
9a59df6b5e 🐛 Provide the correct status code when subscription not found 2024-09-17 02:01:29 +08:00
16391ce18f 🐛 Bug fix again on determine subscriptions 2024-09-17 01:46:31 +08:00
86cdf28b22 🐛 Fix determine subscription has or not 2024-09-17 01:43:01 +08:00
d2f24d0be2 Subscriptions on the air 2024-09-17 00:35:42 +08:00
c01714b3fc CRUD of subscriptions 2024-09-17 00:12:09 +08:00
45698db199 🐛 Fix reply featured api path issue 2024-09-16 23:04:35 +08:00
41af202623 🐛 Fix get post will ignore visibility 2024-09-16 21:28:48 +08:00
13a65ad518 Feature replies
👔 Hide replies from listing by default
2024-09-16 21:24:48 +08:00
20e399bb39 🐛 Fix userinfo insert into wrong table 2024-09-14 21:43:50 +08:00
a131a5bf86 ♻️ Use the new dealer BaseUser and remove ExternalID 2024-09-11 23:42:46 +08:00
aab0724653 👔 Whats new will no longer only show friends' posts 2024-09-05 20:56:10 +08:00
9207a4f164 Prevent blocked user see friend visible level posts 2024-09-03 20:07:20 +08:00
486bb69977 Whats new API 2024-09-03 20:04:03 +08:00
a0a3ef09d0 ⬆️ Re-sum go mod 2024-08-23 19:38:32 +08:00
f89ec89b67 ⬆️ Upgrade dealer package 2024-08-23 19:36:44 +08:00
9a1e749649 Publisher api 2024-08-19 00:57:20 +08:00
ae269dfb3b 💥 Use realm alias instead of external id 2024-08-19 00:04:49 +08:00
bed420c16d 👽 Update attachment reference to string 2024-08-18 21:27:55 +08:00
4fac4a45b5 🚨 Re-sum to prevent build issue 2024-08-18 16:26:37 +08:00
9cf4068844 Complete friend visibility 2024-08-18 01:06:52 +08:00
a9bfb5767c 🐛 Fix validator still validating alias when it is blank 2024-08-18 00:07:53 +08:00
d3371a7240 🐛 Validate alias 2024-08-17 22:25:11 +08:00
c55d0579f5 🐛 Bug fix on wrong order of parameters in query 2024-08-17 17:12:04 +08:00
2470c4ac53 Better alias with region alias 2024-08-17 15:40:07 +08:00
008d531855 Alias is back 2024-08-17 15:19:59 +08:00
6e14684539 Thumbnail 2024-08-11 01:22:55 +08:00
2fbf7c4808 List post with minimal data 2024-08-10 21:11:55 +08:00
8aa627ef43 🐛 Fix visibility filter 2024-08-10 17:18:55 +08:00
fca4032031 Able to transfer story / article between realms 2024-08-10 16:45:42 +08:00
0383cdf407 🛂 Add permission node at create reactions 2024-08-04 22:24:41 +08:00
6bb5699ec4 🐛 Fix article edit won't save description 2024-08-04 17:45:54 +08:00
3a3c929858 🐛 Fix batch count replies issue 2024-08-02 04:53:25 +08:00
6638a9bf14 Search for tags 2024-07-31 01:29:09 +08:00
dbd71e8b1b Listing tags, get tag and get category 2024-07-31 01:25:51 +08:00
e9c7921d39 🐛 Fix visibility filter will filter user own psots 2024-07-30 17:59:14 +08:00
830484fee1 👔 Update the edited at detection logic 2024-07-30 12:31:33 +08:00
f19de97e8e List post also apply user context filter 2024-07-30 00:09:00 +08:00
ec0a2c3ac6 🐛 Fix posts query 2024-07-30 00:06:58 +08:00
2d66b8acc0 🐛 Fix visibility query type issue 2024-07-29 23:33:55 +08:00
2fe32d6c98 🐛 Fix visibility query issue 2024-07-29 23:24:54 +08:00
0fd742ffd2 🐛 Fix column naming issue in where statement 2024-07-29 23:07:12 +08:00
f07364269a 🐛 Fix column name issue 2024-07-29 23:02:38 +08:00
0519d99bbd 🐛 Try to fix post has no published at 2024-07-29 11:47:02 +08:00
7ae1f8021b 🐛 Try to fix the language detect issue 2024-07-28 22:01:24 +08:00
27994733dd ⚗️ Try to skip detecting language to fix online bugs 2024-07-28 21:40:47 +08:00
8cd154f0de 🔊 Adding logs into posting process 2024-07-28 21:36:47 +08:00
0c686d7f06 Locked post 2024-07-28 12:50:27 +08:00
09bc86da02 ⚗️ Basic featuring recommendation algorithm 2024-07-28 01:50:05 +08:00
bce500a5c2 Post visibility 2024-07-28 01:49:16 +08:00
16d1790fb1 New edited at property to prevent post background update shows as edited 2024-07-27 23:10:07 +08:00
2366c3fd42 🐛 Fix neutral reaction count as negative 2024-07-26 22:21:31 +08:00
30399be718 Each post has total down/upvote count 2024-07-26 21:09:53 +08:00
37aa69e6ca 🐛 Fix querystring naming issue 2024-07-26 16:15:08 +08:00
ee62f3bef8 Better down vote author filter system 2024-07-26 00:53:58 +08:00
a4d8a3b37f User can pin multiple posts 2024-07-25 22:58:47 +08:00
761d73100b Pinned post 2024-07-25 22:45:31 +08:00
c8d55d0b2c Edit post will auto help user correct data 2024-07-25 22:41:18 +08:00
f49cd3a892 🐛 Fix edit draft state wont set published at 2024-07-25 22:39:19 +08:00
2f8f799926 🐛 Fix query statement in shuffle 2024-07-24 14:37:21 +08:00
1925f26f16 🐛 Fix shuffle recommendation 2024-07-24 14:29:43 +08:00
93251c05f4 🐛 Fix reaction api 2024-07-24 00:10:04 +08:00
5af3e280b9 🐛 Fix validation conditions 2024-07-23 18:05:54 +08:00
fba515fc70 🐛 Fix list replies need non-exists alias 2024-07-23 17:59:11 +08:00
dda0687b1a 🐛 Fix buggy recommendation query statement 2024-07-23 17:33:17 +08:00
2cf24c4724 Recommendation API 2024-07-23 16:12:19 +08:00
8429d72ad1 🔀 Merge pull request '♻️ 一切尽在帖子表' (#4) from refactor/everything-in-post into master
Reviewed-on: Hydrogen/Interactive#4
2024-07-22 05:46:46 +00:00
7b8ca225a8 🗑️ Remove feed api 2024-07-22 13:41:35 +08:00
d4dfa810d1 Detect language in controller layer 2024-07-22 01:46:49 +08:00
96b36c1db4 Sort based on published at 2024-07-22 01:44:40 +08:00
f5664715f8 Publish until 2024-07-22 01:44:04 +08:00
045744aa18 Post type 2024-07-22 01:19:23 +08:00
3a5a84ae56 New story & article create & edit api 2024-07-22 00:49:36 +08:00
18dedfc493 🗑️ Remove the old article 2024-07-21 14:47:51 +08:00
e704c59664 Rollback api has no prefix 2024-07-16 18:05:41 +08:00
248f97742b 🗑️ Remove the frontend redirector 2024-07-16 16:53:40 +08:00
7f79c1a5ad 🐛 Fix count metrics issue 2024-07-16 16:52:00 +08:00
06acabcd1a 🐛 Fix wrong service type 2024-07-16 16:44:07 +08:00
75b7c20d62 🔀 Merge pull request '♻️ 迁移到 Dealer' (#3) from refactor/dealer into master
Reviewed-on: Hydrogen/Interactive#3
2024-07-16 07:51:11 +00:00
6ff637212f ♻️ Moved to dealer 2024-07-16 10:53:02 +08:00
c67a38f4bb Language's post 2024-07-13 23:16:40 +08:00
ad3257dabf Mixed draft list API 2024-07-09 21:07:46 +08:00
e4d2a625d9 🐛 Fix some feed api issue 2024-07-07 16:26:50 +08:00
50704892f0 🐛 Fix filter with tag & category query statement 2024-07-07 14:09:52 +08:00
b778822247 🐛 Fix search with tag & category 2024-07-07 13:57:31 +08:00
fe339e722f Allow other words appear in tag 2024-07-07 12:35:46 +08:00
4fa351b923 Load related post tags & categories 2024-07-07 12:31:05 +08:00
74cfd7ed23 🐛 Fix list post didn't preload tags & categories 2024-07-07 11:58:30 +08:00
15d8449d2e Preloading categories and tags 2024-07-07 11:34:37 +08:00
e836a97435 Mixed feed API 2024-07-06 21:40:50 +08:00
02d6801a7f 🐛 Fix mis spells 2024-07-05 21:06:54 +08:00
0015952f92 🐛 Fix batch list reactions cannot work properly 2024-07-05 21:06:18 +08:00
d7113b5237 🐛 Fix back compability of draft state 2024-07-05 20:56:41 +08:00
5b8eff7a42 ⬆️ Follow the permission nodes naming guidelines 2024-07-03 23:26:57 +08:00
93285e3ac1 Articles & Article CRUD APIs 2024-07-03 22:16:23 +08:00
396b5c6122 🐛 Fix panic via upgrade deps 2024-06-23 16:39:35 +08:00
247dfbb46c ⬆️ Upgrade Passport to add cache 2024-06-23 16:15:06 +08:00
8e38735ad8 🐛 Forgot add external id translator to feed API 2024-06-23 01:24:28 +08:00
c198565ced 🐛 Fix unable to list posts with realm id 2024-06-23 01:18:12 +08:00
608c52ace6 🐛 Fix check realm member issue 2024-06-23 01:07:03 +08:00
b58466e8f6 🐛 Re-sum go mod 2024-06-22 21:18:30 +08:00
a4ad17b038 🐛 Bug fixes on server register 2024-06-22 21:16:56 +08:00
dc86e33b9e 🐛 Fix dockerfile 2024-06-22 18:08:41 +08:00
c79c8d3618 ⬆️ Upgrade to support the latest version Hydrogen Project standard 2024-06-22 17:29:53 +08:00
52c864b11f 🐛 Fix notify type 2024-06-08 16:28:34 +08:00
11e6218185 🚨 Resum the go mod 2024-06-08 13:05:57 +08:00
fc493e82c3 ⬆️ Upgrade the Passport notify module 2024-06-08 12:33:10 +08:00
850efcdc8b 🐛 Bug fixes 2024-06-01 22:15:10 +08:00
890148d580 🐛 Fix list posts have no reply count 2024-05-25 17:43:26 +08:00
fe8c5d2821 ⬆️ Use the latest version of paperclip 2024-05-20 22:33:39 +08:00
c656dc184a 🐛 Fix list post cannot get reaction list 2024-05-19 20:15:28 +08:00
d75131cab4 🐛 Bug fixes thought postId is alias 2024-05-19 20:05:44 +08:00
4b8bc3e09b 🐛 Bug fixes in check attachment 2024-05-18 20:28:40 +08:00
7d36755a72 ⬆️ Use faster way to check attachment it exists 2024-05-18 16:59:58 +08:00
827423ae3f ⬆️ Using Paperclip as attachment provider 2024-05-17 20:52:13 +08:00
80a8a31726 🐛 Bug fixes of won't accept reply id 2024-05-15 20:31:25 +08:00
4709760edc 🗑️ Clean up code 2024-05-15 19:45:49 +08:00
da557fbe60 🐛 Fix delete moment issue 2024-05-08 21:42:36 +08:00
744a2511e9 🐛 Fix cannot edit moment 2024-05-08 21:42:06 +08:00
fa87a8e838 🐛 Use alias instead of id to link realm 2024-05-05 19:54:47 +08:00
dc7e83eb61 🐛 Fix create post need cloned realm id 2024-05-05 19:09:44 +08:00
1542507715 🚑 Fix import wrong services package cause server blew up 2024-05-05 17:13:25 +08:00
4fb31ef20e 🐛 Fix link external realm 2024-05-05 16:35:41 +08:00
69716216eb 🐛 Fix on import wrong package 2024-05-05 00:29:37 +08:00
f78a1447d5 ♻️ Move realm system to Passport 2024-05-04 22:22:58 +08:00
1181b1e5ce 🐛 Bug fixes of edit comment
 Comments now supports attachment!
2024-05-01 11:16:23 +08:00
82a6c6dd31 🐛 Fix notifier 2024-04-27 00:13:47 +08:00
017b67d738 🐛 Fix attitude 0 detected as non-provided 2024-04-15 23:22:54 +08:00
97e2bb586c 🐛 Fix go sum issue 2024-04-13 19:29:42 +08:00
4aae5700e0 🐛 Fix attachment transfer issue 2024-04-13 19:26:44 +08:00
ed9066ca7b 🐛 Fix friend detection 2024-04-06 23:23:29 +08:00
49363c801a Only can invite friends now 2024-04-06 12:05:54 +08:00
e74d38f479 Leave realm API 2024-04-06 11:58:48 +08:00
f36592dd37 Database Cleaner 2024-04-06 11:47:58 +08:00
f8377e7029 🐛 Fix notify 2024-03-31 22:49:32 +08:00
36bb84e48c Notify is back! 2024-03-31 21:46:59 +08:00
6e98029eb4 🔨 Fix dockerfile still build the frontend issue 2024-03-30 13:00:15 +08:00
ef3aa99827 Add SolarAgent launcher
🗑️ Remove the embed frontend
2024-03-30 12:56:18 +08:00
2116e5b390 Fix link account always update cache record 2024-03-26 22:49:12 +08:00
97e1d1f87e 🐛 Fix reaction and comment didn't count in spec api 2024-03-25 19:47:51 +08:00
562af023f1 Realm posts mixed in feed
💩 The feed api didn't respect the visibility level
2024-03-25 19:40:43 +08:00
fb0c7860e0 Instant upload 2024-03-24 19:01:18 +08:00
93d959e7a6 Real attachment deletion 2024-03-24 18:31:36 +08:00
bdfd74eaf4 Better attachment displaying
 Audio file support
2024-03-24 17:19:22 +08:00
19be4e2a67 Removable members 2024-03-23 16:52:00 +08:00
327941455e Invite accounts 2024-03-23 16:23:21 +08:00
311700db04 Realm members api 2024-03-23 13:05:05 +08:00
2b4ffb6811 🐛 Fix editor won't reset after posted post 2024-03-23 00:46:34 +08:00
1664fa62c4 🧪 More detailed debug config 2024-03-23 00:41:32 +08:00
36afe6ce3f 🐛 Fix UI bugs 2024-03-20 23:20:00 +08:00
b2ed560019 💄 Optimized UI styles 2024-03-20 23:15:46 +08:00
8c7338a752 💄 Now create post in realm will auto linking 2024-03-20 23:06:50 +08:00
87603e8bc5 🐛 Fix comment api 2024-03-20 22:33:39 +08:00
30e55a5c6e Moment editor C-V upload attachment 2024-03-20 22:32:50 +08:00
298e1fe617 ⬆️ Upgrade to latest identity 2024-03-20 21:42:14 +08:00
1eb7e88362 🚚 Update domain 2024-03-20 20:57:21 +08:00
12cbb670df 🐛 Fix code in wrong place 2024-03-19 22:54:21 +08:00
920492d5a6 Deletable realm
🐛 Fix data won't reload after go to another realm
2024-03-19 22:52:38 +08:00
a61da4f65e 🚨 Fix somewhere affected due refactored realm store 2024-03-19 22:08:56 +08:00
c14d3f70a3 Editable realm 2024-03-19 22:03:58 +08:00
2f87f9bc32 More places will show menu 2024-03-19 20:35:05 +08:00
b1518f030b 🍱 Fix favicon 2024-03-18 19:54:57 +08:00
437c2e5b4b 🐛 Fix didn't handle invalid auth sessions 2024-03-17 23:13:44 +08:00
ea460c3623 🚀 Realm alpha 2024-03-17 22:59:31 +08:00
d954cb87e6 Realm page 2024-03-17 22:43:31 +08:00
cbea87f74d Realm list 2024-03-17 22:08:33 +08:00
1505244726 🚀 Launch v2 2024-03-16 16:36:35 +08:00
bc2517dcda 🔀 Merge pull request '♻️ Interactive v2' (#1) from refactor/v2 into master
Reviewed-on: Hydrogen/Interactive#1
2024-03-16 08:22:25 +00:00
03f21927ad 🚀 Ready to launch v2 2024-03-16 16:15:36 +08:00
ad863b4844 Fit Hydrogen.Identity 2024-03-16 16:14:45 +08:00
a229faf013 🔨 Add v2 Build Script 2024-03-11 23:57:00 +08:00
4d593fa20a 🐛 Fix bugs 2024-03-11 23:55:00 +08:00
73aaa6af56 Better navigator 2024-03-11 23:50:36 +08:00
7f89b36efd Better details page 2024-03-11 23:47:19 +08:00
f5ebc1748a Deletion 2024-03-11 23:03:07 +08:00
7b2a6c3709 🗑️ Remove trash files 2024-03-10 23:36:29 +08:00
8391c95abc Editable 2024-03-10 23:35:38 +08:00
965feaa26d Paste media 2024-03-10 20:58:05 +08:00
c1fe67bb75 💄 Optimize UX 2024-03-10 20:29:33 +08:00
38ba4d9c75 Attachments 2024-03-10 18:38:42 +08:00
192d0c40bb 🎨 Optimized structure 2024-03-09 17:47:39 +08:00
1f69fcbfb4 Article editor 2024-03-09 17:32:45 +08:00
fa5b166d88 🐛 Fix feed API sorting issue 2024-03-07 23:39:51 +08:00
ad88fe312f Sidebar userinfo 2024-03-07 23:22:40 +08:00
3300e46e88 Post & Comment Editors 2024-03-07 22:41:00 +08:00
f9438b4d89 Comments 2024-03-06 23:31:22 +08:00
450c1e4450 Comments API 2024-03-06 22:35:10 +08:00
69ced62715 ♻️ Use the universal post api replace some duplicated apis 2024-03-05 23:40:54 +08:00
1e366af3b8 Reactions 2024-03-04 22:13:25 +08:00
932bdf1e5a Reaction info 2024-03-03 22:57:17 +08:00
1725724758 Frontend move to union feed 2024-03-03 21:24:08 +08:00
e1822e5363 💩 Randomly write some SQL and create feed API 2024-03-03 19:10:08 +08:00
a528f293f6 Other datasets CRUD 2024-03-03 16:10:25 +08:00
bb565965da ♻️ Reaction instead of like / dislike 2024-03-03 12:17:18 +08:00
c23bde901b New datasets 2024-03-03 01:23:11 +08:00
e0bb05bee8 New post editor basis 2024-03-02 21:40:09 +08:00
3ae72cd9e0 ♻️ Brand new post list 2024-03-02 20:01:59 +08:00
178f80c707 🎉 Reinital Commit 2024-03-02 12:29:16 +08:00
1e04f2029f Use uni-token
💄 A lot of optimization
2024-02-21 22:58:51 +08:00
4101043d65 🚑 Fix request was not imported 2024-02-19 19:44:56 +08:00
65ee99c213 🐛 Bug fixes of garfish api 2024-02-19 19:37:43 +08:00
cd12b6ce8d Support for garfish 2024-02-19 18:02:50 +08:00
7cecb8f8f7 ⬆️ Upgrade passport to identity 2024-02-18 22:41:18 +08:00
9986fb0b9c Cached attachments 2024-02-18 19:54:56 +08:00
ed578ec315 🗑️ Remove .DS_Store 2024-02-18 03:30:02 +00:00
34cb534342 Add page number in query string 2024-02-15 18:37:27 +08:00
520ab738f9 🐛 Fix icon issue 2024-02-15 18:22:20 +08:00
69b30c7dc6 Count replies and reposts 2024-02-14 22:30:07 +08:00
31a09a9074 Better categories 2024-02-14 22:03:45 +08:00
2e9304fecd 💄 Fix styles 2024-02-14 19:23:57 +08:00
791ae438df 🚨 Fix ts check issue 2024-02-14 19:18:53 +08:00
7dcbf9908e 💄 Better navbar 2024-02-14 19:12:12 +08:00
c1b444fbeb 💄 Optimized post controls 2024-02-14 19:01:54 +08:00
b0c9368530 Feed no longer show replies 2024-02-14 18:48:02 +08:00
1baffd4200 Bottom Navigation 2024-02-14 18:09:44 +08:00
2480dd9b6e 🐛 Bug fixes of upload 2024-02-12 21:11:41 +08:00
4e2147b679 Bug fixes 2024-02-12 17:54:36 +08:00
4690e8be10 Notify to followers when their following account posted 2024-02-12 17:24:17 +08:00
c5ed5e8df6 🐛 Add limitation of pagination 2024-02-12 12:32:37 +08:00
5ec6cacb69 🐛 Fix won't add attachment 2024-02-11 23:33:35 +08:00
a1c94919f7 🐛 Fix creators center won't get post in realm 2024-02-11 13:59:48 +08:00
3a0daab641 🐛 Bug fixes 2024-02-11 13:35:10 +08:00
a5d6071bef Creator hub 2024-02-11 13:12:37 +08:00
4dbbb423e7 Changeable permalink 2024-02-10 12:05:34 +08:00
a223c85ed1 More embed options available 2024-02-10 01:53:57 +08:00
c7d250d201 🐛 Fix edit cannot edit tags, categories and attachments 2024-02-10 01:39:58 +08:00
5ce552f1be 🚑 Fix nil pointer 2024-02-09 18:43:48 +08:00
9d64449ae4 ✏️ Fix typo 2024-02-09 18:25:28 +08:00
64569cb499 Reply do not post in realm 2024-02-09 18:09:15 +08:00
57dc2771c2 Realms utilities 2024-02-09 15:19:43 +08:00
012ee55c3a Realm permission check 2024-02-09 12:36:39 +08:00
798e78ff8e ♻️ Use unified post list api 2024-02-09 11:43:21 +08:00
86e184a803 💄 UX Optimized 2024-02-09 00:44:20 +08:00
8d1cb9d9c0 🐛 Bug fixes 2024-02-08 22:52:04 +08:00
2ea1032b9c 📄 Add license 2024-02-08 20:41:45 +08:00
04d1970dc0 Reply will notify original poster 2024-02-08 18:47:29 +08:00
834c9de463 💄 Fix styles 2024-02-08 18:11:57 +08:00
da7599edf2 💄 Fix styles issue 2024-02-08 16:51:44 +08:00
8fd34d1bb7 🐛 Bug fixes 2024-02-08 03:38:57 +08:00
6a48508ffd 🐛 Fix styles issue 2024-02-08 03:27:40 +08:00
fb2d2a3b84 Searchable tags and categories 2024-02-07 16:41:35 +08:00
a619f4ca23 Fix UX in post list 2024-02-07 14:47:05 +08:00
8ea3e4763c 🐛 Increase body size limit 2024-02-06 20:02:47 +08:00
31e244220b 🐛 Bug fixes in list post with author 2024-02-06 11:21:23 +08:00
6edee6c048 💄 Fix some style issue 2024-02-06 11:15:21 +08:00
4d618a91fe 🐛 Fix get single won't get react info 2024-02-06 10:57:05 +08:00
bbdc8e6aa6 Post own page 2024-02-06 01:10:22 +08:00
d3adb20b0e 💄 Optimize styles 2024-02-05 23:03:13 +08:00
e08fb6f4de :typo: Fix typo in publish area 2024-02-05 22:56:24 +08:00
7cf43656d8 🐛 Fix read out of scope 2024-02-05 22:54:41 +08:00
f3ff8e8222 🐛 Fix editing 2024-02-05 22:22:53 +08:00
3f005a7c5f Markdown content 2024-02-05 21:56:18 +08:00
9bc270c12f Login promote 2024-02-05 21:47:17 +08:00
f8f8c3c3b5 🐛 Fix attachments display 2024-02-05 21:27:19 +08:00
158 changed files with 7468 additions and 4012 deletions

46
.air.toml Normal file
View File

@ -0,0 +1,46 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "dist"
[build]
args_bin = []
bin = "./dist/server"
cmd = "go build -o ./dist/server ./pkg/cmd/main.go"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "pkg/views"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

View File

@ -2,7 +2,7 @@ name: release-nightly
on:
push:
branches: [ master ]
branches: [master]
jobs:
build-docker:

5
.gitignore vendored
View File

@ -1 +1,6 @@
/uploads
/dist
/keys
.DS_Store
.idea

8
.idea/.gitignore generated vendored
View File

@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

9
.idea/Interactive.iml generated
View File

@ -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>

8
.idea/modules.xml generated
View File

@ -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
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -1,15 +1,9 @@
# Building Backend
FROM golang:alpine as interactive-server
RUN apk add nodejs npm
WORKDIR /source
COPY . .
WORKDIR /source/pkg/view
RUN npm install
RUN npm run build
WORKDIR /source
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -buildvcs -o /dist ./pkg/cmd/main.go
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -buildvcs -o /dist ./pkg/main.go
# Runtime
FROM golang:alpine

120
go.mod
View File

@ -1,71 +1,105 @@
module code.smartsheep.studio/hydrogen/interactive
module git.solsynth.dev/hypernet/interactive
go 1.21.6
go 1.23.2
require (
github.com/go-playground/validator/v10 v10.17.0
github.com/gofiber/fiber/v2 v2.52.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.5.0
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
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-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/rs/zerolog v1.31.0
github.com/samber/lo v1.39.0
github.com/spf13/viper v1.18.2
golang.org/x/crypto v0.18.0
golang.org/x/oauth2 v0.16.0
gorm.io/datatypes v1.2.0
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.6
github.com/pemistahl/lingua-go v1.4.0
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.33.0
github.com/samber/lo v1.47.0
github.com/spf13/viper v1.19.0
google.golang.org/grpc v1.70.0
google.golang.org/protobuf v1.36.4
gorm.io/datatypes v1.2.4
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.12
)
require (
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eko/gocache/lib/v4 v4.2.0 // indirect
github.com/eko/gocache/store/redis/v4 v4.2.2 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-ap/errors v0.0.0-20250124135319-3da8adefd4a9 // indirect
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 // indirect
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.7.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/go-sql-driver/mysql v1.8.1 // 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
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/pgx/v5 v5.5.1 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.70 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/philhofer/fwd v1.1.2 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/nats-io/nats.go v1.37.0 // indirect
github.com/nats-io/nkeys v0.4.7 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/prometheus/client_golang v1.19.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.52.3 // indirect
github.com/prometheus/procfs v0.13.0 // indirect
github.com/redis/go-redis/v9 v9.7.3 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sagikazarmark/locafero v0.6.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/tinylib/msgp v1.2.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/valyala/fasthttp v1.59.0 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
go.uber.org/mock v0.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.32.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.5.2 // indirect
gorm.io/driver/mysql v1.5.7 // indirect
)

312
go.sum
View File

@ -1,86 +1,139 @@
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.solsynth.dev/hypernet/insight v0.0.0-20250129172551-974266b2c1d2 h1:dPBdssDIb+SqkAYlZgv80iftkfiijpMBCKe/6o4jwuA=
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-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=
git.solsynth.dev/hypernet/pusher v0.0.0-20250216145944-5fb769823a88/go.mod h1:ildzMtLagNsLK0Rkw4Hgk2TrrwqZnjwJIUx0MNZwcDY=
git.solsynth.dev/hypernet/wallet v0.0.0-20250323095812-468cd655f886 h1:rVssXF8jZ64ctAfzlCgIgF22NCT9VAPAVxrwlcItx3s=
git.solsynth.dev/hypernet/wallet v0.0.0-20250323095812-468cd655f886/go.mod h1:rmomNGQ6RBSp8TpZGA8tFr5M54AL2NADJ/1n0MfrIRM=
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4HHsCo6xi2oWZYKWW4bly/Ory9FuTpFPRxj/mAg=
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eko/gocache/lib/v4 v4.2.0 h1:MNykyi5Xw+5Wu3+PUrvtOCaKSZM1nUSVftbzmeC7Yuw=
github.com/eko/gocache/lib/v4 v4.2.0/go.mod h1:7ViVmbU+CzDHzRpmB4SXKyyzyuJ8A3UW3/cszpcqB4M=
github.com/eko/gocache/store/redis/v4 v4.2.2 h1:Thw31fzGuH3WzJywsdbMivOmP550D6JS7GDHhvCJPA0=
github.com/eko/gocache/store/redis/v4 v4.2.2/go.mod h1:LaTxLKx9TG/YUEybQvPMij++D7PBTIJ4+pzvk0ykz0w=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-ap/activitypub v0.0.0-20250212090640-aeb6499ba581 h1:73sFEdBsWBTBut0aDMPgt8HRuMO+ML0fd8AA/zjO8BQ=
github.com/go-ap/activitypub v0.0.0-20250212090640-aeb6499ba581/go.mod h1:IO2PtAsxfGXN5IHrPuOslENFbq7MprYLNOyiiOELoRQ=
github.com/go-ap/errors v0.0.0-20250124135319-3da8adefd4a9 h1:AJBGzuJVgfkKF3LoXCNQfH9yWmsVDV/oPDJE/zeXOjE=
github.com/go-ap/errors v0.0.0-20250124135319-3da8adefd4a9/go.mod h1:Vkh+Z3f24K8nMsJKXo1FHn5ebPsXvB/WDH5JRtYqdNo=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74=
github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE=
github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI=
github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.70 h1:1u9NtMgfK1U42kUxcsl5v0yj6TEOPR497OAQxpJnn2g=
github.com/minio/minio-go/v7 v7.0.70/go.mod h1:4yBA8v80xGA30cfM3fz0DKYMXunWl/AV/6tWEs9ryzo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -88,114 +141,137 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/nats-io/nats.go v1.37.0 h1:07rauXbVnnJvv1gfIyghFEo6lUcYRY0WXc3x7x0vUxE=
github.com/nats-io/nats.go v1.37.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8=
github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI=
github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM=
github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.52.3 h1:5f8uj6ZwHSscOGNdIQg6OiZv/ybiK2CO2q2drVZAQSA=
github.com/prometheus/common v0.52.3/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U=
github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI=
github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE=
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287 h1:J1H9f+LEdWAfHcez/4cvaVBox7cOYT+IU6rgqj5x++8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250127172529-29210b9bc287/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -204,16 +280,16 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco=
gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/datatypes v1.2.4 h1:uZmGAcK/QZ0uyfCuVg0VQY1ZmV9h1fuG0tMwKByO1z4=
gorm.io/datatypes v1.2.4/go.mod h1:f4BsLcFAX67szSv8svwLRjklArSHAvHLeE3pXAS5DZI=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.6 h1:V92+vVda1wEISSOMtodHVRcUIOPYa2tgQtyF+DfFx+A=
gorm.io/gorm v1.25.6/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=

661
license Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -1,52 +0,0 @@
package main
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/server"
"os"
"os/signal"
"syscall"
interactive "code.smartsheep.studio/hydrogen/interactive/pkg"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
func init() {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout})
}
func main() {
// Configure settings
viper.AddConfigPath(".")
viper.AddConfigPath("..")
viper.SetConfigName("settings")
viper.SetConfigType("toml")
// Load settings
if err := viper.ReadInConfig(); err != nil {
log.Panic().Err(err).Msg("An error occurred when loading settings.")
}
// Connect to database
if err := database.NewSource(); err != nil {
log.Fatal().Err(err).Msg("An error occurred when connect to database.")
} else if err := database.RunMigration(database.C); err != nil {
log.Fatal().Err(err).Msg("An error occurred when running database auto migration.")
}
// Server
server.NewServer()
go server.Listen()
// Messages
log.Info().Msgf("Interactive v%s is started...", interactive.AppVersion)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info().Msgf("Interactive v%s is quitting...", interactive.AppVersion)
}

View File

@ -1,24 +0,0 @@
package database
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"gorm.io/gorm"
)
func RunMigration(source *gorm.DB) error {
if err := source.AutoMigrate(
&models.Account{},
&models.AccountMembership{},
&models.Realm{},
&models.Category{},
&models.Tag{},
&models.Post{},
&models.PostLike{},
&models.PostDislike{},
&models.Attachment{},
); err != nil {
return err
}
return nil
}

View File

@ -1,28 +0,0 @@
package database
import (
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"github.com/spf13/viper"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
var C *gorm.DB
func NewSource() error {
var err error
dialector := postgres.Open(viper.GetString("database.dsn"))
C, err = gorm.Open(dialector, &gorm.Config{NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("database.prefix"),
}, Logger: logger.New(&log.Logger, logger.Config{
Colorful: true,
IgnoreRecordNotFoundError: true,
LogLevel: lo.Ternary(viper.GetBool("debug"), logger.Info, logger.Silent),
})})
return err
}

View File

@ -0,0 +1,32 @@
package database
import (
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"gorm.io/gorm"
)
var AutoMaintainRange = []any{
&models.Publisher{},
&models.Category{},
&models.Tag{},
&models.Post{},
&models.PostInsight{},
&models.Subscription{},
&models.Poll{},
&models.PollAnswer{},
&models.PostFlag{},
&models.PostView{},
}
func RunMigration(source *gorm.DB) error {
if err := source.AutoMigrate(
append(
AutoMaintainRange,
&models.Reaction{},
)...,
); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,31 @@
package database
import (
"fmt"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"github.com/spf13/viper"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var C *gorm.DB
func NewGorm() error {
dsn, err := cruda.NewCrudaConn(gap.Nx).AllocDatabase("interactive")
if err != nil {
return fmt.Errorf("failed to alloc database from nexus: %v", err)
}
C, err = gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.New(&log.Logger, logger.Config{
Colorful: true,
IgnoreRecordNotFoundError: true,
LogLevel: lo.Ternary(viper.GetBool("debug.database"), logger.Info, logger.Silent),
})})
return err
}

View File

@ -0,0 +1,62 @@
package gap
import (
"fmt"
"strings"
"time"
"git.solsynth.dev/hypernet/nexus/pkg/nex"
"git.solsynth.dev/hypernet/nexus/pkg/nex/cachekit"
"git.solsynth.dev/hypernet/pusher/pkg/pushkit/pushcon"
"github.com/samber/lo"
"git.solsynth.dev/hypernet/nexus/pkg/proto"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
var (
Nx *nex.Conn
Px *pushcon.Conn
Ca *cachekit.Conn
)
func InitializeToNexus() error {
grpcBind := strings.SplitN(viper.GetString("grpc_bind"), ":", 2)
httpBind := strings.SplitN(viper.GetString("bind"), ":", 2)
outboundIp, _ := nex.GetOutboundIP()
grpcOutbound := fmt.Sprintf("%s:%s", outboundIp, grpcBind[1])
httpOutbound := fmt.Sprintf("%s:%s", outboundIp, httpBind[1])
var err error
Nx, err = nex.NewNexusConn(viper.GetString("nexus_addr"), &proto.ServiceInfo{
Id: viper.GetString("id"),
Type: "co",
Label: "Interactive",
GrpcAddr: grpcOutbound,
HttpAddr: lo.ToPtr("http://" + httpOutbound + "/api"),
})
if err == nil {
go func() {
err := Nx.RunRegistering()
if err != nil {
log.Error().Err(err).Msg("An error occurred while registering service...")
}
}()
}
Px, err = pushcon.NewConn(Nx)
if err != nil {
return fmt.Errorf("error during initialize pushcon: %v", err)
}
Ca, err = cachekit.NewConn(Nx, 3*time.Second)
if err != nil {
return fmt.Errorf("error during initialize cachekit: %v", err)
}
return err
}

View File

@ -0,0 +1,26 @@
package grpc
import (
"context"
health "google.golang.org/grpc/health/grpc_health_v1"
"time"
)
func (v *App) Check(ctx context.Context, request *health.HealthCheckRequest) (*health.HealthCheckResponse, error) {
return &health.HealthCheckResponse{
Status: health.HealthCheckResponse_SERVING,
}, nil
}
func (v *App) Watch(request *health.HealthCheckRequest, server health.Health_WatchServer) error {
for {
if server.Send(&health.HealthCheckResponse{
Status: health.HealthCheckResponse_SERVING,
}) != nil {
break
}
time.Sleep(1000 * time.Millisecond)
}
return nil
}

View File

@ -0,0 +1,38 @@
package grpc
import (
"git.solsynth.dev/hypernet/nexus/pkg/proto"
"github.com/spf13/viper"
"google.golang.org/grpc"
health "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
"net"
)
type App struct {
proto.UnimplementedDirectoryServiceServer
srv *grpc.Server
}
func NewGrpc() *App {
server := &App{
srv: grpc.NewServer(),
}
health.RegisterHealthServer(server.srv, server)
proto.RegisterDirectoryServiceServer(server.srv, server)
reflection.Register(server.srv)
return server
}
func (v *App) Listen() error {
listener, err := net.Listen("tcp", viper.GetString("grpc_bind"))
if err != nil {
return err
}
return v.srv.Serve(listener)
}

View File

@ -0,0 +1,59 @@
package grpc
import (
"context"
"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/nexus/pkg/nex"
"git.solsynth.dev/hypernet/nexus/pkg/proto"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
)
func (v *App) BroadcastEvent(ctx context.Context, in *proto.EventInfo) (*proto.EventResponse, error) {
switch in.GetEvent() {
case "deletion":
data := nex.DecodeMap(in.GetData())
resType, ok := data["type"].(string)
if !ok {
break
}
switch resType {
case "account":
var data struct {
ID int `json:"id"`
}
if err := jsoniter.Unmarshal(in.GetData(), &data); err != nil {
break
}
tx := database.C.Begin()
for _, model := range database.AutoMaintainRange {
switch model.(type) {
default:
tx.Delete(model, "account_id = ?", data.ID)
}
}
tx.Commit()
case "realm":
var data struct {
ID int `json:"id"`
}
if err := jsoniter.Unmarshal(in.GetData(), &data); err != nil {
break
}
var posts []models.Post
if err := database.C.Where("realm_id = ?", data.ID).
Select("Body").Select("ID").
Find(&posts).Error; err != nil {
break
}
if err := services.DeletePostInBatch(posts); err != nil {
log.Error().Err(err).Msg("An error occurred when deleting post...")
}
}
}
return &proto.EventResponse{}, nil
}

View File

@ -0,0 +1,6 @@
package admin
import "github.com/gofiber/fiber/v2"
func MapControllers(app *fiber.App, baseURL string) {
}

View File

@ -0,0 +1,146 @@
package api
import (
"fmt"
"log"
"math"
"net/http"
"strconv"
"time"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/interactive/pkg/internal/services"
"github.com/go-ap/activitypub"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
)
func apUserInbox(c *fiber.Ctx) error {
name := c.Params("name")
var activity activitypub.Activity
if err := c.BodyParser(&activity); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid activitypub event")
}
// TODO Handle all these
switch activity.Type {
case activitypub.LikeType:
log.Printf("User %s received a Like on: %s", name, activity.Object.GetID())
case activitypub.FollowType:
log.Printf("User %s received a Follow request from: %s", name, activity.Actor.GetID())
case activitypub.CreateType:
log.Printf("New post received for %s: %s", name, activity.Object.GetID())
default:
log.Printf("Unhandled activity type received: %+v", activity)
}
return c.Status(http.StatusAccepted).SendString("Activity received")
}
func apUserOutbox(c *fiber.Ctx) error {
name := c.Params("name")
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10)
if limit > 100 {
limit = 100
}
var publisher models.Publisher
if err := database.C.Where("name = ?", name).First(&publisher).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
tx, err := services.UniversalPostFilter(c, database.C)
tx.Where("publisher_id = ? AND reply_id IS NULL", publisher.ID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
count, err := services.CountPost(tx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
var activities []activitypub.Item
if posts, err := services.ListPost(tx, limit, (page-1)*limit, "published_at DESC", nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else {
for _, post := range posts {
var content string
if val, ok := post.Body["content"].(string); ok {
content = val
} else {
content = "Posted a post"
}
note := activitypub.Note{
ID: services.GetActivityID("/posts/" + strconv.Itoa(int(post.ID))),
Type: activitypub.NoteType,
Attachment: nil,
AttributedTo: services.GetActivityIRI("/users/" + publisher.Name),
Published: lo.TernaryF(post.PublishedAt == nil, func() time.Time {
return post.CreatedAt
}, func() time.Time {
return *post.PublishedAt
}),
Updated: lo.TernaryF(post.EditedAt == nil, func() time.Time {
return post.UpdatedAt
}, func() time.Time {
return *post.EditedAt
}),
To: activitypub.ItemCollection{activitypub.PublicNS},
Content: activitypub.DefaultNaturalLanguageValue(content),
}
activity := activitypub.Create{
ID: services.GetActivityID("/activities/posts/" + strconv.Itoa(int(post.ID))),
Type: activitypub.CreateType,
Actor: services.GetActivityIRI("/users/" + publisher.Name),
Object: note,
}
activities = append(activities, activity)
}
}
totalPages := int(math.Ceil(float64(count) / float64(limit)))
outbox := activitypub.OrderedCollectionPage{
ID: services.GetActivityID("/users/" + publisher.Name + "/outbox"),
Type: activitypub.OrderedCollectionType,
TotalItems: uint(count),
OrderedItems: activitypub.ItemCollection(activities),
First: services.GetActivityIRI(fmt.Sprintf("/users/%s/outbox?page=%d", publisher.Name, 1)),
Last: services.GetActivityIRI(fmt.Sprintf("/users/%s/outbox?page=%d", publisher.Name, totalPages)),
}
if page > 1 {
outbox.Prev = services.GetActivityIRI(fmt.Sprintf("/users/%s/outbox?page=%d&limit=%d", publisher.Name, page-1, limit))
}
if page < totalPages {
outbox.Next = services.GetActivityIRI(fmt.Sprintf("/users/%s/outbox?page=%d&limit=%d", publisher.Name, page+1, limit))
}
return c.JSON(outbox)
}
func apUserActor(c *fiber.Ctx) error {
name := c.Params("name")
var publisher models.Publisher
if err := database.C.Where("name = ?", name).First(&publisher).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "User not found"})
}
id := services.GetActivityID("/users/" + publisher.Name)
actor := activitypub.Actor{
ID: id,
Inbox: id + "/inbox",
Outbox: id + "/outbox",
Type: activitypub.PersonType,
Name: activitypub.DefaultNaturalLanguageValue(publisher.Name),
PreferredUsername: activitypub.DefaultNaturalLanguageValue(publisher.Nick),
}
return c.JSON(actor)
}

View File

@ -0,0 +1,217 @@
package api
import (
"fmt"
"time"
"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"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/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 *string `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"`
Realm *uint `json:"realm"`
}
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.Realm != nil {
if _, err := authkit.GetRealmMember(gap.Nx, *data.Realm, user.ID); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("you are not a member of realm #%d", *data.Realm))
}
item.RealmID = data.Realm
}
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",
map[string]interface{}{"post": item},
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 *string `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,
Type: models.PostTypeArticle,
}).Preload("Publisher").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)
og := item
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 item.PublishedAt == nil {
item.PublishedAt = data.PublishedAt
}
if data.Visibility != nil {
item.Visibility = *data.Visibility
}
if item, err = services.EditPost(item, og); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.edit",
map[string]interface{}{"post": item},
c,
)
}
return c.JSON(item)
}

View File

@ -0,0 +1,113 @@
package api
import (
"git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/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 {
take := c.QueryInt("take", 10)
offset := c.QueryInt("offset", 0)
probe := c.Query("probe")
if take > 100 {
take = 100
}
var categories []models.Category
var err error
if len(probe) > 0 {
categories, err = services.SearchCategories(take, offset, probe)
} else {
categories, err = services.ListCategory(take, offset)
}
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, 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,50 @@
package api
import (
"strconv"
"strings"
"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/nexus/pkg/nex/sec"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"github.com/gofiber/fiber/v2"
)
func createFlag(c *fiber.Ctx) error {
if err := sec.EnsureGrantedPerm(c, "FlagPost", true); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
id := c.Params("postId")
var item models.Post
var err error
tx := services.FilterPostDraft(database.C)
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())
}
flag, err := services.NewFlag(item, user.ID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(flag)
}

View File

@ -0,0 +1,111 @@
package api
import (
"github.com/gofiber/fiber/v2"
)
func MapControllers(app *fiber.App, baseURL string) {
api := app.Group(baseURL).Name("API")
{
api.Get("/webfinger", getWebfinger)
activitypub := api.Group("/activitypub").Name("ActivityPub API")
{
activitypub.Post("/users/:name/inbox", apUserInbox)
activitypub.Get("/users/:name/outbox", apUserOutbox)
activitypub.Get("/users/:name", apUserActor)
}
publishers := api.Group("/publishers").Name("Publisher API")
{
publishers.Get("/", listRelatedPublisher)
publishers.Get("/me", listOwnedPublisher)
publishers.Post("/personal", createPersonalPublisher)
publishers.Post("/organization", createOrganizationPublisher)
publishers.Get("/:name/pins", listPinnedPost)
publishers.Get("/:name", getPublisher)
publishers.Put("/:name", editPublisher)
publishers.Delete("/:name", deletePublisher)
}
recommendations := api.Group("/recommendations").Name("Recommendations API")
{
recommendations.Get("/", listRecommendation)
recommendations.Get("/shuffle", listRecommendationShuffle)
recommendations.Get("/feed", getRecommendationFeed)
}
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)
}
questions := api.Group("/questions").Name("Question API")
{
questions.Post("/", createQuestion)
questions.Put("/:postId", editQuestion)
questions.Put("/:postId/answer", selectQuestionAnswer)
}
videos := api.Group("/videos").Name("Video API")
{
videos.Post("/", createVideo)
videos.Put("/:postId", editVideo)
}
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.Get("/:postId/insight", getPostInsight)
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)
posts.Get("/:postId/replies/featured", listPostFeaturedReply)
}
polls := api.Group("/polls").Name("Polls API")
{
polls.Get("/:pollId", getPoll)
polls.Post("/", createPoll)
polls.Put("/:pollId", updatePoll)
polls.Delete("/:pollId", deletePoll)
polls.Post("/:pollId/answer", answerPoll)
polls.Get("/:pollId/answer", getMyPollAnswer)
}
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,58 @@
package api
import (
"strconv"
"strings"
"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/nexus/pkg/nex/sec"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"github.com/gofiber/fiber/v2"
)
func getPostInsight(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
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(c, tx, &user)
} else {
tx = services.FilterPostWithUserContext(c, 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())
}
response, err := services.GeneratePostInsights(item, user.ID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"response": response,
})
}

View File

@ -0,0 +1,78 @@
package api
import (
"time"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/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 getMyPollAnswer(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
pollId, _ := c.ParamsInt("pollId")
var answer models.PollAnswer
if err := database.C.Where("poll_id = ? AND account_id = ?", pollId, user.ID).First(&answer).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(answer)
}
func answerPoll(c *fiber.Ctx) error {
pollId, _ := c.ParamsInt("pollId")
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var data struct {
Answer string `json:"answer" validate:"required"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
var poll models.Poll
if err := database.C.Where("id = ?", pollId).First(&poll).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if poll.ExpiredAt != nil && time.Now().Unix() >= poll.ExpiredAt.Unix() {
return fiber.NewError(fiber.StatusBadRequest, "poll has been ended")
}
doesContains := false
for _, option := range poll.Options {
if option.ID == data.Answer {
doesContains = true
break
}
}
if !doesContains {
return fiber.NewError(fiber.StatusBadRequest, "poll does not have a option like that")
}
answer := models.PollAnswer{
Answer: data.Answer,
PollID: poll.ID,
AccountID: user.ID,
}
if answer, err := services.AddPollAnswer(poll, answer); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.JSON(answer)
}
}

View File

@ -0,0 +1,108 @@
package api
import (
"time"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/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 getPoll(c *fiber.Ctx) error {
pollId, _ := c.ParamsInt("pollId")
var poll models.Poll
if err := database.C.Where("id = ?", pollId).First(&poll).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
poll.Metric = services.GetPollMetric(poll)
return c.JSON(poll)
}
func createPoll(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var data struct {
Options []models.PollOption `json:"options" validate:"required"`
ExpiredAt *time.Time `json:"expired_at"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
poll := models.Poll{
ExpiredAt: data.ExpiredAt,
Options: data.Options,
AccountID: user.ID,
}
var err error
if poll, err = services.NewPoll(poll); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(poll)
}
func updatePoll(c *fiber.Ctx) error {
pollId, _ := c.ParamsInt("pollId")
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var data struct {
Options []models.PollOption `json:"options" validate:"required"`
ExpiredAt *time.Time `json:"expired_at"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
var poll models.Poll
if err := database.C.Where("id = ? AND account_id = ?", pollId, user.ID).First(&poll).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
poll.Options = data.Options
poll.ExpiredAt = data.ExpiredAt
var err error
if poll, err = services.UpdatePoll(poll); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(poll)
}
func deletePoll(c *fiber.Ctx) error {
pollId, _ := c.ParamsInt("pollId")
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var poll models.Poll
if err := database.C.Where("id = ? AND account_id = ?", pollId, user.ID).First(&poll).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := database.C.Delete(&poll).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(poll)
}

View File

@ -0,0 +1,388 @@
package api
import (
"fmt"
"strconv"
"strings"
"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"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts"
"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"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
)
func getPost(c *fiber.Ctx) error {
id := c.Params("postId")
var item models.Post
var err error
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,
ShowCollapsed: true,
}); err != nil {
return err
}
if numericId, paramErr := strconv.Atoi(id); paramErr == nil {
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 {
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]
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 {
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", 10)
offset := c.QueryInt("offset", 0)
tx := database.C
probe := c.Query("probe")
if len(probe) == 0 && len(c.Query("tags")) == 0 && len(c.Query("categories")) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "search term (probe, tags or categories) is required")
}
tx = services.FilterPostWithFuzzySearch(tx, probe)
var err error
if tx, err = services.UniversalPostFilter(c, tx, services.UniversalPostFilterConfig{
ShowReply: true,
}); err != nil {
return err
}
var userId *uint
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
userId = &user.ID
}
var count int64
countTx := tx
count, err = services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, 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 err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if c.QueryBool("truncate", true) {
for _, item := range items {
item = services.TruncatePostContent(item)
}
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}
func listPost(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
}
var count int64
countTx := tx
count, err = services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, 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 err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if c.QueryBool("truncate", true) {
for _, item := range items {
item = services.TruncatePostContent(item)
}
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}
func listPostMinimal(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
}
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", 10)
offset := c.QueryInt("offset", 0)
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var err error
tx := services.FilterPostWithAuthorDraft(database.C, user.ID)
var userId *uint
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
userId = &user.ID
}
var count int64
countTx := tx
count, err = services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, 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 err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if c.QueryBool("truncate", true) {
for _, item := range items {
item = 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,
}).Preload("Publisher").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",
map[string]any{"post": item},
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",
map[string]any{"post_id": res.ID, "reaction": reaction},
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 publisher_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",
map[string]any{"post": res},
c,
)
return c.SendStatus(fiber.StatusOK)
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.unpin",
map[string]any{"post": res},
c,
)
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)
}

View File

@ -0,0 +1,228 @@
package api
import (
"fmt"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/interactive/pkg/internal/services"
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"github.com/gofiber/fiber/v2"
)
func listPinnedPost(c *fiber.Ctx) error {
name := c.Params("name")
var user models.Publisher
if err := database.C.
Where("name = ?", name).
First(&user).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
tx := services.FilterPostDraft(database.C)
tx = tx.Where("publisher_id = ?", user.ID)
tx = tx.Where("pinned_at IS NOT NULL")
var userId *uint
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
userId = &user.ID
}
items, err := services.ListPost(tx, 100, 0, "published_at DESC", userId)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(items)
}
func getPublisher(c *fiber.Ctx) error {
name := c.Params("name")
var publisher models.Publisher
if err := database.C.Where("name = ?", name).First(&publisher).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(publisher)
}
func listRelatedPublisher(c *fiber.Ctx) error {
tx := database.C
if len(c.Query("user")) > 0 {
user, err := authkit.GetUserByName(gap.Nx, c.Query("user"))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to find user: %v", err))
}
tx = tx.Where("account_id = ? AND type = ?", user.ID, models.PublisherTypePersonal)
} else if len(c.Query("realm")) > 0 {
realm, err := authkit.GetRealmByAlias(gap.Nx, c.Query("realm"))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("unable to find realm: %v", err))
}
tx = tx.Where("realm_id = ? AND type = ?", realm.ID, models.PublisherTypeOrganization)
} else {
return fiber.NewError(fiber.StatusBadRequest, "missing user or realm in query string")
}
var publishers []models.Publisher
if err := tx.Find(&publishers).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(publishers)
}
func listOwnedPublisher(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var publishers []models.Publisher
if err := database.C.Where("account_id = ?", user.ID).Find(&publishers).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(publishers)
}
func createPersonalPublisher(c *fiber.Ctx) error {
if err := sec.EnsureGrantedPerm(c, "CreatePublishers", true); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var data struct {
Name string `json:"name" validate:"required,min=4,max=32,alphanum"`
Nick string `json:"nick" validate:"required,min=2,max=64"`
Description string `json:"description"`
Avatar string `json:"avatar"`
Banner string `json:"banner"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
if pub, err := services.CreatePersonalPublisher(
user,
data.Name,
data.Nick,
data.Description,
data.Avatar,
data.Banner,
); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.JSON(pub)
}
}
func createOrganizationPublisher(c *fiber.Ctx) error {
if err := sec.EnsureGrantedPerm(c, "CreatePublishers", true); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
var data struct {
Realm string `json:"realm" validate:"required"`
Name string `json:"name" validate:"required,min=4,max=32,alphanum"`
Nick string `json:"nick" validate:"required,min=2,max=64"`
Description string `json:"description"`
Avatar string `json:"avatar"`
Banner string `json:"banner"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
realm, err := authkit.GetRealmByAlias(gap.Nx, data.Realm)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to get realm: %v", err))
}
if !authkit.CheckRealmMemberPerm(gap.Nx, realm.ID, int(user.ID), 100) {
return fiber.NewError(fiber.StatusForbidden, "you least need to be the admin of this realm to create a publisher")
}
if pub, err := services.CreateOrganizationPublisher(
user,
realm,
data.Name,
data.Nick,
data.Description,
data.Avatar,
data.Banner,
); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.JSON(pub)
}
}
func editPublisher(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
name := c.Params("name")
publisher, err := services.GetPublisherByName(name, user.ID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
var data struct {
Name string `json:"name"`
Nick string `json:"nick"`
Description string `json:"description"`
Avatar string `json:"avatar"`
Banner string `json:"banner"`
AccountID *uint `json:"account_id"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
og := publisher
publisher.Name = data.Name
publisher.Nick = data.Nick
publisher.Description = data.Description
publisher.Avatar = data.Avatar
publisher.Banner = data.Banner
if data.AccountID != nil {
publisher.AccountID = data.AccountID
}
if publisher, err = services.EditPublisher(user, publisher, og); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(publisher)
}
func deletePublisher(c *fiber.Ctx) error {
if err := sec.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(authm.Account)
name := c.Params("name")
publisher, err := services.GetPublisherByName(name, user.ID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.DeletePublisher(publisher); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -0,0 +1,355 @@
package api
import (
"context"
"fmt"
"time"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/interactive/pkg/internal/services"
"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"
wproto "git.solsynth.dev/hypernet/wallet/pkg/proto"
"github.com/gofiber/fiber/v2"
jsoniter "github.com/json-iterator/go"
"github.com/samber/lo"
)
func createQuestion(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:"max=4096"`
Location *string `json:"location"`
Thumbnail *string `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"`
Realm *uint `json:"realm"`
Reward float64 `json:"reward"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
} else if len(data.Content) == 0 && len(data.Attachments) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "content or attachments are required")
}
publisher, err := services.GetPublisher(data.Publisher, user.ID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
// Take charge
if data.Reward > 0 {
conn, err := gap.Nx.GetClientGrpcConn("wa")
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("unable to connect Wallet: %v", err))
}
wc := wproto.NewPaymentServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
if _, err := wc.MakeTransactionWithAccount(ctx, &wproto.MakeTransactionWithAccountRequest{
Amount: data.Reward,
Remark: "As reward for posting a question",
PayerAccountId: lo.ToPtr(uint64(user.ID)),
}); err != nil {
return fiber.NewError(fiber.StatusPaymentRequired, fmt.Sprintf("failed to handle payment: %v", err))
}
}
body := models.PostQuestionBody{
PostStoryBody: models.PostStoryBody{
Thumbnail: data.Thumbnail,
Title: data.Title,
Content: data.Content,
Location: data.Location,
Attachments: data.Attachments,
},
Reward: data.Reward,
}
var bodyMapping map[string]any
rawBody, _ := jsoniter.Marshal(body)
_ = jsoniter.Unmarshal(rawBody, &bodyMapping)
item := models.Post{
Alias: data.Alias,
Type: models.PostTypeQuestion,
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.Realm != nil {
if _, err := authkit.GetRealmMember(gap.Nx, *data.Realm, user.ID); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("you are not a member of realm #%d", *data.Realm))
}
item.RealmID = data.Realm
}
if data.Visibility != nil {
item.Visibility = *data.Visibility
} else {
item.Visibility = models.PostVisibilityAll
}
item, err = services.NewPost(publisher, item)
if err != nil {
// Failed to create post, refund the charge
if data.Reward > 0 {
conn, err := gap.Nx.GetClientGrpcConn("wa")
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("unable to connect Wallet: %v", err))
}
wc := wproto.NewPaymentServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
if _, err := wc.MakeTransactionWithAccount(ctx, &wproto.MakeTransactionWithAccountRequest{
Amount: data.Reward,
Remark: "As reward for posting a question - Refund",
PayeeAccountId: lo.ToPtr(uint64(user.ID)),
}); err != nil {
return fiber.NewError(fiber.StatusPaymentRequired, fmt.Sprintf("failed to handle payment: %v", err))
}
}
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.new",
map[string]any{"post": item},
c,
)
}
return c.JSON(item)
}
func editQuestion(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:"max=4096"`
Thumbnail *string `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
} else if len(data.Content) == 0 && len(data.Attachments) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "content or attachments are required")
}
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,
Type: models.PostTypeQuestion,
}).Preload("Publisher").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
}
var body models.PostQuestionBody
raw, _ := jsoniter.Marshal(item.Body)
_ = jsoniter.Unmarshal(raw, &body)
newBody := models.PostQuestionBody{
PostStoryBody: models.PostStoryBody{
Thumbnail: data.Thumbnail,
Title: data.Title,
Content: data.Content,
Location: data.Location,
Attachments: data.Attachments,
},
Reward: body.Reward,
Answer: body.Answer,
}
var newBodyMapping map[string]any
rawBody, _ := jsoniter.Marshal(newBody)
_ = jsoniter.Unmarshal(rawBody, &newBodyMapping)
og := item
item.Alias = data.Alias
item.Body = newBodyMapping
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 item.PublishedAt == nil {
item.PublishedAt = data.PublishedAt
}
if data.Visibility != nil {
item.Visibility = *data.Visibility
}
if item, err = services.EditPost(item, og); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.edit",
map[string]any{"post": item},
c,
)
}
return c.JSON(item)
}
func selectQuestionAnswer(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"`
AnswerID uint `json:"answer_id" validate:"required"`
}
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,
Type: models.PostTypeQuestion,
}).Preload("Publisher").First(&item).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
var body models.PostQuestionBody
raw, _ := jsoniter.Marshal(item.Body)
_ = jsoniter.Unmarshal(raw, &body)
if item.LockedAt != nil {
return fiber.NewError(fiber.StatusForbidden, "post was locked")
}
if body.Answer != nil && *body.Answer > 0 {
return fiber.NewError(fiber.StatusBadRequest, "question already has an answer")
}
var answer models.Post
if err := database.C.Where("id = ? AND reply_id = ?", data.AnswerID, item.ID).Preload("Publisher").First(&answer).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("related answer was not found: %v", err))
}
item.Body["answer"] = answer.ID
// Preload publisher data
item.Publisher = publisher
if item, err = services.EditPost(item, item); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
// Give the reward
if body.Reward > 0 && answer.Publisher.AccountID != nil {
conn, err := gap.Nx.GetClientGrpcConn("wa")
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("unable to connect Wallet: %v", err))
}
wc := wproto.NewPaymentServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
if _, err := wc.MakeTransactionWithAccount(ctx, &wproto.MakeTransactionWithAccountRequest{
Amount: body.Reward,
Remark: fmt.Sprintf("Answer of question %d got selected reward", item.ID),
PayeeAccountId: lo.ToPtr(uint64(*answer.Publisher.AccountID)),
}); err != nil {
return fiber.NewError(fiber.StatusPaymentRequired, fmt.Sprintf("failed to handle payment: %v", err))
}
}
_ = authkit.AddEventExt(
gap.Nx,
"posts.edit.answer",
map[string]any{
"post": item,
"answer": answer,
},
c,
)
}
return c.JSON(item)
}

View File

@ -0,0 +1,120 @@
package api
import (
"time"
"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"
)
func listRecommendation(c *fiber.Ctx) error {
const featuredMax = 5
var err error
var posts []models.Post
posts, err = services.GetFeaturedPosts(featuredMax)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
postIdx := lo.Map(posts, func(item models.Post, index int) uint {
return item.ID
})
var userId *uint
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
userId = &user.ID
}
tx := database.C.Where("id IN ?", postIdx)
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
})
// Revert the position & truncate
for idx, item := range posts {
posts[idx] = services.TruncatePostContent(newPostMap[item.ID])
}
return c.JSON(posts)
}
func listRecommendationShuffle(c *fiber.Ctx) error {
take := c.QueryInt("take", 10)
offset := c.QueryInt("offset", 0)
var err error
tx := database.C
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
}
var count int64
countTx := tx
count, err = services.CountPost(countTx)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
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 {
item = services.TruncatePostContent(item)
}
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}
func getRecommendationFeed(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 20)
cursor := c.QueryInt("cursor", 0)
var cursorTime *time.Time
if cursor > 0 {
cursorTime = lo.ToPtr(time.UnixMilli(int64(cursor - 1)))
}
var userId *uint
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
userId = &user.ID
}
entries, err := queries.GetFeed(c, limit, userId, cursorTime)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(entries)
}

View File

@ -0,0 +1,99 @@
package api
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"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"github.com/gofiber/fiber/v2"
)
func listPostReplies(c *fiber.Ctx) error {
take := c.QueryInt("take", 10)
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("name = ?", c.Query("author")).First(&author).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
tx = tx.Where("publisher_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"))
}
var userId *uint
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
userId = &user.ID
}
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", userId)
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", 10)
take = max(1, min(take, 3))
var userId *uint
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
userId = &user.ID
}
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("name = ?", c.Query("author")).First(&author).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
tx = tx.Where("publisher_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", userId)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(items)
}

View File

@ -0,0 +1,244 @@
package api
import (
"fmt"
"time"
"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"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/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:"max=4096"`
Location *string `json:"location"`
Thumbnail *string `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"`
Poll *uint `json:"poll"`
Realm *uint `json:"realm"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
} else if len(data.Content) == 0 && len(data.Attachments) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "content or attachments are required")
}
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,
PollID: data.Poll,
}
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.Realm != nil {
if _, err := authkit.GetRealmMember(gap.Nx, *data.Realm, user.ID); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("you are not a member of realm #%d", *data.Realm))
}
item.RealmID = data.Realm
}
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.edit",
map[string]interface{}{"post": item},
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:"max=4096"`
Thumbnail *string `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"`
Poll *uint `json:"poll"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
} else if len(data.Content) == 0 && len(data.Attachments) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "content or attachments are required")
}
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,
Type: models.PostTypeStory,
}).Preload("Publisher").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)
og := item
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
item.PollID = data.Poll
// Preload publisher data
item.Publisher = publisher
if item.PublishedAt == nil {
item.PublishedAt = data.PublishedAt
}
if data.Visibility != nil {
item.Visibility = *data.Visibility
}
if item, err = services.EditPost(item, og); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.edit",
map[string]interface{}{"post": item},
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"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/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",
map[string]any{"user": otherUser},
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",
map[string]any{"tag": tag},
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",
map[string]any{"category": category},
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",
map[string]any{"user": otherUser},
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",
map[string]any{"tag": tag},
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",
map[string]any{"category": category},
c,
)
return c.SendStatus(fiber.StatusOK)
}

View File

@ -0,0 +1,41 @@
package api
import (
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/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", 10)
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,228 @@
package api
import (
"fmt"
"time"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/interactive/pkg/internal/services"
"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"
"github.com/gofiber/fiber/v2"
jsoniter "github.com/json-iterator/go"
"github.com/samber/lo"
)
func createVideo(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"`
Video string `json:"video" validate:"required"`
Alias *string `json:"alias"`
Title string `json:"title" validate:"required"`
Description *string `json:"description"`
Location *string `json:"location"`
Thumbnail *string `json:"thumbnail"`
Subtitles map[string]string `json:"subtitles"`
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"`
Renderer *string `json:"renderer"`
IsLive bool `json:"is_live"`
IsDraft bool `json:"is_draft"`
Realm *uint `json:"realm"`
}
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.PostVideoBody{
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
rawBody, _ := jsoniter.Marshal(body)
_ = jsoniter.Unmarshal(rawBody, &bodyMapping)
item := models.Post{
Alias: data.Alias,
Type: models.PostTypeVideo,
Body: bodyMapping,
Language: services.DetectLanguage(data.Title),
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.Realm != nil {
if _, err := authkit.GetRealmMember(gap.Nx, *data.Realm, user.ID); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("you are not a member of realm #%d", *data.Realm))
}
item.RealmID = data.Realm
}
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",
map[string]any{"post": item},
c,
)
}
return c.JSON(item)
}
func editVideo(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"`
Video string `json:"video" validate:"required"`
Alias *string `json:"alias"`
Title string `json:"title" validate:"required"`
Description *string `json:"description"`
Location *string `json:"location"`
Thumbnail *string `json:"thumbnail"`
Subtitles map[string]string `json:"subtitles"`
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"`
Renderer *string `json:"renderer"`
IsLive bool `json:"is_live"`
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,
Type: models.PostTypeVideo,
}).Preload("Publisher").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.PostVideoBody{
Thumbnail: data.Thumbnail,
Video: data.Video,
Title: data.Title,
Description: data.Description,
Location: data.Location,
Subtitles: data.Subtitles,
Renderer: data.Renderer,
IsLive: data.IsLive,
}
var bodyMapping map[string]any
rawBody, _ := jsoniter.Marshal(body)
_ = jsoniter.Unmarshal(rawBody, &bodyMapping)
og := item
item.Alias = data.Alias
item.Body = bodyMapping
item.Language = services.DetectLanguage(data.Title)
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 item.PublishedAt == nil {
item.PublishedAt = data.PublishedAt
}
if data.Visibility != nil {
item.Visibility = *data.Visibility
}
if item, err = services.EditPost(item, og); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
_ = authkit.AddEventExt(
gap.Nx,
"posts.edit",
map[string]any{"post": item},
c,
)
}
return c.JSON(item)
}

View File

@ -0,0 +1,65 @@
package api
import (
"strings"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/interactive/pkg/internal/services"
"github.com/gofiber/fiber/v2"
)
type WebFingerResponse struct {
Subject string `json:"subject"`
Aliases []string `json:"aliases"`
Links []struct {
Rel string `json:"rel"`
Type string `json:"type,omitempty"`
Href string `json:"href"`
} `json:"links"`
}
// Although this webfinger is desgined for users
// But in this case we will provide publisher for them
func getWebfinger(c *fiber.Ctx) error {
resource := c.Query("resource")
if len(resource) < 6 || resource[:5] != "acct:" {
return c.Status(400).JSON(fiber.Map{"error": "Invalid resource format"})
}
username := resource[5:]
if username == "" {
return c.Status(400).JSON(fiber.Map{"error": "Invalid username"})
}
parts := strings.SplitN(username, "@", 2)
if len(parts) != 2 {
return c.Status(400).JSON(fiber.Map{"error": "Invalid username"})
}
var publisher models.Publisher
if err := database.C.Where("name = ?", parts[0]).First(&publisher).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
response := WebFingerResponse{
Subject: "acct:" + username,
Aliases: []string{
services.GetActivityID("/users/" + publisher.Name).String(),
},
Links: []struct {
Rel string `json:"rel"`
Type string `json:"type,omitempty"`
Href string `json:"href"`
}{
{
Rel: "self",
Type: "application/activity+json",
Href: services.GetActivityID("/users/" + publisher.Name).String(),
},
// TODO Add avatar here
},
}
return c.JSON(response)
}

View File

@ -0,0 +1,52 @@
package api
import (
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/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(c, tx, &user)
tx = tx.Where("id > ?", pivot)
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())
}
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, userId)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": items,
})
}

View File

@ -1,4 +1,4 @@
package server
package exts
import (
"github.com/go-playground/validator/v10"

View File

@ -0,0 +1,77 @@
package http
import (
"strings"
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
"git.solsynth.dev/hypernet/interactive/pkg/internal/http/admin"
"git.solsynth.dev/hypernet/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: "Hypernet.Interactive",
AppName: "Hypernet.Interactive",
ProxyHeader: fiber.HeaderXForwardedFor,
JSONEncoder: jsoniter.ConfigCompatibleWithStandardLibrary.Marshal,
JSONDecoder: jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal,
BodyLimit: 50 * 1024 * 1024,
ReadBufferSize: 5 * 1024 * 1024, // 5MB for large JWT
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.ParseAccountMiddleware)
api.MapControllers(app, "/api")
admin.MapControllers(app, "/api/admin")
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...")
}
}

View File

@ -1,19 +1,21 @@
package models
type Tag struct {
BaseModel
import "git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum,min=4,max=24"`
type Tag struct {
cruda.BaseModel
Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase"`
Name string `json:"name"`
Description string `json:"description"`
Posts []Post `json:"posts" gorm:"many2many:post_tags"`
}
type Category struct {
BaseModel
cruda.BaseModel
Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum,min=4,max=24"`
Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum"`
Name string `json:"name"`
Description string `json:"description"`
Posts []Post `json:"categories" gorm:"many2many:post_categories"`
Posts []Post `json:"posts" gorm:"many2many:post_categories"`
}

View File

@ -0,0 +1,10 @@
package models
import "git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
type PostFlag struct {
cruda.BaseModel
PostID uint `json:"post_id"`
AccountID uint `json:"account_id"`
}

View File

@ -0,0 +1,7 @@
package models
type PostMetric struct {
ReplyCount int64 `json:"reply_count"`
ReactionCount int64 `json:"reaction_count"`
ReactionList map[string]int64 `json:"reaction_list,omitempty"`
}

View File

@ -0,0 +1,39 @@
package models
import (
"time"
"git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
"gorm.io/datatypes"
)
type Poll struct {
cruda.BaseModel
ExpiredAt *time.Time `json:"expired_at"`
Options datatypes.JSONSlice[PollOption] `json:"options"`
AccountID uint `json:"account_id"`
Metric PollMetric `json:"metric" gorm:"-"`
}
type PollMetric struct {
TotalAnswer int64 `json:"total_answer"`
ByOptions map[string]int64 `json:"by_options"`
ByOptionsPercentage map[string]float64 `json:"by_options_percentage"`
}
type PollOption struct {
ID string `json:"id"`
Icon string `json:"icon"`
Name string `json:"name"`
Description string `json:"description"`
}
type PollAnswer struct {
cruda.BaseModel
Answer string `json:"answer"`
PollID uint `json:"poll_id"`
AccountID uint `json:"account_id"`
}

View File

@ -0,0 +1,124 @@
package models
import (
"time"
"git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"gorm.io/datatypes"
)
const (
PostTypeStory = "story"
PostTypeArticle = "article"
PostTypeQuestion = "question"
PostTypeVideo = "video"
)
type PostVisibilityLevel = int8
const (
PostVisibilityAll = PostVisibilityLevel(iota)
PostVisibilityFriends
PostVisibilityFiltered
PostVisibilitySelected
PostVisibilityNone
)
type Post struct {
cruda.BaseModel
Type string `json:"type"`
Body datatypes.JSONMap `json:"body" gorm:"index:,type:gin"`
Language string `json:"language"`
Alias *string `json:"alias" gorm:"index"`
AliasPrefix *string `json:"alias_prefix" gorm:"index"`
Tags []Tag `json:"tags" gorm:"many2many:post_tags"`
Categories []Category `json:"categories" gorm:"many2many:post_categories"`
Reactions []Reaction `json:"reactions"`
Replies []Post `json:"replies" gorm:"foreignKey:ReplyID"`
Flags []PostFlag `json:"flags" gorm:"foreignKey:PostID"`
ReplyID *uint `json:"reply_id"`
RepostID *uint `json:"repost_id"`
ReplyTo *Post `json:"reply_to" gorm:"foreignKey:ReplyID"`
RepostTo *Post `json:"repost_to" gorm:"foreignKey:RepostID"`
VisibleUsers datatypes.JSONSlice[uint] `json:"visible_users_list"`
InvisibleUsers datatypes.JSONSlice[uint] `json:"invisible_users_list"`
Visibility PostVisibilityLevel `json:"visibility"`
EditedAt *time.Time `json:"edited_at"`
PinnedAt *time.Time `json:"pinned_at"`
LockedAt *time.Time `json:"locked_at"`
IsCollapsed bool `json:"is_collapsed"`
IsDraft bool `json:"is_draft"`
PublishedAt *time.Time `json:"published_at"`
PublishedUntil *time.Time `json:"published_until"`
TotalUpvote int `json:"total_upvote"`
TotalDownvote int `json:"total_downvote"`
TotalViews int64 `json:"total_views"`
TotalAggressiveViews int64 `json:"total_aggressive_views"`
PollID *uint `json:"poll_id"`
Poll *Poll `json:"poll"`
RealmID *uint `json:"realm_id"`
Realm *authm.Realm `json:"realm" gorm:"-"`
PublisherID uint `json:"publisher_id"`
Publisher Publisher `json:"publisher"`
Metric PostMetric `json:"metric" gorm:"-"`
}
type PostStoryBody struct {
Thumbnail *string `json:"thumbnail"`
Title *string `json:"title"`
Content string `json:"content"`
Location *string `json:"location"`
Attachments []string `json:"attachments"`
}
type PostArticleBody struct {
Thumbnail *string `json:"thumbnail"`
Title string `json:"title"`
Description *string `json:"description"`
Content string `json:"content"`
Attachments []string `json:"attachments"`
}
type PostQuestionBody struct {
PostStoryBody
Answer *uint `json:"answer"`
Reward float64 `json:"reward"`
}
type PostVideoBody struct {
Thumbnail *string `json:"thumbnail"`
Title string `json:"title"`
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"`
}
type PostInsight struct {
cruda.BaseModel
Response string `json:"response"`
Post Post `json:"post"`
PostID uint `json:"post_id"`
}
type PostView struct {
AccountID uint `json:"account_id" gorm:"primaryKey"`
PostID uint `json:"post_id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -0,0 +1,35 @@
package models
import (
"git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
"git.solsynth.dev/hypernet/passport/pkg/authkit/models"
)
const (
PublisherTypePersonal = iota
PublisherTypeOrganization
PublisherTypeAnonymous
)
type Publisher struct {
cruda.BaseModel
Type int `json:"type"`
Name string `json:"name" gorm:"uniqueIndex"`
Nick string `json:"nick"`
Description string `json:"description"`
Avatar string `json:"avatar"`
Banner string `json:"banner"`
Posts []Post `json:"posts"`
TotalUpvote int `json:"total_upvote"`
TotalDownvote int `json:"total_downvote"`
RealmID *uint `json:"realm_id"`
AccountID *uint `json:"account_id"`
Account models.Account `gorm:"-" json:"account"`
Realm models.Realm `gorm:"-" json:"realm"`
}

View File

@ -0,0 +1,25 @@
package models
import (
"time"
)
type ReactionAttitude = uint8
const (
AttitudeNeutral = ReactionAttitude(iota)
AttitudePositive
AttitudeNegative
)
type Reaction struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Symbol string `json:"symbol"`
Attitude ReactionAttitude `json:"attitude"`
PostID uint `json:"post_id"`
AccountID uint `json:"account_id"`
}

View File

@ -0,0 +1,12 @@
package models
import "git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
type Subscription struct {
cruda.BaseModel
FollowerID uint `json:"follower_id"`
AccountID *uint `json:"account_id,omitempty"`
TagID *uint `json:"tag_id,omitempty"`
CategoryID *uint `json:"category_id,omitempty"`
}

View File

@ -0,0 +1,59 @@
package services
import (
"fmt"
"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/passport/pkg/authkit"
"git.solsynth.dev/hypernet/pusher/pkg/pushkit"
"github.com/rs/zerolog/log"
)
func GetAccountWithID(id uint) (models.Publisher, error) {
var account models.Publisher
if err := database.C.Where("id = ?", id).First(&account).Error; err != nil {
return account, fmt.Errorf("unable to get account by id: %v", err)
}
return account, nil
}
func ModifyPosterVoteCount(user models.Publisher, isUpvote bool, delta int) error {
if isUpvote {
user.TotalUpvote += delta
return database.C.Model(&user).Update("total_upvote", user.TotalUpvote).Error
} else {
user.TotalDownvote += delta
return database.C.Model(&user).Update("total_downvote", user.TotalDownvote).Error
}
}
func NotifyPosterAccount(pub models.Publisher, post models.Post, title, body, topic string, subtitle ...string) error {
if pub.AccountID == nil {
return nil
}
if len(subtitle) == 0 {
subtitle = append(subtitle, "")
}
err := authkit.NotifyUser(gap.Nx, uint64(*pub.AccountID), pushkit.Notification{
Topic: topic,
Title: title,
Subtitle: subtitle[0],
Body: body,
Priority: 4,
Metadata: map[string]any{
"related_post": TruncatePostContent(post),
"avatar": pub.Avatar,
},
})
if err != nil {
log.Warn().Err(err).Msg("An error occurred when notify account...")
} else {
log.Debug().Uint("uid", pub.ID).Msg("Notified account.")
}
return err
}

View File

@ -0,0 +1,16 @@
package services
import (
"github.com/go-ap/activitypub"
"github.com/spf13/viper"
)
func GetActivityID(uri string) activitypub.ID {
baseUrl := viper.GetString("activitypub_base_url")
return activitypub.ID(baseUrl + uri)
}
func GetActivityIRI(uri string) activitypub.IRI {
baseUrl := viper.GetString("activitypub_base_url")
return activitypub.IRI(baseUrl + uri)
}

View File

@ -0,0 +1,99 @@
package services
import (
"errors"
"strings"
"git.solsynth.dev/hypernet/nexus/pkg/nex/cruda"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"gorm.io/gorm"
)
func SearchCategories(take int, offset int, probe string) ([]models.Category, error) {
probe = "%" + probe + "%"
var categories []models.Category
err := database.C.Where("alias LIKE ?", probe).Offset(offset).Limit(take).Find(&categories).Error
return categories, err
}
func ListCategory(take int, offset int) ([]models.Category, error) {
var categories []models.Category
err := database.C.Offset(offset).Limit(take).Find(&categories).Error
return categories, err
}
func GetCategory(alias string) (models.Category, error) {
var category models.Category
if err := database.C.Where(models.Category{Alias: alias}).First(&category).Error; err != nil {
return category, err
}
return category, nil
}
func GetCategoryWithID(id uint) (models.Category, error) {
var category models.Category
if err := database.C.Where(models.Category{
BaseModel: cruda.BaseModel{ID: id},
}).First(&category).Error; err != nil {
return category, err
}
return category, nil
}
func NewCategory(alias, name, description string) (models.Category, error) {
category := models.Category{
Alias: alias,
Name: name,
Description: description,
}
err := database.C.Save(&category).Error
return category, err
}
func EditCategory(category models.Category, alias, name, description string) (models.Category, error) {
category.Alias = alias
category.Name = name
category.Description = description
err := database.C.Save(&category).Error
return category, err
}
func DeleteCategory(category models.Category) error {
return database.C.Delete(category).Error
}
func GetTagWithID(id uint) (models.Tag, error) {
var tag models.Tag
if err := database.C.Where(models.Tag{
BaseModel: cruda.BaseModel{ID: id},
}).First(&tag).Error; err != nil {
return tag, err
}
return tag, nil
}
func GetTagOrCreate(alias, name string) (models.Tag, error) {
alias = strings.ToLower(alias)
var tag models.Tag
if err := database.C.Where(models.Category{Alias: alias}).First(&tag).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
tag = models.Tag{
Alias: alias,
Name: name,
}
err := database.C.Save(&tag).Error
return tag, err
}
return tag, err
}
return tag, nil
}

View File

@ -0,0 +1,39 @@
package services
import (
"fmt"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"github.com/spf13/viper"
)
func GetConversation(start uint, offset, take int, order string, participants []uint) ([]models.Post, error) {
var posts []models.Post
tablePrefix := viper.GetString("database.prefix")
table := tablePrefix + "posts"
result := database.C.Raw(fmt.Sprintf(
`
WITH RECURSIVE conversation AS (
SELECT *
FROM %s
WHERE id = ?
UNION ALL
SELECT p.*
FROM %s p
INNER JOIN conversation c ON p.reply_id = c.id AND p.publisher_id IN (?)
)
SELECT * FROM conversation ORDER BY %s DESC OFFSET %d LIMIT %d`,
table, table, order, offset, take,
), start, participants).Scan(&posts)
// Check for errors
if result.Error != nil {
return nil, result.Error
}
return posts, nil
}

View File

@ -0,0 +1,41 @@
package services
import (
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"time"
)
// GetFeaturedPosts How to determine featured posts?
// Get the most upvoted posts in the last 7 days
// And then how to get the upvote count of each post in the last 7 days?
// We will get the reactions that attitude equals to 1 and created within the last 7 days
// By the way, the upvote count will subtract the downvote count
// Notice, this function is a raw query, it is not recommended to return the result directly
// Instead, you should get the id and query it again via the ListPost function
func GetFeaturedPosts(count int) ([]models.Post, error) {
deadline := time.Now().Add(-7 * 24 * time.Hour)
var posts []models.Post
if err := database.C.Raw(`
SELECT p.*, t.social_points
FROM posts p
JOIN (
SELECT
post_id,
SUM(CASE WHEN attitude = 1 THEN 1 ELSE 0 END) -
SUM(CASE WHEN attitude = 2 THEN 1 ELSE 0 END) AS social_points
FROM reactions
WHERE created_at >= ?
GROUP BY post_id
ORDER BY social_points DESC
LIMIT ?
) t ON p.id = t.post_id
WHERE p.visibility = ?
ORDER BY t.social_points DESC, p.published_at DESC
`, deadline, count, models.PostVisibilityAll).Scan(&posts).Error; err != nil {
return posts, err
}
return posts, nil
}

View File

@ -0,0 +1,43 @@
package services
import (
"fmt"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
)
func NewFlag(post models.Post, account uint) (models.PostFlag, error) {
var flag models.PostFlag
if err := database.C.Where("post_id = ? AND account_id = ?", post.ID, account).First(&flag).Error; err == nil {
return flag, fmt.Errorf("flag already exists")
}
flag = models.PostFlag{
PostID: post.ID,
AccountID: account,
}
if err := database.C.Save(&flag).Error; err != nil {
return flag, err
}
if err := FlagCalculateCollapseStatus(post); err != nil {
return flag, err
}
return flag, nil
}
func FlagCalculateCollapseStatus(post models.Post) error {
if post.TotalViews <= 2 {
return nil
}
collapseLimit := 0.5
var flagCount int64
if err := database.C.Model(&models.PostFlag{}).Where("post_id = ?", post.ID).Count(&flagCount).Error; err != nil {
return err
}
if float64(flagCount)/float64(post.TotalViews) >= collapseLimit {
return database.C.Model(&post).Update("is_collapsed", true).Error
}
return nil
}

View File

@ -0,0 +1,60 @@
package services
import (
"context"
"fmt"
"strings"
"time"
iproto "git.solsynth.dev/hypernet/insight/pkg/proto"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"github.com/rs/zerolog/log"
)
func GeneratePostInsights(post models.Post, user uint) (string, error) {
var insight models.PostInsight
if err := database.C.Where("post_id = ?", post.ID).First(&insight).Error; err == nil {
return insight.Response, nil
}
var compactBuilder []string
if val, ok := post.Body["title"].(string); ok && len(val) > 0 {
compactBuilder = append(compactBuilder, "Title: "+val)
}
if val, ok := post.Body["description"].(string); ok && len(val) > 0 {
compactBuilder = append(compactBuilder, "Description: "+val)
}
if val, ok := post.Body["content"].(string); ok && len(val) > 0 {
compactBuilder = append(compactBuilder, val)
}
compact := strings.Join(compactBuilder, "\n")
conn, err := gap.Nx.GetClientGrpcConn("ai")
if err != nil {
return "", fmt.Errorf("failed to connect Insight: %v", err)
}
ic := iproto.NewInsightServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
resp, err := ic.GenerateInsight(ctx, &iproto.InsightRequest{
Source: compact,
UserId: uint64(user),
})
if err != nil {
return "", err
}
insight = models.PostInsight{
Response: resp.Response,
Post: post,
PostID: post.ID,
}
if err := database.C.Create(&insight).Error; err != nil {
log.Error().Err(err).Msg("Failed to create post insight result in database...")
}
return resp.Response, nil
}

View File

@ -0,0 +1,27 @@
package services
import (
"strings"
"github.com/pemistahl/lingua-go"
)
var detector lingua.LanguageDetector
func CreateLanguageDetector() lingua.LanguageDetector {
return lingua.NewLanguageDetectorBuilder().
FromAllLanguages().
WithLowAccuracyMode().
Build()
}
func DetectLanguage(content string) string {
if detector == nil {
detector = CreateLanguageDetector()
}
if lang, ok := detector.DetectLanguageOf(content); ok {
return strings.ToLower(lang.String())
}
return "unknown"
}

View File

@ -0,0 +1,77 @@
package services
import (
"errors"
"fmt"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"gorm.io/gorm"
)
func NewPoll(poll models.Poll) (models.Poll, error) {
if err := database.C.Create(&poll).Error; err != nil {
return poll, err
}
return poll, nil
}
func UpdatePoll(poll models.Poll) (models.Poll, error) {
if err := database.C.Save(&poll).Error; err != nil {
return poll, err
}
return poll, nil
}
func AddPollAnswer(poll models.Poll, answer models.PollAnswer) (models.PollAnswer, error) {
answer.PollID = poll.ID
var currentAnswer models.PollAnswer
if err := database.C.Model(&models.PollAnswer{}).
Where("poll_id = ? AND account_id = ?", poll.ID, answer.AccountID).
First(&currentAnswer).Error; err == nil {
if err := database.C.Model(&currentAnswer).
Where("id = ?", currentAnswer.ID).
Updates(&models.PollAnswer{Answer: answer.Answer}).Error; err != nil {
return answer, fmt.Errorf("failed to update your answer")
}
return answer, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return answer, err
}
if err := database.C.Create(&answer).Error; err != nil {
return answer, err
}
return answer, nil
}
func GetPollMetric(poll models.Poll) models.PollMetric {
var answers []models.PollAnswer
if err := database.C.Where("poll_id = ?", poll.ID).Find(&answers).Error; err != nil {
return models.PollMetric{}
}
byOptions := make(map[string]int64)
for _, answer := range answers {
if _, ok := byOptions[answer.Answer]; !ok {
byOptions[answer.Answer] = 0
}
byOptions[answer.Answer]++
}
byOptionsPercentage := make(map[string]float64)
for _, option := range poll.Options {
if val, ok := byOptions[option.ID]; ok {
byOptionsPercentage[option.ID] = float64(val) / float64(len(answers))
}
}
return models.PollMetric{
TotalAnswer: int64(len(answers)),
ByOptions: byOptions,
ByOptionsPercentage: byOptionsPercentage,
}
}

View File

@ -0,0 +1,56 @@
package services
import (
"fmt"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"github.com/samber/lo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var postViewQueue []models.PostView
func AddPostView(post models.Post, account uint) {
postViewQueue = append(postViewQueue, models.PostView{
AccountID: account,
PostID: post.ID,
})
}
func AddPostViews(posts []models.Post, account uint) {
for _, post := range posts {
postViewQueue = append(postViewQueue, models.PostView{
AccountID: account,
PostID: post.ID,
})
}
}
func FlushPostViews() {
if len(postViewQueue) == 0 {
return
}
workingQueue := make([]models.PostView, len(postViewQueue))
copy(workingQueue, postViewQueue)
clear(postViewQueue)
updateRequiredPost := make(map[uint]int)
for _, item := range workingQueue {
updateRequiredPost[item.PostID]++
}
workingQueue = lo.UniqBy(workingQueue, func(item models.PostView) string {
return fmt.Sprintf("%d:%d", item.PostID, item.AccountID)
})
_ = database.C.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(workingQueue, 1000).Error
for k, v := range updateRequiredPost {
var count int64
if err := database.C.Model(&models.PostView{}).Where("post_id = ?", k).Count(&count).Error; err != nil {
continue
}
database.C.Model(&models.Post{}).Where("id = ?", k).Updates(map[string]any{
"total_views": count,
"total_aggressive_views": gorm.Expr("total_aggressive_views + ?", v),
})
}
}

View File

@ -0,0 +1,913 @@
package services
import (
"context"
"errors"
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"git.solsynth.dev/hypernet/nexus/pkg/nex"
"git.solsynth.dev/hypernet/nexus/pkg/nex/cachekit"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/nexus/pkg/proto"
"git.solsynth.dev/hypernet/paperclip/pkg/filekit"
pproto "git.solsynth.dev/hypernet/paperclip/pkg/proto"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
aproto "git.solsynth.dev/hypernet/passport/pkg/proto"
"gorm.io/datatypes"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"gorm.io/gorm"
)
func FilterPostWithUserContext(c *fiber.Ctx, tx *gorm.DB, user *authm.Account) *gorm.DB {
if user == nil {
return tx.Where("visibility = ? AND realm_id IS NULL", models.PostVisibilityAll)
}
const (
AllVisibility = models.PostVisibilityAll
FriendsVisibility = models.PostVisibilityFriends
SelectedVisibility = models.PostVisibilitySelected
FilteredVisibility = models.PostVisibilityFiltered
)
type userContextState struct {
Self []uint `json:"self"`
Allowlist []uint `json:"allow"`
InvisibleList []uint `json:"invisible"`
FollowList []uint `json:"follow"`
RealmList []uint `json:"realm"`
}
var self, allowlist, invisibleList, followList, realmList []uint
statusCacheKey := fmt.Sprintf("post-user-filter#%d", user.ID)
state, err := cachekit.Get[userContextState](gap.Ca, statusCacheKey)
if err == nil {
allowlist = state.Allowlist
invisibleList = state.InvisibleList
followList = state.FollowList
realmList = state.RealmList
self = state.Self
} else {
// Get itself
{
var publishers []models.Publisher
if err := database.C.Where("account_id = ?", user.ID).Find(&publishers).Error; err != nil {
return tx
}
self = lo.Map(publishers, func(item models.Publisher, index int) uint {
return item.ID
})
allowlist = append(allowlist, self...)
}
// Getting the relationships
userFriends, _ := authkit.ListRelative(gap.Nx, user.ID, int32(authm.RelationshipFriend), true)
userGotBlocked, _ := authkit.ListRelative(gap.Nx, user.ID, int32(authm.RelationshipBlocked), true)
userBlocked, _ := authkit.ListRelative(gap.Nx, user.ID, int32(authm.RelationshipBlocked), false)
// Getting the realm list
{
conn, err := gap.Nx.GetClientGrpcConn(nex.ServiceTypeAuth)
if err == nil {
ac := aproto.NewRealmServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
resp, err := ac.ListAvailableRealm(ctx, &aproto.LookupUserRealmRequest{
UserId: uint64(user.ID),
IncludePublic: lo.ToPtr(true),
})
if err == nil {
realmList = lo.Map(resp.GetData(), func(item *aproto.RealmInfo, index int) uint {
return uint(item.GetId())
})
} else {
log.Warn().Err(err).Uint("user", user.ID).Msg("An error occurred when getting realm list from grpc...")
}
} else {
log.Warn().Err(err).Uint("user", user.ID).Msg("An error occurred when getting grpc connection to Auth...")
}
}
userFriendList := lo.Map(userFriends, func(item *proto.UserInfo, index int) uint {
return uint(item.GetId())
})
userGotBlockList := lo.Map(userGotBlocked, func(item *proto.UserInfo, index int) uint {
return uint(item.GetId())
})
userBlocklist := lo.Map(userBlocked, func(item *proto.UserInfo, index int) uint {
return uint(item.GetId())
})
// Query the publishers according to the user's relationship
var publishers []models.Publisher
database.C.Where(
"account_id IN ? AND type = ?",
lo.Uniq(append(append(userFriendList, userGotBlockList...), userBlocklist...)),
models.PublisherTypePersonal,
).Find(&publishers)
// Getting the follow list
{
var subs []models.Subscription
if err := database.C.Where("follower_id = ? AND account_id IS NOT NULL", user.ID).Find(&subs).Error; err != nil {
log.Error().Err(err).Msg("An error occurred when getting subscriptions...")
}
followList = lo.Map(lo.Filter(subs, func(item models.Subscription, index int) bool {
return item.AccountID != nil
}), func(item models.Subscription, index int) uint {
return *item.AccountID
})
}
allowlist = lo.Map(lo.Filter(publishers, func(item models.Publisher, index int) bool {
if item.AccountID == nil {
return false
}
return lo.Contains(userFriendList, *item.AccountID)
}), func(item models.Publisher, index int) uint {
return item.ID
})
invisibleList = lo.Map(lo.Filter(publishers, func(item models.Publisher, index int) bool {
if item.AccountID == nil {
return false
}
return lo.Contains(userBlocklist, *item.AccountID)
}), func(item models.Publisher, index int) uint {
return item.ID
})
cachekit.Set(
gap.Ca,
statusCacheKey,
userContextState{
Allowlist: allowlist,
InvisibleList: invisibleList,
RealmList: realmList,
FollowList: followList,
Self: self,
},
5*time.Minute,
fmt.Sprintf("user#%d", user.ID),
)
}
if len(self) == 0 && len(allowlist) == 0 {
tx = tx.Where(
"(visibility = ? OR"+
"(visibility = ? AND ?) OR "+
"(visibility = ? AND NOT ?))",
AllVisibility,
SelectedVisibility,
datatypes.JSONQuery("visible_users").HasKey(strconv.Itoa(int(user.ID))),
FilteredVisibility,
datatypes.JSONQuery("invisible_users").HasKey(strconv.Itoa(int(user.ID))),
)
} else if len(self) == 0 {
tx = tx.Where(
"(visibility = ? OR"+
"(visibility = ? AND publisher_id IN ?) OR "+
"(visibility = ? AND ?) OR "+
"(visibility = ? AND NOT ?))",
AllVisibility,
FriendsVisibility,
allowlist,
SelectedVisibility,
datatypes.JSONQuery("visible_users").HasKey(strconv.Itoa(int(user.ID))),
FilteredVisibility,
datatypes.JSONQuery("invisible_users").HasKey(strconv.Itoa(int(user.ID))),
)
} else {
tx = tx.Where(
"(publisher_id IN ? OR visibility = ? OR"+
"(visibility = ? AND publisher_id IN ?) OR "+
"(visibility = ? AND ?) OR "+
"(visibility = ? AND NOT ?))",
self,
AllVisibility,
FriendsVisibility,
allowlist,
SelectedVisibility,
datatypes.JSONQuery("visible_users").HasKey(strconv.Itoa(int(user.ID))),
FilteredVisibility,
datatypes.JSONQuery("invisible_users").HasKey(strconv.Itoa(int(user.ID))),
)
}
if len(invisibleList) > 0 {
tx = tx.Where("publisher_id NOT IN ?", invisibleList)
}
if len(c.Query("realm")) == 0 {
if len(realmList) > 0 {
tx = tx.Where("realm_id IN ? OR realm_id IS NULL", realmList)
} else {
tx = tx.Where("realm_id IS NULL")
}
}
switch c.Query("channel") {
case "friends":
tx = tx.Where("publisher_id IN ?", allowlist)
case "following":
tx = tx.Where("publisher_id IN ?", followList)
}
return tx
}
func FilterPostWithRealm(tx *gorm.DB, probe string) *gorm.DB {
if numericId, err := strconv.Atoi(probe); err == nil {
return tx.Where("realm_id = ?", uint(numericId))
}
realm, err := authkit.GetRealmByAlias(gap.Nx, probe)
if err != nil {
log.Warn().Msgf("Failed to find realm with alias %s: %s", probe, err)
return tx
}
return tx.Where("realm_id = ?", realm.ID)
}
func FilterPostWithCategory(tx *gorm.DB, alias string) *gorm.DB {
aliases := strings.Split(alias, ",")
return tx.Joins("JOIN post_categories ON posts.id = post_categories.post_id").
Joins("JOIN categories ON categories.id = post_categories.category_id").
Where("categories.alias IN ?", aliases).
Group("posts.id").
Having("COUNT(DISTINCT categories.id) = ?", len(aliases))
}
func FilterPostWithTag(tx *gorm.DB, alias string) *gorm.DB {
aliases := strings.Split(alias, ",")
return tx.Joins("JOIN post_tags ON posts.id = post_tags.post_id").
Joins("JOIN tags ON tags.id = post_tags.tag_id").
Where("tags.alias IN ?", aliases).
Group("posts.id").
Having("COUNT(DISTINCT tags.id) = ?", len(aliases))
}
func FilterPostWithType(tx *gorm.DB, t string) *gorm.DB {
return tx.Where("type = ?", t)
}
func FilterPostReply(tx *gorm.DB, replyTo ...uint) *gorm.DB {
if len(replyTo) > 0 && replyTo[0] > 0 {
return tx.Where("reply_id = ?", replyTo[0])
} else {
return tx.Where("reply_id IS NULL")
}
}
func FilterPostWithPublishedAt(tx *gorm.DB, date time.Time, uid ...uint) *gorm.DB {
var publishers []models.Publisher
if len(uid) > 0 {
if err := database.C.Where("account_id = ?", uid[0]).Find(&publishers).Error; err == nil {
}
}
return tx.
Where("(published_at < ? OR published_at IS NULL)", date).
Where("(published_until >= ? OR published_until IS NULL)", date)
}
func FilterPostWithAuthorDraft(tx *gorm.DB, uid uint) *gorm.DB {
var publishers []models.Publisher
if err := database.C.Where("account_id = ?", uid).Find(&publishers).Error; err != nil {
return FilterPostDraft(tx)
}
if len(publishers) == 0 {
return FilterPostDraft(tx)
}
idSet := lo.Map(publishers, func(item models.Publisher, index int) uint {
return item.ID
})
return tx.Where("publisher_id IN ? AND is_draft = ?", idSet, true)
}
func FilterPostDraft(tx *gorm.DB) *gorm.DB {
return tx.Where("is_draft = ? OR is_draft IS NULL", false)
}
func FilterPostDraftWithAuthor(tx *gorm.DB, uid uint) *gorm.DB {
var publishers []models.Publisher
if err := database.C.Where("account_id = ?", uid).Find(&publishers).Error; err != nil {
return FilterPostDraft(tx)
}
if len(publishers) == 0 {
return FilterPostDraft(tx)
}
idSet := lo.Map(publishers, func(item models.Publisher, index int) uint {
return item.ID
})
return tx.Where("(is_draft = ? OR is_draft IS NULL) OR publisher_id IN ?", false, idSet)
}
func FilterPostWithFuzzySearch(tx *gorm.DB, probe string) *gorm.DB {
if len(probe) == 0 {
return tx
}
probe = "%" + probe + "%"
return tx.
Where(
"(? AND body->>'content' ILIKE ? OR ? AND body->>'title' ILIKE ? OR ? AND body->>'description' ILIKE ?)",
gorm.Expr("body ? 'content'"),
probe,
gorm.Expr("body ? 'title'"),
probe,
gorm.Expr("body ? 'description'"),
probe,
)
}
func PreloadGeneral(tx *gorm.DB) *gorm.DB {
return tx.
Preload("Tags").
Preload("Categories").
Preload("Publisher").
Preload("Poll")
}
func GetPost(tx *gorm.DB, id uint) (models.Post, error) {
var item models.Post
if err := PreloadGeneral(tx).
Where("id = ?", id).
First(&item).Error; err != nil {
return item, err
}
return item, nil
}
func GetPostByAlias(tx *gorm.DB, alias, area string) (models.Post, error) {
var item models.Post
if err := PreloadGeneral(tx).
Where("alias = ?", alias).
Where("alias_prefix = ?", area).
First(&item).Error; err != nil {
return item, err
}
return item, nil
}
func CountPost(tx *gorm.DB) (int64, error) {
var count int64
if err := tx.Model(&models.Post{}).Count(&count).Error; err != nil {
return count, err
}
return count, nil
}
func CountPostReply(id uint) int64 {
var count int64
if err := database.C.Model(&models.Post{}).
Where("reply_id = ?", id).
Count(&count).Error; err != nil {
return 0
}
return count
}
func CountPostReactions(id uint) int64 {
var count int64
if err := database.C.Model(&models.Reaction{}).
Where("post_id = ?", id).
Count(&count).Error; err != nil {
return 0
}
return count
}
func ListPost(tx *gorm.DB, take int, offset int, order any, user *uint, noReact ...bool) ([]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
}
// 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]
}
// 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
}
}
}
// 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(posts, *user)
}
return posts, nil
}
func ListPostMinimal(tx *gorm.DB, take int, offset int, order any) ([]*models.Post, error) {
if take > 500 {
take = 500
}
var items []*models.Post
if err := tx.
Limit(take).Offset(offset).
Order(order).
Find(&items).Error; err != nil {
return items, err
}
return items, nil
}
func EnsurePostCategoriesAndTags(item models.Post) (models.Post, error) {
var err error
for idx, category := range item.Categories {
item.Categories[idx], err = GetCategory(category.Alias)
if err != nil {
return item, err
}
}
for idx, tag := range item.Tags {
item.Tags[idx], err = GetTagOrCreate(tag.Alias, tag.Name)
if err != nil {
return item, err
}
}
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
}
if item.PublishedAt != nil && item.PublishedAt.UTC().Unix() < time.Now().UTC().Unix() {
return item, fmt.Errorf("post cannot be published before now")
}
if item.Alias != nil {
re := regexp.MustCompile(`^[a-z0-9.-]+$`)
if !re.MatchString(*item.Alias) {
return item, fmt.Errorf("invalid post alias, learn more about alias rule on our wiki")
}
}
if item.Realm != nil {
item.AliasPrefix = &item.Realm.Alias
} else {
item.AliasPrefix = &user.Name
}
log.Debug().Any("body", item.Body).Msg("Posting a post...")
start := time.Now()
log.Debug().Any("tags", item.Tags).Any("categories", item.Categories).Msg("Preparing categories and tags...")
item, err := EnsurePostCategoriesAndTags(item)
if err != nil {
return item, err
}
log.Debug().Msg("Saving post record into database...")
if err := database.C.Save(&item).Error; err != nil {
return item, err
}
item.Publisher = user
err = UpdatePostAttachmentMeta(item)
if err != nil {
log.Error().Err(err).Msg("An error occurred when updating post attachment meta...")
}
// Notify the original poster its post has been replied
if item.ReplyID != nil && !item.IsDraft {
go NotifyReplying(item, user)
}
// Notify the subscriptions
if item.ReplyID == nil && !item.IsDraft {
go NotifySubscribers(item, user)
}
log.Debug().Dur("elapsed", time.Since(start)).Msg("The post is posted.")
return item, nil
}
func EditPost(item models.Post, og models.Post) (models.Post, error) {
if _, ok := item.Body["content_truncated"]; ok {
return item, fmt.Errorf("prevented from editing post with truncated content")
}
if !item.IsDraft && item.PublishedAt == nil {
item.PublishedAt = lo.ToPtr(time.Now())
}
if item.Alias != nil && len(*item.Alias) == 0 {
item.Alias = nil
}
if item.Alias != nil {
re := regexp.MustCompile(`^[a-z0-9.-]+$`)
if !re.MatchString(*item.Alias) {
return item, fmt.Errorf("invalid post alias, learn more about alias rule on our wiki")
}
}
if item.Realm != nil {
item.AliasPrefix = &item.Realm.Alias
} else {
item.AliasPrefix = &item.Publisher.Name
}
item, err := EnsurePostCategoriesAndTags(item)
if err != nil {
return item, err
}
_ = database.C.Model(&item).Association("Categories").Replace(item.Categories)
_ = database.C.Model(&item).Association("Tags").Replace(item.Tags)
pub := item.Publisher
err = database.C.Save(&item).Error
if err == nil {
item.Publisher = pub
err = UpdatePostAttachmentMeta(item)
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
}
func UpdatePostAttachmentMeta(item models.Post, old ...models.Post) error {
log.Debug().Any("attachments", item.Body["attachments"]).Msg("Updating post attachments meta...")
// Marking usage
sameAsOld := false
if len(old) > 0 {
sameAsOld = reflect.DeepEqual(old[0].Body, item.Body)
}
var oldBody, newBody models.PostStoryBody
if len(old) > 0 {
raw, _ := json.Marshal(old[0].Body)
json.Unmarshal(raw, &oldBody)
}
{
raw, _ := json.Marshal(item.Body)
json.Unmarshal(raw, &newBody)
}
var minusAttachments, plusAttachments []string
if len(old) > 0 && !sameAsOld {
minusAttachments = append(minusAttachments, oldBody.Attachments...)
}
if len(old) == 0 || !sameAsOld {
plusAttachments = append(plusAttachments, newBody.Attachments...)
}
if dat, ok := item.Body["thumbnail"].(string); ok {
plusAttachments = append(plusAttachments, dat)
}
if dat, ok := item.Body["video"].(string); ok {
plusAttachments = append(plusAttachments, dat)
}
if len(minusAttachments) > 0 {
filekit.CountAttachmentUsage(gap.Nx, &pproto.UpdateUsageRequest{
Rid: minusAttachments,
Delta: -1,
})
}
if len(plusAttachments) > 0 {
filekit.CountAttachmentUsage(gap.Nx, &pproto.UpdateUsageRequest{
Rid: plusAttachments,
Delta: 1,
})
}
// Updating visibility
if item.Publisher.AccountID == nil {
log.Warn().Msg("Post publisher did not have account id, skip updating attachments meta...")
return nil
}
if val, ok := item.Body["attachments"].([]any); ok && len(val) > 0 {
conn, err := gap.Nx.GetClientGrpcConn("uc")
if err != nil {
log.Error().Err(err).Msg("An error occurred when getting grpc connection to Paperclip...")
return nil
}
pc := pproto.NewAttachmentServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
resp, err := pc.UpdateVisibility(ctx, &pproto.UpdateVisibilityRequest{
Rid: lo.Map(val, func(item any, _ int) string {
return item.(string)
}),
UserId: lo.ToPtr(uint64(*item.Publisher.AccountID)),
IsIndexable: item.Visibility == models.PostVisibilityAll,
})
if err != nil {
log.Error().Any("attachments", val).Err(err).Msg("An error occurred when updating post attachment visibility...")
return err
}
log.Debug().Any("attachments", val).Int32("count", resp.Count).Msg("Post attachment visibility updated.")
} else {
log.Debug().Any("attachments", val).Msg("Post attachment visibility update skipped...")
}
return nil
}
func DeletePost(item models.Post) error {
copiedItem := item
if err := database.C.Delete(&copiedItem).Error; err != nil {
return err
}
// Cleaning up related attachments
var body models.PostStoryBody
{
raw, _ := json.Marshal(item.Body)
json.Unmarshal(raw, &body)
}
if len(body.Attachments) > 0 {
if item.Publisher.AccountID == nil {
return nil
}
err := filekit.CountAttachmentUsage(gap.Nx, &pproto.UpdateUsageRequest{
Rid: lo.Uniq(body.Attachments),
})
if err != nil {
log.Error().Err(err).Msg("An error occurred when deleting post attachment...")
}
}
return nil
}
func DeletePostInBatch(items []models.Post) error {
if err := database.C.Delete(&items).Error; err != nil {
return err
}
var bodies []models.PostStoryBody
{
raw, _ := json.Marshal(items)
json.Unmarshal(raw, &bodies)
}
var attachments []string
for idx := range items {
if len(bodies[idx].Attachments) > 0 {
attachments = append(attachments, bodies[idx].Attachments...)
}
}
err := filekit.CountAttachmentUsage(gap.Nx, &pproto.UpdateUsageRequest{
Rid: lo.Uniq(attachments),
})
if err != nil {
log.Error().Err(err).Msg("An error occurred when deleting post attachment...")
}
return nil
}
func ReactPost(user authm.Account, reaction models.Reaction) (bool, models.Reaction, error) {
var op models.Post
if err := database.C.
Where("id = ?", reaction.PostID).
Preload("Publisher").
First(&op).Error; err != nil {
return true, reaction, err
}
if err := database.C.Where(reaction).First(&reaction).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
if op.Publisher.AccountID != nil && *op.Publisher.AccountID != user.ID {
err = NotifyPosterAccount(
op.Publisher,
op,
"Post got reacted",
fmt.Sprintf("%s (%s) reacted your post a %s.", user.Nick, user.Name, reaction.Symbol),
"interactive.feedback",
fmt.Sprintf("%s reacted you", user.Nick),
)
if err != nil {
log.Error().Err(err).Msg("An error occurred when notifying user...")
}
}
err = database.C.Save(&reaction).Error
if err == nil && reaction.Attitude != models.AttitudeNeutral {
_ = ModifyPosterVoteCount(op.Publisher, reaction.Attitude == models.AttitudePositive, 1)
if reaction.Attitude == models.AttitudePositive {
op.TotalUpvote++
database.C.Model(&op).Update("total_upvote", op.TotalUpvote)
} else {
op.TotalDownvote++
database.C.Model(&op).Update("total_downvote", op.TotalDownvote)
}
}
return true, reaction, err
} else {
return true, reaction, err
}
} else {
err = database.C.Delete(&reaction).Error
if err == nil && reaction.Attitude != models.AttitudeNeutral {
_ = ModifyPosterVoteCount(op.Publisher, reaction.Attitude == models.AttitudePositive, -1)
if reaction.Attitude == models.AttitudePositive {
op.TotalUpvote--
database.C.Model(&op).Update("total_upvote", op.TotalUpvote)
} else {
op.TotalDownvote--
database.C.Model(&op).Update("total_downvote", op.TotalDownvote)
}
}
return false, reaction, err
}
}
func PinPost(post models.Post) (bool, error) {
if post.PinnedAt != nil {
post.PinnedAt = nil
} else {
post.PinnedAt = lo.ToPtr(time.Now())
}
if err := database.C.Model(&post).Update("pinned_at", post.PinnedAt).Error; err != nil {
return post.PinnedAt != nil, err
}
return post.PinnedAt != nil, nil
}
const TruncatePostContentThreshold = 160
func TruncatePostContent(post models.Post) models.Post {
if post.Body["content"] != nil {
if val, ok := post.Body["content"].(string); ok {
length := TruncatePostContentThreshold
post.Body["content_length"] = len([]rune(val))
if len([]rune(val)) >= length {
post.Body["content"] = string([]rune(val)[:length]) + "..."
post.Body["content_truncated"] = true
}
}
}
if post.RepostTo != nil {
post.RepostTo = lo.ToPtr(TruncatePostContent(*post.RepostTo))
}
if post.ReplyTo != nil {
post.ReplyTo = lo.ToPtr(TruncatePostContent(*post.ReplyTo))
}
return post
}
const TruncatePostContentShortThreshold = 80
func TruncatePostContentShort(content string) string {
length := TruncatePostContentShortThreshold
if len([]rune(content)) >= length {
return string([]rune(content)[:length]) + "..."
} else {
return content
}
}

View File

@ -0,0 +1,76 @@
package services
import (
"time"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type UniversalPostFilterConfig struct {
ShowDraft bool
ShowReply bool
ShowCollapsed bool
TimeCursor *time.Time
}
func UniversalPostFilter(c *fiber.Ctx, tx *gorm.DB, cfg ...UniversalPostFilterConfig) (*gorm.DB, error) {
var config UniversalPostFilterConfig
if len(cfg) > 0 {
config = cfg[0]
} else {
config = UniversalPostFilterConfig{}
}
timeCursor := time.Now()
if config.TimeCursor != nil {
timeCursor = *config.TimeCursor
}
if user, authenticated := c.Locals("user").(authm.Account); authenticated {
tx = FilterPostWithUserContext(c, tx, &user)
if c.QueryBool("noDraft", true) && !config.ShowDraft {
tx = FilterPostDraft(tx)
tx = FilterPostWithPublishedAt(tx, timeCursor)
} else {
tx = FilterPostDraftWithAuthor(database.C, user.ID)
tx = FilterPostWithPublishedAt(tx, timeCursor, user.ID)
}
} else {
tx = FilterPostWithUserContext(c, tx, nil)
tx = FilterPostDraft(tx)
tx = FilterPostWithPublishedAt(tx, timeCursor)
}
if c.QueryBool("noReply", true) && !config.ShowReply {
tx = FilterPostReply(tx)
}
if len(c.Query("author")) > 0 {
var author models.Publisher
if err := database.C.Where("name = ?", c.Query("author")).First(&author).Error; err != nil {
return tx, fiber.NewError(fiber.StatusNotFound, err.Error())
}
tx = tx.Where("publisher_id = ?", author.ID)
}
if len(c.Query("categories")) > 0 {
tx = FilterPostWithCategory(tx, c.Query("categories"))
}
if len(c.Query("tags")) > 0 {
tx = FilterPostWithTag(tx, c.Query("tags"))
}
if len(c.Query("type")) > 0 {
tx = FilterPostWithType(tx, c.Query("type"))
}
if len(c.Query("realm")) > 0 {
tx = FilterPostWithRealm(tx, c.Query("realm"))
}
return tx, nil
}

View File

@ -0,0 +1,160 @@
package services
import (
"fmt"
"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/paperclip/pkg/filekit"
"git.solsynth.dev/hypernet/paperclip/pkg/proto"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
)
func GetPublisher(id uint, userID uint) (models.Publisher, error) {
var publisher models.Publisher
if err := database.C.Where("id = ? AND account_id = ?", id, userID).First(&publisher).Error; err != nil {
return publisher, fmt.Errorf("unable to get publisher: %v", err)
}
return publisher, nil
}
func GetPublisherByName(name string, userID uint) (models.Publisher, error) {
var publisher models.Publisher
if err := database.C.Where("name = ? AND account_id = ?", name, userID).First(&publisher).Error; err != nil {
return publisher, fmt.Errorf("unable to get publisher: %v", err)
}
return publisher, nil
}
func CreatePersonalPublisher(user authm.Account, name, nick, desc, avatar, banner string) (models.Publisher, error) {
publisher := models.Publisher{
Type: models.PublisherTypePersonal,
Name: name,
Nick: nick,
Description: desc,
Avatar: avatar,
Banner: banner,
AccountID: &user.ID,
}
var attachments []string
if user.Avatar != nil && len(publisher.Avatar) == 0 {
attachments = append(attachments, *user.Avatar)
publisher.Avatar = *user.Avatar
}
if user.Banner != nil && len(publisher.Banner) == 0 {
attachments = append(attachments, *user.Banner)
publisher.Banner = *user.Banner
}
if len(attachments) > 0 {
filekit.CountAttachmentUsage(gap.Nx, &proto.UpdateUsageRequest{
Rid: attachments,
Delta: 1,
})
}
if err := database.C.Create(&publisher).Error; err != nil {
return publisher, err
}
return publisher, nil
}
func CreateOrganizationPublisher(user authm.Account, realm authm.Realm, name, nick, desc, avatar, banner string) (models.Publisher, error) {
publisher := models.Publisher{
Type: models.PublisherTypeOrganization,
Name: name,
Nick: nick,
Description: desc,
Avatar: avatar,
Banner: banner,
RealmID: &realm.ID,
AccountID: &user.ID,
}
var attachments []string
if realm.Avatar != nil && len(publisher.Avatar) == 0 {
attachments = append(attachments, *realm.Avatar)
publisher.Avatar = *realm.Avatar
}
if realm.Banner != nil && len(publisher.Banner) == 0 {
attachments = append(attachments, *realm.Banner)
publisher.Banner = *realm.Banner
}
if len(attachments) > 0 {
filekit.CountAttachmentUsage(gap.Nx, &proto.UpdateUsageRequest{
Rid: attachments,
Delta: 1,
})
}
if err := database.C.Create(&publisher).Error; err != nil {
return publisher, err
}
return publisher, nil
}
func EditPublisher(user authm.Account, publisher, og models.Publisher) (models.Publisher, error) {
if publisher.Type == models.PublisherTypePersonal {
if *publisher.AccountID != user.ID {
return publisher, fmt.Errorf("you cannot transfer personal publisher")
}
}
var minusAttachments, plusAttachments []string
if publisher.Avatar != og.Avatar {
minusAttachments = append(minusAttachments, og.Avatar)
plusAttachments = append(plusAttachments, publisher.Avatar)
}
if publisher.Banner != og.Banner {
minusAttachments = append(minusAttachments, og.Banner)
plusAttachments = append(plusAttachments, publisher.Banner)
}
if len(minusAttachments) > 0 {
filekit.CountAttachmentUsage(gap.Nx, &proto.UpdateUsageRequest{
Rid: minusAttachments,
Delta: -1,
})
}
if len(plusAttachments) > 0 {
filekit.CountAttachmentUsage(gap.Nx, &proto.UpdateUsageRequest{
Rid: plusAttachments,
Delta: 1,
})
}
err := database.C.Save(&publisher).Error
return publisher, err
}
func DeletePublisher(publisher models.Publisher) error {
tx := database.C.Begin()
if err := tx.Where("publisher_id = ?", publisher.ID).Delete(&models.Post{}).Error; err != nil {
tx.Rollback()
return err
}
if err := tx.Delete(&publisher).Error; err != nil {
tx.Rollback()
return err
}
err := tx.Commit().Error
if err == nil {
var attachments []string
if len(publisher.Avatar) > 0 {
attachments = append(attachments, publisher.Avatar)
}
if len(publisher.Banner) > 0 {
attachments = append(attachments, publisher.Banner)
}
if len(attachments) > 0 {
filekit.CountAttachmentUsage(gap.Nx, &proto.UpdateUsageRequest{
Rid: attachments,
Delta: -1,
})
}
}
return err
}

View File

@ -0,0 +1,120 @@
package queries
import (
"context"
"fmt"
"math"
"sort"
"time"
"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"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"gorm.io/gorm"
)
type FeedEntry struct {
Type string `json:"type"`
Data any `json:"data"`
CreatedAt time.Time `json:"created_at"`
}
func GetFeed(c *fiber.Ctx, limit int, user *uint, cursor *time.Time) ([]FeedEntry, error) {
// We got two types of data for now
// Plan to let each of them take 50% of the output
var feed []FeedEntry
// Planing the feed
limitF := float64(limit)
interCount := int(math.Ceil(limitF * 0.7))
readerCount := int(math.Ceil(limitF * 0.3))
// Internal posts
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)
}
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, posts...)
sort.Slice(feed, func(i, j int) bool {
return feed[i].CreatedAt.After(feed[j].CreatedAt)
})
// News today - from Reader
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...)
}
return feed, nil
}
// 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, 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 {
return FeedEntry{
Type: "interactive.post",
Data: services.TruncatePostContent(post),
CreatedAt: post.CreatedAt,
}
})
return entries, nil
}
func ListReaderPagesForFeed(limit int, cursor *time.Time) ([]FeedEntry, error) {
conn, err := gap.Nx.GetClientGrpcConn("re")
if err != nil {
return nil, fmt.Errorf("failed to get grpc connection with reader: %v", err)
}
client := proto.NewFeedServiceClient(conn)
request := &proto.GetFeedRequest{
Limit: int64(limit),
}
if cursor != nil {
request.Cursor = lo.ToPtr(uint64(cursor.UnixMilli()))
}
resp, err := client.GetFeed(context.Background(), request)
if err != nil {
return nil, fmt.Errorf("failed to get feed from reader: %v", err)
}
var createdAt time.Time
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
}

View File

@ -0,0 +1,248 @@
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"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"git.solsynth.dev/hypernet/interactive/pkg/internal/services"
"git.solsynth.dev/hypernet/paperclip/pkg/filekit"
fmodels "git.solsynth.dev/hypernet/paperclip/pkg/filekit/models"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
amodels "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"gorm.io/gorm"
)
var singularAttachmentFields = []string{"video", "thumbnail"}
func CompletePostMeta(in ...models.Post) ([]models.Post, error) {
// Collect post IDs
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] = &in[i]
}
// Batch load reactions
if mapping, err := services.BatchListPostReactions(database.C.Where("post_id IN ?", idx), "post_id"); err != nil {
return in, err
} else {
for postID, reactions := range mapping {
if post, exists := itemMap[postID]; exists {
post.Metric.ReactionList = reactions
}
}
}
// 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 in, err
}
for _, info := range replies {
if post, exists := itemMap[info.PostID]; exists {
post.Metric.ReplyCount = info.Count
}
}
// Batch load some metadata
var err error
var attachmentsRid []string
var usersId []uint
var realmsId []uint
// 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 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)
}
}
}
}
log.Debug().Int("attachments", len(attachmentsRid)).Int("users", len(usersId)).Msg("Scanned metadata to load for listing post...")
// Batch load attachments
attachmentsRid = lo.Uniq(attachmentsRid)
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)
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 in {
var this []fmodels.Attachment
if len(bodies[idx].Attachments) > 0 {
this = lo.Filter(attachments, func(item fmodels.Attachment, _ int) bool {
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
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
if user != nil {
services.AddPostViews(posts, *user)
}
return posts, nil
}

View File

@ -0,0 +1,60 @@
package services
import (
"fmt"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
"github.com/samber/lo"
"gorm.io/gorm"
)
func ListPostReactions(tx *gorm.DB) (map[string]int64, error) {
var reactions []struct {
Symbol string
Count int64
}
if err := tx.Model(&models.Reaction{}).
Select("symbol, COUNT(id) as count").
Group("symbol").
Scan(&reactions).Error; err != nil {
return map[string]int64{}, err
}
return lo.SliceToMap(reactions, func(item struct {
Symbol string
Count int64
},
) (string, int64) {
return item.Symbol, item.Count
}), nil
}
func BatchListPostReactions(tx *gorm.DB, indexField string) (map[uint]map[string]int64, error) {
var reactions []struct {
ID uint
Symbol string
Count int64
}
reactInfo := map[uint]map[string]int64{}
if err := tx.Model(&models.Reaction{}).
Select(fmt.Sprintf("%s as id, symbol, COUNT(*) as count", indexField)).
Group("id, symbol").
Scan(&reactions).Error; err != nil {
return reactInfo, err
}
for _, info := range reactions {
if _, ok := reactInfo[info.ID]; !ok {
reactInfo[info.ID] = make(map[string]int64)
}
if _, exists := reactInfo[info.ID][info.Symbol]; exists {
reactInfo[info.ID][info.Symbol] += info.Count
} else {
reactInfo[info.ID][info.Symbol] = info.Count
}
}
return reactInfo, nil
}

View File

@ -0,0 +1,308 @@
package services
import (
"errors"
"fmt"
"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/nexus/pkg/proto"
"git.solsynth.dev/hypernet/passport/pkg/authkit"
authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models"
"git.solsynth.dev/hypernet/pusher/pkg/pushkit"
"github.com/samber/lo"
"gorm.io/gorm"
)
func GetSubscriptionOnUser(user authm.Account, target models.Publisher) (*models.Subscription, error) {
var subscription models.Subscription
if err := database.C.Where("follower_id = ? AND account_id = ?", user.ID, target.ID).First(&subscription).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, fmt.Errorf("unable to get subscription: %v", err)
}
return &subscription, nil
}
func GetSubscriptionOnTag(user authm.Account, target models.Tag) (*models.Subscription, error) {
var subscription models.Subscription
if err := database.C.Where("follower_id = ? AND tag_id = ?", user.ID, target.ID).First(&subscription).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, fmt.Errorf("unable to get subscription: %v", err)
}
return &subscription, nil
}
func GetSubscriptionOnCategory(user authm.Account, target models.Category) (*models.Subscription, error) {
var subscription models.Subscription
if err := database.C.Where("follower_id = ? AND category_id = ?", user.ID, target.ID).First(&subscription).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, fmt.Errorf("unable to get subscription: %v", err)
}
return &subscription, nil
}
func SubscribeToUser(user authm.Account, target models.Publisher) (models.Subscription, error) {
var subscription models.Subscription
if err := database.C.Where("follower_id = ? AND account_id = ?", user.ID, target.ID).First(&subscription).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return subscription, fmt.Errorf("subscription already exists")
}
}
subscription = models.Subscription{
FollowerID: user.ID,
AccountID: &target.ID,
}
err := database.C.Save(&subscription).Error
return subscription, err
}
func SubscribeToTag(user authm.Account, target models.Tag) (models.Subscription, error) {
var subscription models.Subscription
if err := database.C.Where("follower_id = ? AND tag_id = ?", user.ID, target.ID).First(&subscription).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return subscription, fmt.Errorf("subscription already exists")
}
}
subscription = models.Subscription{
FollowerID: user.ID,
TagID: &target.ID,
}
err := database.C.Save(&subscription).Error
return subscription, err
}
func SubscribeToCategory(user authm.Account, target models.Category) (models.Subscription, error) {
var subscription models.Subscription
if err := database.C.Where("follower_id = ? AND category_id = ?", user.ID, target.ID).First(&subscription).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return subscription, fmt.Errorf("subscription already exists")
}
}
subscription = models.Subscription{
FollowerID: user.ID,
CategoryID: &target.ID,
}
err := database.C.Save(&subscription).Error
return subscription, err
}
func UnsubscribeFromUser(user authm.Account, target models.Publisher) error {
var subscription models.Subscription
if err := database.C.Where("follower_id = ? AND account_id = ?", user.ID, target.ID).First(&subscription).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("subscription does not exist")
}
return fmt.Errorf("unable to check subscription is exists or not: %v", err)
}
err := database.C.Delete(&subscription).Error
return err
}
func UnsubscribeFromTag(user authm.Account, target models.Tag) error {
var subscription models.Subscription
if err := database.C.Where("follower_id = ? AND tag_id = ?", user.ID, target.ID).First(&subscription).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("subscription does not exist")
}
return fmt.Errorf("unable to check subscription is exists or not: %v", err)
}
err := database.C.Delete(&subscription).Error
return err
}
func UnsubscribeFromCategory(user authm.Account, target models.Category) error {
var subscription models.Subscription
if err := database.C.Where("follower_id = ? AND category_id = ?", user.ID, target.ID).First(&subscription).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("subscription does not exist")
}
return fmt.Errorf("unable to check subscription is exists or not: %v", err)
}
err := database.C.Delete(&subscription).Error
return err
}
func NotifyUserSubscription(poster models.Publisher, item models.Post, content string, title *string) error {
if item.Visibility == models.PostVisibilityNone {
return nil
}
var subscriptions []models.Subscription
if err := database.C.Where("account_id = ?", poster.ID).Find(&subscriptions).Error; err != nil {
return fmt.Errorf("unable to get subscriptions: %v", err)
}
nTitle := fmt.Sprintf("New post from %s (%s)", poster.Nick, poster.Name)
nSubtitle := "From your subscription"
body := TruncatePostContentShort(content)
if title != nil {
body = fmt.Sprintf("%s\n%s", *title, body)
}
userIDs := make([]uint64, 0, len(subscriptions))
for _, subscription := range subscriptions {
userIDs = append(userIDs, uint64(subscription.FollowerID))
}
if item.Visibility == models.PostVisibilitySelected {
userIDs = lo.Filter(userIDs, func(entry uint64, index int) bool {
return lo.Contains(item.VisibleUsers, uint(entry))
})
} else if item.Visibility == models.PostVisibilityFiltered {
userIDs = lo.Filter(userIDs, func(entry uint64, index int) bool {
return !lo.Contains(item.InvisibleUsers, uint(entry))
})
} else if invisibleList := ListPostInvisibleUser(poster, item.Visibility); len(invisibleList) > 0 {
userIDs = lo.Filter(userIDs, func(entry uint64, index int) bool {
return !lo.Contains(invisibleList, uint(entry))
})
}
metadata := map[string]any{
"related_post": TruncatePostContent(item),
}
err := authkit.NotifyUserBatch(gap.Nx, userIDs, pushkit.Notification{
Topic: "interactive.subscription",
Title: nTitle,
Subtitle: nSubtitle,
Body: body,
Metadata: metadata,
Priority: 3,
})
return err
}
func NotifyTagSubscription(poster models.Tag, og models.Publisher, item models.Post, content string, title *string) error {
if item.Visibility == models.PostVisibilityNone {
return nil
}
var subscriptions []models.Subscription
if err := database.C.Where("tag_id = ?", poster.ID).Find(&subscriptions).Error; err != nil {
return fmt.Errorf("unable to get subscriptions: %v", err)
}
nTitle := fmt.Sprintf("New post in %s by %s (%s)", poster.Name, og.Nick, og.Name)
nSubtitle := "From your subscription"
body := TruncatePostContentShort(content)
if title != nil {
body = fmt.Sprintf("%s\n%s", *title, body)
}
userIDs := make([]uint64, 0, len(subscriptions))
for _, subscription := range subscriptions {
userIDs = append(userIDs, uint64(subscription.FollowerID))
}
if item.Visibility == models.PostVisibilitySelected {
userIDs = lo.Filter(userIDs, func(entry uint64, index int) bool {
return lo.Contains(item.VisibleUsers, uint(entry))
})
} else if item.Visibility == models.PostVisibilityFiltered {
userIDs = lo.Filter(userIDs, func(entry uint64, index int) bool {
return !lo.Contains(item.InvisibleUsers, uint(entry))
})
} else if invisibleList := ListPostInvisibleUser(item.Publisher, item.Visibility); len(invisibleList) > 0 {
userIDs = lo.Filter(userIDs, func(entry uint64, index int) bool {
return !lo.Contains(invisibleList, uint(entry))
})
}
err := authkit.NotifyUserBatch(gap.Nx, userIDs, pushkit.Notification{
Topic: "interactive.subscription",
Title: nTitle,
Subtitle: nSubtitle,
Body: body,
Priority: 3,
})
return err
}
func NotifyCategorySubscription(poster models.Category, og models.Publisher, item models.Post, content string, title *string) error {
if item.Visibility == models.PostVisibilityNone {
return nil
}
var subscriptions []models.Subscription
if err := database.C.Where("category_id = ?", poster.ID).Find(&subscriptions).Error; err != nil {
return fmt.Errorf("unable to get subscriptions: %v", err)
}
nTitle := fmt.Sprintf("New post in %s by %s (%s)", poster.Name, og.Nick, og.Name)
nSubtitle := "From your subscription"
body := TruncatePostContentShort(content)
if title != nil {
body = fmt.Sprintf("%s\n%s", *title, body)
}
userIDs := make([]uint64, 0, len(subscriptions))
for _, subscription := range subscriptions {
userIDs = append(userIDs, uint64(subscription.FollowerID))
}
if item.Visibility == models.PostVisibilitySelected {
userIDs = lo.Filter(userIDs, func(entry uint64, index int) bool {
return lo.Contains(item.VisibleUsers, uint(entry))
})
} else if item.Visibility == models.PostVisibilityFiltered {
userIDs = lo.Filter(userIDs, func(entry uint64, index int) bool {
return !lo.Contains(item.InvisibleUsers, uint(entry))
})
} else if invisibleList := ListPostInvisibleUser(item.Publisher, item.Visibility); len(invisibleList) > 0 {
userIDs = lo.Filter(userIDs, func(entry uint64, index int) bool {
return !lo.Contains(invisibleList, uint(entry))
})
}
err := authkit.NotifyUserBatch(gap.Nx, userIDs, pushkit.Notification{
Topic: "interactive.subscription",
Title: nTitle,
Subtitle: nSubtitle,
Body: body,
Priority: 3,
})
return err
}
// ListPostInvisibleUser will return a list of users which should not be notified the post.
// NOTICE If the visibility is PostVisibilitySelected, PostVisibilityFiltered or PostVisibilityNone, you need do extra steps to filter users
// WARNING This function won't use cache, be careful of the queries
func ListPostInvisibleUser(og models.Publisher, visibility models.PostVisibilityLevel) []uint {
switch visibility {
case models.PostVisibilityAll:
return []uint{}
case models.PostVisibilityFriends:
if og.AccountID == nil {
return []uint{}
}
userFriends, _ := authkit.ListRelative(gap.Nx, *og.AccountID, int32(authm.RelationshipFriend), true)
return lo.Map(userFriends, func(item *proto.UserInfo, index int) uint {
return uint(item.GetId())
})
default:
return nil
}
}

View File

@ -0,0 +1,30 @@
package services
import (
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/models"
)
func ListTags(take int, offset int) ([]models.Tag, error) {
var tags []models.Tag
err := database.C.Offset(offset).Limit(take).Find(&tags).Error
return tags, err
}
func SearchTags(take int, offset int, probe string) ([]models.Tag, error) {
probe = "%" + probe + "%"
var tags []models.Tag
err := database.C.Where("alias LIKE ?", probe).Offset(offset).Limit(take).Find(&tags).Error
return tags, err
}
func GetTag(alias string) (models.Tag, error) {
var tag models.Tag
if err := database.C.Where(models.Tag{Alias: alias}).First(&tag).Error; err != nil {
return tag, err
}
return tag, nil
}

83
pkg/main.go Normal file
View File

@ -0,0 +1,83 @@
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
pkg "git.solsynth.dev/hypernet/interactive/pkg/internal"
"git.solsynth.dev/hypernet/interactive/pkg/internal/gap"
"git.solsynth.dev/hypernet/nexus/pkg/nex/sec"
"github.com/fatih/color"
"git.solsynth.dev/hypernet/interactive/pkg/internal/database"
"git.solsynth.dev/hypernet/interactive/pkg/internal/grpc"
"git.solsynth.dev/hypernet/interactive/pkg/internal/http"
"git.solsynth.dev/hypernet/interactive/pkg/internal/services"
"github.com/robfig/cron/v3"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
func init() {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout})
}
func main() {
// Booting screen
fmt.Println(color.YellowString(" ___ _ _ _\n|_ _|_ __ | |_ ___ _ __ __ _ ___| |_(_)_ _____\n | || '_ \\| __/ _ \\ '__/ _` |/ __| __| \\ \\ / / _ \\\n | || | | | || __/ | | (_| | (__| |_| |\\ V / __/\n|___|_| |_|\\__\\___|_| \\__,_|\\___|\\__|_| \\_/ \\___|"))
fmt.Printf("%s v%s\n", color.New(color.FgHiYellow).Add(color.Bold).Sprintf("Hypernet.Interactive"), pkg.AppVersion)
fmt.Printf("The social networking service in Hypernet\n")
color.HiBlack("=====================================================\n")
// Configure settings
viper.AddConfigPath(".")
viper.AddConfigPath("..")
viper.SetConfigName("settings")
viper.SetConfigType("toml")
// Load settings
if err := viper.ReadInConfig(); err != nil {
log.Panic().Err(err).Msg("An error occurred when loading settings.")
}
// Connect to nexus
if err := gap.InitializeToNexus(); err != nil {
log.Fatal().Err(err).Msg("An error occurred when connecting to nexus...")
}
// Load keypair
if reader, err := sec.NewInternalTokenReader(viper.GetString("security.internal_public_key")); err != nil {
log.Error().Err(err).Msg("An error occurred when reading internal public key for jwt. Authentication related features will be disabled.")
} else {
http.IReader = reader
log.Info().Msg("Internal jwt public key loaded.")
}
// Connect to database
if err := database.NewGorm(); err != nil {
log.Fatal().Err(err).Msg("An error occurred when connect to database.")
} else if err := database.RunMigration(database.C); err != nil {
log.Fatal().Err(err).Msg("An error occurred when running database auto migration.")
}
// Configure timed tasks
quartz := cron.New(cron.WithLogger(cron.VerbosePrintfLogger(&log.Logger)))
quartz.AddFunc("@every 5m", services.FlushPostViews)
quartz.Start()
// App
go http.NewServer().Listen()
go grpc.NewGrpc().Listen()
// Messages
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
quartz.Stop()
}

View File

@ -1,31 +0,0 @@
package models
import "time"
// Account profiles basically fetched from Hydrogen.Passport
// But cache at here for better usage
// At the same time this model can make relations between local models
type Account struct {
BaseModel
Name string `json:"name"`
Nick string `json:"nick"`
Avatar string `json:"avatar"`
Description string `json:"description"`
EmailAddress string `json:"email_address"`
PowerLevel int `json:"power_level"`
Posts []Post `json:"posts" gorm:"foreignKey:AuthorID"`
Attachments []Attachment `json:"attachments" gorm:"foreignKey:AuthorID"`
LikedPosts []PostLike `json:"liked_posts"`
DislikedPosts []PostDislike `json:"disliked_posts"`
Realms []Realm `json:"realms"`
ExternalID uint `json:"external_id"`
}
type AccountMembership struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
FollowerID uint
FollowingID uint
}

View File

@ -1,30 +0,0 @@
package models
import (
"fmt"
"github.com/spf13/viper"
"path/filepath"
)
type Attachment struct {
BaseModel
FileID string `json:"file_id"`
Filesize int64 `json:"filesize"`
Filename string `json:"filename"`
Mimetype string `json:"mimetype"`
ExternalUrl string `json:"external_url"`
Post *Post `json:"post"`
Author Account `json:"author"`
PostID *uint `json:"post_id"`
AuthorID uint `json:"author_id"`
}
func (v Attachment) GetStoragePath() string {
basepath := viper.GetString("content")
return filepath.Join(basepath, v.FileID)
}
func (v Attachment) GetAccessPath() string {
return fmt.Sprintf("/api/attachments/o/%s", v.FileID)
}

View File

@ -1,17 +0,0 @@
package models
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type JSONMap = datatypes.JSONType[map[string]any]
type BaseModel struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}

View File

@ -1,30 +0,0 @@
package models
import "time"
type Post struct {
BaseModel
Alias string `json:"alias" gorm:"uniqueIndex"`
Title string `json:"title"`
Content string `json:"content"`
Tags []Tag `json:"tags" gorm:"many2many:post_tags"`
Categories []Category `json:"categories" gorm:"many2many:post_categories"`
Attachments []Attachment `json:"attachments"`
LikedAccounts []PostLike `json:"liked_accounts"`
DislikedAccounts []PostDislike `json:"disliked_accounts"`
RepostTo *Post `json:"repost_to" gorm:"foreignKey:RepostID"`
ReplyTo *Post `json:"reply_to" gorm:"foreignKey:ReplyID"`
PinnedAt *time.Time `json:"pinned_at"`
EditedAt *time.Time `json:"edited_at"`
PublishedAt time.Time `json:"published_at"`
RepostID *uint `json:"repost_id"`
ReplyID *uint `json:"reply_id"`
RealmID *uint `json:"realm_id"`
AuthorID uint `json:"author_id"`
Author Account `json:"author"`
// Dynamic Calculating Values
LikeCount int64 `json:"like_count" gorm:"-"`
DislikeCount int64 `json:"dislike_count" gorm:"-"`
}

View File

@ -1,19 +0,0 @@
package models
import "time"
type PostLike struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PostID uint `json:"post_id"`
AccountID uint `json:"account_id"`
}
type PostDislike struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PostID uint `json:"post_id"`
AccountID uint `json:"account_id"`
}

View File

@ -1,10 +0,0 @@
package models
type Realm struct {
BaseModel
Name string `json:"name"`
Description string `json:"description"`
Posts []Post `json:"posts"`
AccountID uint `json:"account_id"`
}

271
pkg/proto/feed.pb.go Normal file
View File

@ -0,0 +1,271 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.28.3
// source: feed.proto
package proto
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type FeedItem struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"`
CreatedAt uint64 `protobuf:"varint,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
Content []byte `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"`
}
func (x *FeedItem) Reset() {
*x = FeedItem{}
mi := &file_feed_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *FeedItem) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*FeedItem) ProtoMessage() {}
func (x *FeedItem) ProtoReflect() protoreflect.Message {
mi := &file_feed_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use FeedItem.ProtoReflect.Descriptor instead.
func (*FeedItem) Descriptor() ([]byte, []int) {
return file_feed_proto_rawDescGZIP(), []int{0}
}
func (x *FeedItem) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *FeedItem) GetCreatedAt() uint64 {
if x != nil {
return x.CreatedAt
}
return 0
}
func (x *FeedItem) GetContent() []byte {
if x != nil {
return x.Content
}
return nil
}
type GetFeedRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Limit int64 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"`
UserId uint64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Cursor *uint64 `protobuf:"varint,3,opt,name=cursor,proto3,oneof" json:"cursor,omitempty"`
}
func (x *GetFeedRequest) Reset() {
*x = GetFeedRequest{}
mi := &file_feed_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetFeedRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetFeedRequest) ProtoMessage() {}
func (x *GetFeedRequest) ProtoReflect() protoreflect.Message {
mi := &file_feed_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetFeedRequest.ProtoReflect.Descriptor instead.
func (*GetFeedRequest) Descriptor() ([]byte, []int) {
return file_feed_proto_rawDescGZIP(), []int{1}
}
func (x *GetFeedRequest) GetLimit() int64 {
if x != nil {
return x.Limit
}
return 0
}
func (x *GetFeedRequest) GetUserId() uint64 {
if x != nil {
return x.UserId
}
return 0
}
func (x *GetFeedRequest) GetCursor() uint64 {
if x != nil && x.Cursor != nil {
return *x.Cursor
}
return 0
}
type GetFeedResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Items []*FeedItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
}
func (x *GetFeedResponse) Reset() {
*x = GetFeedResponse{}
mi := &file_feed_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetFeedResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetFeedResponse) ProtoMessage() {}
func (x *GetFeedResponse) ProtoReflect() protoreflect.Message {
mi := &file_feed_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetFeedResponse.ProtoReflect.Descriptor instead.
func (*GetFeedResponse) Descriptor() ([]byte, []int) {
return file_feed_proto_rawDescGZIP(), []int{2}
}
func (x *GetFeedResponse) GetItems() []*FeedItem {
if x != nil {
return x.Items
}
return nil
}
var File_feed_proto protoreflect.FileDescriptor
var file_feed_proto_rawDesc = []byte{
0x0a, 0x0a, 0x66, 0x65, 0x65, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x22, 0x57, 0x0a, 0x08, 0x46, 0x65, 0x65, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12,
0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74,
0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61,
0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20,
0x01, 0x28, 0x0c, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22, 0x67, 0x0a, 0x0e,
0x47, 0x65, 0x74, 0x46, 0x65, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14,
0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c,
0x69, 0x6d, 0x69, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a,
0x06, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x48, 0x00, 0x52,
0x06, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x63,
0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0x38, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x46, 0x65, 0x65, 0x64,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d,
0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
0x46, 0x65, 0x65, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x32,
0x47, 0x0a, 0x0b, 0x46, 0x65, 0x65, 0x64, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x38,
0x0a, 0x07, 0x47, 0x65, 0x74, 0x46, 0x65, 0x65, 0x64, 0x12, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x65, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x65, 0x65, 0x64,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x09, 0x5a, 0x07, 0x2e, 0x3b, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_feed_proto_rawDescOnce sync.Once
file_feed_proto_rawDescData = file_feed_proto_rawDesc
)
func file_feed_proto_rawDescGZIP() []byte {
file_feed_proto_rawDescOnce.Do(func() {
file_feed_proto_rawDescData = protoimpl.X.CompressGZIP(file_feed_proto_rawDescData)
})
return file_feed_proto_rawDescData
}
var file_feed_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_feed_proto_goTypes = []any{
(*FeedItem)(nil), // 0: proto.FeedItem
(*GetFeedRequest)(nil), // 1: proto.GetFeedRequest
(*GetFeedResponse)(nil), // 2: proto.GetFeedResponse
}
var file_feed_proto_depIdxs = []int32{
0, // 0: proto.GetFeedResponse.items:type_name -> proto.FeedItem
1, // 1: proto.FeedService.GetFeed:input_type -> proto.GetFeedRequest
2, // 2: proto.FeedService.GetFeed:output_type -> proto.GetFeedResponse
2, // [2:3] is the sub-list for method output_type
1, // [1:2] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_feed_proto_init() }
func file_feed_proto_init() {
if File_feed_proto != nil {
return
}
file_feed_proto_msgTypes[1].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_feed_proto_rawDesc,
NumEnums: 0,
NumMessages: 3,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_feed_proto_goTypes,
DependencyIndexes: file_feed_proto_depIdxs,
MessageInfos: file_feed_proto_msgTypes,
}.Build()
File_feed_proto = out.File
file_feed_proto_rawDesc = nil
file_feed_proto_goTypes = nil
file_feed_proto_depIdxs = nil
}

25
pkg/proto/feed.proto Normal file
View File

@ -0,0 +1,25 @@
syntax = "proto3";
option go_package = ".;proto";
package proto;
service FeedService {
rpc GetFeed(GetFeedRequest) returns (GetFeedResponse);
}
message FeedItem {
string type = 1;
uint64 created_at = 2;
bytes content = 3;
}
message GetFeedRequest {
int64 limit = 1;
uint64 user_id = 2;
optional uint64 cursor = 3;
}
message GetFeedResponse {
repeated FeedItem items = 1;
}

121
pkg/proto/feed_grpc.pb.go Normal file
View File

@ -0,0 +1,121 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.28.3
// source: feed.proto
package proto
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
FeedService_GetFeed_FullMethodName = "/proto.FeedService/GetFeed"
)
// FeedServiceClient is the client API for FeedService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type FeedServiceClient interface {
GetFeed(ctx context.Context, in *GetFeedRequest, opts ...grpc.CallOption) (*GetFeedResponse, error)
}
type feedServiceClient struct {
cc grpc.ClientConnInterface
}
func NewFeedServiceClient(cc grpc.ClientConnInterface) FeedServiceClient {
return &feedServiceClient{cc}
}
func (c *feedServiceClient) GetFeed(ctx context.Context, in *GetFeedRequest, opts ...grpc.CallOption) (*GetFeedResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetFeedResponse)
err := c.cc.Invoke(ctx, FeedService_GetFeed_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// FeedServiceServer is the server API for FeedService service.
// All implementations must embed UnimplementedFeedServiceServer
// for forward compatibility.
type FeedServiceServer interface {
GetFeed(context.Context, *GetFeedRequest) (*GetFeedResponse, error)
mustEmbedUnimplementedFeedServiceServer()
}
// UnimplementedFeedServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedFeedServiceServer struct{}
func (UnimplementedFeedServiceServer) GetFeed(context.Context, *GetFeedRequest) (*GetFeedResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetFeed not implemented")
}
func (UnimplementedFeedServiceServer) mustEmbedUnimplementedFeedServiceServer() {}
func (UnimplementedFeedServiceServer) testEmbeddedByValue() {}
// UnsafeFeedServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to FeedServiceServer will
// result in compilation errors.
type UnsafeFeedServiceServer interface {
mustEmbedUnimplementedFeedServiceServer()
}
func RegisterFeedServiceServer(s grpc.ServiceRegistrar, srv FeedServiceServer) {
// If the following call pancis, it indicates UnimplementedFeedServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&FeedService_ServiceDesc, srv)
}
func _FeedService_GetFeed_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetFeedRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(FeedServiceServer).GetFeed(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: FeedService_GetFeed_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(FeedServiceServer).GetFeed(ctx, req.(*GetFeedRequest))
}
return interceptor(ctx, in, info, handler)
}
// FeedService_ServiceDesc is the grpc.ServiceDesc for FeedService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var FeedService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "proto.FeedService",
HandlerType: (*FeedServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetFeed",
Handler: _FeedService_GetFeed_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "feed.proto",
}

View File

@ -1,12 +0,0 @@
package security
import "golang.org/x/crypto/bcrypt"
func HashPassword(raw string) string {
data, _ := bcrypt.GenerateFromPassword([]byte(raw), 12)
return string(data)
}
func VerifyPassword(text string, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(password), []byte(text)) == nil
}

View File

@ -1,56 +0,0 @@
package security
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/viper"
)
type PayloadClaims struct {
jwt.RegisteredClaims
Type string `json:"typ"`
}
const (
JwtAccessType = "access"
JwtRefreshType = "refresh"
)
func EncodeJwt(id string, typ, sub string, aud []string, exp time.Time) (string, error) {
tk := jwt.NewWithClaims(jwt.SigningMethodHS512, PayloadClaims{
jwt.RegisteredClaims{
Subject: sub,
Audience: aud,
Issuer: fmt.Sprintf("https://%s", viper.GetString("domain")),
ExpiresAt: jwt.NewNumericDate(exp),
NotBefore: jwt.NewNumericDate(time.Now()),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: id,
},
typ,
})
return tk.SignedString([]byte(viper.GetString("secret")))
}
func DecodeJwt(str string) (PayloadClaims, error) {
var claims PayloadClaims
tk, err := jwt.ParseWithClaims(str, &claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(viper.GetString("secret")), nil
})
if err != nil {
return claims, err
}
if data, ok := tk.Claims.(*PayloadClaims); ok {
return *data, nil
} else {
return claims, fmt.Errorf("unexpected token payload: not payload claims type")
}
}

View File

@ -1,38 +0,0 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"github.com/gofiber/fiber/v2"
"github.com/spf13/viper"
"path/filepath"
)
func openAttachment(c *fiber.Ctx) error {
id := c.Params("fileId")
basepath := viper.GetString("content")
return c.SendFile(filepath.Join(basepath, id))
}
func uploadAttachment(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
file, err := c.FormFile("attachment")
if err != nil {
return err
}
attachment, err := services.NewAttachment(user, file)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if err := c.SaveFile(file, attachment.GetStoragePath()); err != nil {
return err
}
return c.JSON(fiber.Map{
"info": attachment,
"url": attachment.GetAccessPath(),
})
}

View File

@ -1,35 +0,0 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/security"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/keyauth"
"strconv"
)
var auth = keyauth.New(keyauth.Config{
KeyLookup: "header:Authorization",
AuthScheme: "Bearer",
Validator: func(c *fiber.Ctx, token string) (bool, error) {
claims, err := security.DecodeJwt(token)
if err != nil {
return false, err
}
id, _ := strconv.Atoi(claims.Subject)
var user models.Account
if err := database.C.Where(&models.Account{
BaseModel: models.BaseModel{ID: uint(id)},
}).First(&user).Error; err != nil {
return false, err
}
c.Locals("principal", user)
return true, nil
},
ContextKey: "token",
})

View File

@ -1,93 +0,0 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"context"
"encoding/json"
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/spf13/viper"
"golang.org/x/oauth2"
)
var cfg oauth2.Config
func buildOauth2Config() {
cfg = oauth2.Config{
RedirectURL: fmt.Sprintf("https://%s/auth/callback", viper.GetString("domain")),
ClientID: viper.GetString("passport.client_id"),
ClientSecret: viper.GetString("passport.client_secret"),
Scopes: []string{"openid"},
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s/auth/o/connect", viper.GetString("passport.endpoint")),
TokenURL: fmt.Sprintf("%s/api/auth/token", viper.GetString("passport.endpoint")),
AuthStyle: oauth2.AuthStyleInParams,
},
}
}
func doLogin(c *fiber.Ctx) error {
buildOauth2Config()
url := cfg.AuthCodeURL(uuid.NewString())
return c.JSON(fiber.Map{
"target": url,
})
}
func postLogin(c *fiber.Ctx) error {
buildOauth2Config()
code := c.Query("code")
token, err := cfg.Exchange(context.Background(), code)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to exchange token: %q", err))
}
agent := fiber.
Get(fmt.Sprintf("%s/api/users/me", viper.GetString("passport.endpoint"))).
Set(fiber.HeaderAuthorization, fmt.Sprintf("Bearer %s", token.AccessToken))
_, body, errs := agent.Bytes()
if len(errs) > 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to get userinfo: %q", errs))
}
var userinfo services.PassportUserinfo
err = json.Unmarshal(body, &userinfo)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to parse userinfo: %q", err))
}
account, err := services.LinkAccount(userinfo)
access, refresh, err := services.GetToken(account)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to get token: %q", err))
}
return c.JSON(fiber.Map{
"access_token": access,
"refresh_token": refresh,
})
}
func doRefreshToken(c *fiber.Ctx) error {
var data struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
access, refresh, err := services.RefreshToken(data.RefreshToken)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to get token: %q", err))
}
return c.JSON(fiber.Map{
"access_token": access,
"refresh_token": refresh,
})
}

View File

@ -1,210 +0,0 @@
package server
import (
"strings"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/samber/lo"
)
func listPost(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
authorId := c.QueryInt("authorId", 0)
tx := database.C.
Where("realm_id IS NULL").
Where("published_at <= ? OR published_at IS NULL", time.Now()).
Order("created_at desc")
if authorId > 0 {
tx = tx.Where(&models.Post{AuthorID: uint(authorId)})
}
var count int64
if err := tx.
Model(&models.Post{}).
Count(&count).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
posts, err := services.ListPost(tx, take, offset)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": posts,
})
}
func createPost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data struct {
Alias string `json:"alias"`
Title string `json:"title"`
Content string `json:"content" validate:"required"`
Tags []models.Tag `json:"tags"`
Categories []models.Category `json:"categories"`
Attachments []models.Attachment `json:"attachments"`
PublishedAt *time.Time `json:"published_at"`
RealmID *uint `json:"realm_id"`
RepostTo uint `json:"repost_to"`
ReplyTo uint `json:"reply_to"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
} else if len(data.Alias) == 0 {
data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "")
}
var repostTo *uint = nil
var replyTo *uint = nil
var relatedCount int64
if data.RepostTo > 0 {
if err := database.C.Where(&models.Post{
BaseModel: models.BaseModel{ID: data.RepostTo},
}).Model(&models.Post{}).Count(&relatedCount).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if relatedCount <= 0 {
return fiber.NewError(fiber.StatusNotFound, "related post was not found")
} else {
repostTo = &data.RepostTo
}
} else if data.ReplyTo > 0 {
if err := database.C.Where(&models.Post{
BaseModel: models.BaseModel{ID: data.ReplyTo},
}).Model(&models.Post{}).Count(&relatedCount).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if relatedCount <= 0 {
return fiber.NewError(fiber.StatusNotFound, "related post was not found")
} else {
replyTo = &data.ReplyTo
}
}
var realm *models.Realm
if data.RealmID != nil {
if err := database.C.Where(&models.Realm{
BaseModel: models.BaseModel{ID: *data.RealmID},
}).First(&realm).Error; err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
}
post, err := services.NewPost(
user,
realm,
data.Alias,
data.Title,
data.Content,
data.Attachments,
data.Categories,
data.Tags,
data.PublishedAt,
replyTo,
repostTo,
)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(post)
}
func editPost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("postId", 0)
var data struct {
Alias string `json:"alias" validate:"required"`
Title string `json:"title"`
Content string `json:"content" validate:"required"`
PublishedAt *time.Time `json:"published_at"`
Tags []models.Tag `json:"tags"`
Categories []models.Category `json:"categories"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
var post models.Post
if err := database.C.Where(&models.Post{
BaseModel: models.BaseModel{ID: uint(id)},
AuthorID: user.ID,
}).First(&post).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
post, err := services.EditPost(
post,
data.Alias,
data.Title,
data.Content,
data.PublishedAt,
data.Categories,
data.Tags,
)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(post)
}
func reactPost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("postId", 0)
var post models.Post
if err := database.C.Where(&models.Post{
BaseModel: models.BaseModel{ID: uint(id)},
}).First(&post).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
switch strings.ToLower(c.Params("reactType")) {
case "like":
if positive, err := services.LikePost(user, post); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.SendStatus(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent))
}
case "dislike":
if positive, err := services.DislikePost(user, post); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else {
return c.SendStatus(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent))
}
default:
return fiber.NewError(fiber.StatusBadRequest, "unsupported reaction")
}
}
func deletePost(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("postId", 0)
var post models.Post
if err := database.C.Where(&models.Post{
BaseModel: models.BaseModel{ID: uint(id)},
AuthorID: user.ID,
}).First(&post).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.DeletePost(post); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -1,148 +0,0 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
"time"
)
func getRealm(c *fiber.Ctx) error {
id, _ := c.ParamsInt("realmId", 0)
var realm models.Realm
if err := database.C.Where(&models.Realm{
BaseModel: models.BaseModel{ID: uint(id)},
}).First(&realm).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(realm)
}
func listRealm(c *fiber.Ctx) error {
realms, err := services.ListRealm()
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(realms)
}
func listOwnedRealm(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
realms, err := services.ListRealmWithUser(user)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(realms)
}
func listPostInRealm(c *fiber.Ctx) error {
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
authorId := c.QueryInt("authorId", 0)
realmId, _ := c.ParamsInt("realmId", 0)
tx := database.C.
Where(&models.Post{RealmID: lo.ToPtr(uint(realmId))}).
Where("published_at <= ? OR published_at IS NULL", time.Now()).
Order("created_at desc")
if authorId > 0 {
tx = tx.Where(&models.Post{AuthorID: uint(authorId)})
}
var count int64
if err := tx.
Model(&models.Post{}).
Count(&count).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
posts, err := services.ListPost(tx, take, offset)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": posts,
})
}
func createRealm(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
if user.PowerLevel < 10 {
return fiber.NewError(fiber.StatusForbidden, "require power level 10 to create realm")
}
var data struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
realm, err := services.NewRealm(user, data.Name, data.Description)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(realm)
}
func editRealm(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("realmId", 0)
var data struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
var realm models.Realm
if err := database.C.Where(&models.Realm{
BaseModel: models.BaseModel{ID: uint(id)},
AccountID: user.ID,
}).First(&realm).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
realm, err := services.EditRealm(realm, data.Name, data.Description)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(realm)
}
func deleteRealm(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("realmId", 0)
var realm models.Realm
if err := database.C.Where(&models.Realm{
BaseModel: models.BaseModel{ID: uint(id)},
AccountID: user.ID,
}).First(&realm).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := services.DeleteRealm(realm); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -1,101 +0,0 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/view"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"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"
"net/http"
"strings"
"time"
)
var A *fiber.App
func NewServer() {
A = 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,
EnablePrintRoutes: viper.GetBool("debug"),
})
A.Use(idempotency.New())
A.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
},
}))
A.Use(logger.New(logger.Config{
Format: "${status} | ${latency} | ${method} ${path}\n",
Output: log.Logger,
}))
A.Get("/.well-known", getMetadata)
api := A.Group("/api").Name("API")
{
api.Get("/auth", doLogin)
api.Get("/auth/callback", postLogin)
api.Post("/auth/refresh", doRefreshToken)
api.Get("/users/me", auth, getUserinfo)
api.Get("/users/:accountId", getOthersInfo)
api.Get("/users/:accountId/follow", auth, getAccountFollowed)
api.Post("/users/:accountId/follow", auth, doFollowAccount)
api.Get("/attachments/o/:fileId", openAttachment)
api.Post("/attachments", auth, uploadAttachment)
api.Get("/posts", listPost)
api.Post("/posts", auth, createPost)
api.Post("/posts/:postId/react/:reactType", auth, reactPost)
api.Put("/posts/:postId", auth, editPost)
api.Delete("/posts/:postId", auth, deletePost)
api.Get("/realms", listRealm)
api.Get("/realms/me", auth, listOwnedRealm)
api.Get("/realms/:realmId", getRealm)
api.Get("/realms/:realmId/posts", listPostInRealm)
api.Post("/realms", auth, createRealm)
api.Put("/realms/:realmId", auth, editRealm)
api.Delete("/realms/:realmId", auth, deleteRealm)
}
A.Use("/", cache.New(cache.Config{
Expiration: 24 * time.Hour,
CacheControl: true,
}), filesystem.New(filesystem.Config{
Root: http.FS(view.FS),
PathPrefix: "dist",
Index: "index.html",
NotFoundFile: "dist/index.html",
}))
}
func Listen() {
if err := A.Listen(viper.GetString("bind")); err != nil {
log.Fatal().Err(err).Msg("An error occurred when starting server...")
}
}

View File

@ -1,76 +0,0 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"github.com/gofiber/fiber/v2"
)
func getUserinfo(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data models.Account
if err := database.C.
Where(&models.Account{BaseModel: models.BaseModel{ID: user.ID}}).
First(&data).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(data)
}
func getOthersInfo(c *fiber.Ctx) error {
accountId := c.Params("accountId")
var data models.Account
if err := database.C.
Where(&models.Account{Name: accountId}).
First(&data).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(data)
}
func getAccountFollowed(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
accountId, _ := c.ParamsInt("accountId", 0)
var data models.Account
if err := database.C.
Where(&models.Account{BaseModel: models.BaseModel{ID: uint(accountId)}}).
First(&data).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
_, status := services.GetAccountFollowed(user, data)
return c.JSON(fiber.Map{
"is_followed": status,
})
}
func doFollowAccount(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("accountId", 0)
var account models.Account
if err := database.C.Where(&models.Account{
BaseModel: models.BaseModel{ID: uint(id)},
}).First(&account).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if _, ok := services.GetAccountFollowed(user, account); ok {
if err := services.UnfollowAccount(user.ID, account.ID); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusNoContent)
} else {
if err := services.FollowAccount(user.ID, account.ID); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusCreated)
}
}

View File

@ -1,13 +0,0 @@
package server
import (
"github.com/gofiber/fiber/v2"
"github.com/spf13/viper"
)
func getMetadata(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"name": viper.GetString("name"),
"domain": viper.GetString("domain"),
})
}

View File

@ -1,30 +0,0 @@
package services
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
)
func FollowAccount(followerId, followingId uint) error {
relationship := models.AccountMembership{
FollowerID: followerId,
FollowingID: followingId,
}
return database.C.Create(&relationship).Error
}
func UnfollowAccount(followerId, followingId uint) error {
return database.C.Where(models.AccountMembership{
FollowerID: followerId,
FollowingID: followingId,
}).Delete(&models.AccountMembership{}).Error
}
func GetAccountFollowed(user models.Account, target models.Account) (models.AccountMembership, bool) {
var relationship models.AccountMembership
err := database.C.Model(&models.AccountMembership{}).
Where(&models.AccountMembership{FollowerID: user.ID, FollowingID: target.ID}).
First(&relationship).
Error
return relationship, err == nil
}

View File

@ -1,40 +0,0 @@
package services
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"github.com/google/uuid"
"mime/multipart"
"net/http"
)
func NewAttachment(user models.Account, header *multipart.FileHeader) (models.Attachment, error) {
attachment := models.Attachment{
FileID: uuid.NewString(),
Filesize: header.Size,
Filename: header.Filename,
Mimetype: "unknown/unknown",
PostID: nil,
AuthorID: user.ID,
}
// Open file
file, err := header.Open()
if err != nil {
return attachment, err
}
defer file.Close()
// Detect mimetype
fileHeader := make([]byte, 512)
_, err = file.Read(fileHeader)
if err != nil {
return attachment, err
}
attachment.Mimetype = http.DetectContentType(fileHeader)
// Save into database
err = database.C.Save(&attachment).Error
return attachment, err
}

View File

@ -1,101 +0,0 @@
package services
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/security"
"errors"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
"strconv"
"time"
)
type PassportUserinfo struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Picture string `json:"picture"`
PreferredUsername string `json:"preferred_username"`
}
func LinkAccount(userinfo PassportUserinfo) (models.Account, error) {
id, _ := strconv.Atoi(userinfo.Sub)
var account models.Account
if err := database.C.Where(&models.Account{
ExternalID: uint(id),
}).First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
account = models.Account{
Name: userinfo.Name,
Nick: userinfo.PreferredUsername,
Avatar: userinfo.Picture,
EmailAddress: userinfo.Email,
PowerLevel: 0,
ExternalID: uint(id),
}
return account, database.C.Save(&account).Error
}
return account, err
}
account.Name = userinfo.Name
account.Nick = userinfo.PreferredUsername
account.Avatar = userinfo.Picture
account.EmailAddress = userinfo.Email
err := database.C.Save(&account).Error
return account, err
}
func GetToken(account models.Account) (string, string, error) {
var err error
var refresh, access string
sub := strconv.Itoa(int(account.ID))
access, err = security.EncodeJwt(
uuid.NewString(),
security.JwtAccessType,
sub,
[]string{"interactive"},
time.Now().Add(30*time.Minute),
)
if err != nil {
return refresh, access, err
}
refresh, err = security.EncodeJwt(
uuid.NewString(),
security.JwtRefreshType,
sub,
[]string{"interactive"},
time.Now().Add(30*24*time.Hour),
)
if err != nil {
return refresh, access, err
}
return access, refresh, nil
}
func RefreshToken(token string) (string, string, error) {
parseInt := func(str string) int {
val, _ := strconv.Atoi(str)
return val
}
var account models.Account
if claims, err := security.DecodeJwt(token); err != nil {
return "404", "403", err
} else if claims.Type != security.JwtRefreshType {
return "404", "403", fmt.Errorf("invalid token type, expected refresh token")
} else if err := database.C.Where(models.Account{
BaseModel: models.BaseModel{ID: uint(parseInt(claims.Subject))},
}).First(&account).Error; err != nil {
return "404", "403", err
}
return GetToken(account)
}

View File

@ -1,40 +0,0 @@
package services
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"errors"
"gorm.io/gorm"
)
func GetCategory(alias, name string) (models.Category, error) {
var category models.Category
if err := database.C.Where(models.Category{Alias: alias}).First(&category).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
category = models.Category{
Alias: alias,
Name: name,
}
err := database.C.Save(&category).Error
return category, err
}
return category, err
}
return category, nil
}
func GetTag(alias, name string) (models.Tag, error) {
var tag models.Tag
if err := database.C.Where(models.Category{Alias: alias}).First(&tag).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
tag = models.Tag{
Alias: alias,
Name: name,
}
err := database.C.Save(&tag).Error
return tag, err
}
return tag, err
}
return tag, nil
}

View File

@ -1,51 +0,0 @@
package services
import (
"crypto/tls"
"fmt"
"net/smtp"
"net/textproto"
"github.com/jordan-wright/email"
"github.com/spf13/viper"
)
func SendMail(target string, subject string, content string) error {
mail := &email.Email{
To: []string{target},
From: viper.GetString("mailer.name"),
Subject: subject,
Text: []byte(content),
Headers: textproto.MIMEHeader{},
}
return mail.SendWithTLS(
fmt.Sprintf("%s:%d", viper.GetString("mailer.smtp_host"), viper.GetInt("mailer.smtp_port")),
smtp.PlainAuth(
"",
viper.GetString("mailer.username"),
viper.GetString("mailer.password"),
viper.GetString("mailer.smtp_host"),
),
&tls.Config{ServerName: viper.GetString("mailer.smtp_host")},
)
}
func SendMailHTML(target string, subject string, content string) error {
mail := &email.Email{
To: []string{target},
From: viper.GetString("mailer.name"),
Subject: subject,
HTML: []byte(content),
Headers: textproto.MIMEHeader{},
}
return mail.SendWithTLS(
fmt.Sprintf("%s:%d", viper.GetString("mailer.smtp_host"), viper.GetInt("mailer.smtp_port")),
smtp.PlainAuth(
"",
viper.GetString("mailer.username"),
viper.GetString("mailer.password"),
viper.GetString("mailer.smtp_host"),
),
&tls.Config{ServerName: viper.GetString("mailer.smtp_host")},
)
}

View File

@ -1,209 +0,0 @@
package services
import (
"errors"
"fmt"
"time"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"github.com/samber/lo"
"github.com/spf13/viper"
"gorm.io/gorm"
)
func PreloadRelatedPost(tx *gorm.DB) *gorm.DB {
return tx.
Preload("Author").
Preload("Attachments").
Preload("Categories").
Preload("Tags").
Preload("RepostTo").
Preload("ReplyTo").
Preload("RepostTo.Author").
Preload("ReplyTo.Author").
Preload("RepostTo.Attachments").
Preload("ReplyTo.Attachments").
Preload("RepostTo.Categories").
Preload("ReplyTo.Categories").
Preload("RepostTo.Tags").
Preload("ReplyTo.Tags")
}
func ListPost(tx *gorm.DB, take int, offset int) ([]*models.Post, error) {
var posts []*models.Post
if err := PreloadRelatedPost(tx).
Limit(take).
Offset(offset).
Find(&posts).Error; err != nil {
return posts, err
}
postIds := lo.Map(posts, func(item *models.Post, _ int) uint {
return item.ID
})
var reactInfo []struct {
PostID uint
LikeCount int64
DislikeCount int64
}
prefix := viper.GetString("database.prefix")
database.C.Raw(fmt.Sprintf(`SELECT t.id as post_id,
COALESCE(l.like_count, 0) AS like_count,
COALESCE(d.dislike_count, 0) AS dislike_count
FROM %sposts t
LEFT JOIN (SELECT post_id, COUNT(*) AS like_count
FROM %spost_likes
GROUP BY post_id) l ON t.id = l.post_id
LEFT JOIN (SELECT post_id, COUNT(*) AS dislike_count
FROM %spost_dislikes
GROUP BY post_id) d ON t.id = d.post_id
WHERE t.id IN (?)`, prefix, prefix, prefix), postIds).Scan(&reactInfo)
postMap := lo.SliceToMap(posts, func(item *models.Post) (uint, *models.Post) {
return item.ID, item
})
for _, info := range reactInfo {
if post, ok := postMap[info.PostID]; ok {
post.LikeCount = info.LikeCount
post.DislikeCount = info.DislikeCount
}
}
return posts, nil
}
func NewPost(
user models.Account,
realm *models.Realm,
alias, title, content string,
attachments []models.Attachment,
categories []models.Category,
tags []models.Tag,
publishedAt *time.Time,
replyTo, repostTo *uint,
) (models.Post, error) {
var err error
var post models.Post
for idx, category := range categories {
categories[idx], err = GetCategory(category.Alias, category.Name)
if err != nil {
return post, err
}
}
for idx, tag := range tags {
tags[idx], err = GetTag(tag.Alias, tag.Name)
if err != nil {
return post, err
}
}
var realmId *uint
if realm != nil {
realmId = &realm.ID
}
if publishedAt == nil {
publishedAt = lo.ToPtr(time.Now())
}
post = models.Post{
Alias: alias,
Title: title,
Content: content,
Attachments: attachments,
Tags: tags,
Categories: categories,
AuthorID: user.ID,
RealmID: realmId,
PublishedAt: *publishedAt,
RepostID: repostTo,
ReplyID: replyTo,
}
if err := database.C.Save(&post).Error; err != nil {
return post, err
}
return post, nil
}
func EditPost(
post models.Post,
alias, title, content string,
publishedAt *time.Time,
categories []models.Category,
tags []models.Tag,
) (models.Post, error) {
var err error
for idx, category := range categories {
categories[idx], err = GetCategory(category.Alias, category.Name)
if err != nil {
return post, err
}
}
for idx, tag := range tags {
tags[idx], err = GetTag(tag.Alias, tag.Name)
if err != nil {
return post, err
}
}
if publishedAt == nil {
publishedAt = lo.ToPtr(time.Now())
}
post.Alias = alias
post.Title = title
post.Content = content
post.PublishedAt = *publishedAt
err = database.C.Save(&post).Error
return post, err
}
func LikePost(user models.Account, post models.Post) (bool, error) {
var like models.PostLike
if err := database.C.Where(&models.PostLike{
AccountID: user.ID,
PostID: post.ID,
}).First(&like).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return true, err
}
like = models.PostLike{
AccountID: user.ID,
PostID: post.ID,
}
return true, database.C.Save(&like).Error
} else {
return false, database.C.Delete(&like).Error
}
}
func DislikePost(user models.Account, post models.Post) (bool, error) {
var dislike models.PostDislike
if err := database.C.Where(&models.PostDislike{
AccountID: user.ID,
PostID: post.ID,
}).First(&dislike).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return true, err
}
dislike = models.PostDislike{
AccountID: user.ID,
PostID: post.ID,
}
return true, database.C.Save(&dislike).Error
} else {
return false, database.C.Delete(&dislike).Error
}
}
func DeletePost(post models.Post) error {
return database.C.Delete(&post).Error
}

View File

@ -1,49 +0,0 @@
package services
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
)
func ListRealm() ([]models.Realm, error) {
var realms []models.Realm
if err := database.C.Find(&realms).Error; err != nil {
return realms, err
}
return realms, nil
}
func ListRealmWithUser(user models.Account) ([]models.Realm, error) {
var realms []models.Realm
if err := database.C.Where(&models.Realm{AccountID: user.ID}).Find(&realms).Error; err != nil {
return realms, err
}
return realms, nil
}
func NewRealm(user models.Account, name, description string) (models.Realm, error) {
realm := models.Realm{
Name: name,
Description: description,
AccountID: user.ID,
}
err := database.C.Save(&realm).Error
return realm, err
}
func EditRealm(realm models.Realm, name, description string) (models.Realm, error) {
realm.Name = name
realm.Description = description
err := database.C.Save(&realm).Error
return realm, err
}
func DeleteRealm(realm models.Realm) error {
return database.C.Delete(&realm).Error
}

7
pkg/view/.gitignore vendored
View File

@ -1,7 +0,0 @@
/dist
/node_modules
.DS_Store
package-lock.json
yarn.lock

View File

@ -1,28 +0,0 @@
## Usage
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)

View File

@ -1,6 +0,0 @@
package view
import "embed"
//go:embed all:dist
var FS embed.FS

View File

@ -1,27 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Embedded Interactive</title>
<style>
body, html {
padding: 0;
margin: 0;
}
iframe {
width: 100vw;
height: 100vh;
display: block;
border: 0;
}
</style>
</head>
<body>
<iframe src="http://localhost:8445/realms/1?noTitle=1"></iframe>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More