Compare commits
No commits in common. "6cc699cbbd8c0765bb66ca756e54f34f21da06d3" and "db85e831d9c26ddba1625b63cd668c6640bdbb0c" have entirely different histories.
6cc699cbbd
...
db85e831d9
63 changed files with 5560 additions and 118 deletions
17
auth/auth.go
Normal file
17
auth/auth.go
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Authentication struct {
|
||||||
|
// For when in-depth access is needed
|
||||||
|
db *gorm.DB
|
||||||
|
// Primary method to acquire account data
|
||||||
|
store *storage.Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuth(db *gorm.DB, store *storage.Storage) *Authentication {
|
||||||
|
return &Authentication{db, store}
|
||||||
|
}
|
116
auth/checks.go
Normal file
116
auth/checks.go
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.mstar.dev/mstar/goutils/sliceutils"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Can actorId read the account with targetId?
|
||||||
|
func (a *Authentication) CanReadAccount(actorId *string, targetId string) bool {
|
||||||
|
targetAccount, err := a.store.FindAccountById(targetId)
|
||||||
|
if err != nil {
|
||||||
|
if err == storage.ErrEntryNotFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("account-id", targetId).
|
||||||
|
Msg("Failed to receive account for permission check")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if actorId == nil {
|
||||||
|
// TODO: Decide if roles should have a field to declare an account as follow only/hidden
|
||||||
|
// and then check for that flag here
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
roles, err := a.store.FindRolesByNames(targetAccount.Roles)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Strs("role-names", targetAccount.Roles).
|
||||||
|
Msg("Failed to get roles for target account")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
combined := storage.CollapseRolesIntoOne(roles...)
|
||||||
|
return !sliceutils.Contains(combined.BlockedUsers, *actorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can actorId edit the account with targetId?
|
||||||
|
// If actorId is nil, it is assumed to be an anonymous user trying to edit the target account
|
||||||
|
// if targetId is nil, it is assumed that the actor is editing themselves
|
||||||
|
func (a *Authentication) CanEditAccount(actorId *string, targetId *string) bool {
|
||||||
|
// WARN: This entire function feels wrong, idk
|
||||||
|
// Only the owner of an account should be able to edit said account's data
|
||||||
|
// But how do moderation actions play with this? Do they count as edit or as something separate?
|
||||||
|
if actorId == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if targetId == nil {
|
||||||
|
targetId = actorId
|
||||||
|
}
|
||||||
|
targetAccount, err := a.store.FindAccountById(*targetId)
|
||||||
|
if err != nil {
|
||||||
|
if err != storage.ErrEntryNotFound {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("target-id", *targetId).
|
||||||
|
Msg("Failed to receive account for permission checks")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if targetId == actorId {
|
||||||
|
targetRoles, err := a.store.FindRolesByNames(targetAccount.Roles)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Strs("role-names", targetAccount.Roles).
|
||||||
|
Msg("Failed to get roles from storage")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
combined := storage.CollapseRolesIntoOne(targetRoles...)
|
||||||
|
return *combined.CanLogin
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can actorId delete the account with targetId?
|
||||||
|
// If actorId is nil, it is assumed to be an anonymous user trying to delete the target account
|
||||||
|
// if targetId is nil, it is assumed that the actor is deleting themselves
|
||||||
|
func (a *Authentication) CanDeleteAccount(actorId *string, targetId *string) bool {
|
||||||
|
if actorId == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
acc, err := a.store.FindAccountById(*actorId)
|
||||||
|
if err != nil {
|
||||||
|
// TODO: Logging
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
roles, err := a.store.FindRolesByNames(acc.Roles)
|
||||||
|
if err != nil {
|
||||||
|
// TODO: Logging
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
collapsed := storage.CollapseRolesIntoOne(roles...)
|
||||||
|
if targetId == nil {
|
||||||
|
return *collapsed.CanLogin
|
||||||
|
} else {
|
||||||
|
return *collapsed.CanDeleteAccounts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can actorId create a new post at all?
|
||||||
|
// Specific restrictions regarding the content are not checked
|
||||||
|
func (a *Authentication) CanCreatePost(actorId string) bool { return true }
|
||||||
|
|
||||||
|
// Ensures that a given post conforms with all roles attached to the author account.
|
||||||
|
// Returns the conforming note (or nil of it can't be changed to conform)
|
||||||
|
// and whether the note was changed
|
||||||
|
func (a *Authentication) EnsureNoteConformsWithRoles(note *storage.Note) (*storage.Note, bool) {
|
||||||
|
return note, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does the given note conform with the roles attached to the author account?
|
||||||
|
func (a *Authentication) DoesNoteConform(note *storage.Note) bool { return true }
|
107
main.go
107
main.go
|
@ -5,22 +5,24 @@ import (
|
||||||
"embed"
|
"embed"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"path"
|
"path"
|
||||||
"sync"
|
"time"
|
||||||
|
|
||||||
"git.mstar.dev/mstar/goutils/other"
|
"git.mstar.dev/mstar/goutils/other"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/mstarongithub/passkey"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"gopkg.in/natefinch/lumberjack.v2"
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"git.mstar.dev/mstar/linstrom/config"
|
"git.mstar.dev/mstar/linstrom/config"
|
||||||
|
"git.mstar.dev/mstar/linstrom/server"
|
||||||
"git.mstar.dev/mstar/linstrom/shared"
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
storagenew "git.mstar.dev/mstar/linstrom/storage-new"
|
storagenew "git.mstar.dev/mstar/linstrom/storage-new"
|
||||||
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
|
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage/cache"
|
||||||
webdebug "git.mstar.dev/mstar/linstrom/web/debug"
|
webdebug "git.mstar.dev/mstar/linstrom/web/debug"
|
||||||
webpublic "git.mstar.dev/mstar/linstrom/web/public"
|
webpublic "git.mstar.dev/mstar/linstrom/web/public"
|
||||||
)
|
)
|
||||||
|
@ -41,8 +43,6 @@ var defaultDuck string
|
||||||
var duckFS embed.FS
|
var duckFS embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
_ = reactiveFS
|
|
||||||
_ = nojsFS
|
|
||||||
other.SetupFlags()
|
other.SetupFlags()
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
logfile := getLogFilePathOrNil()
|
logfile := getLogFilePathOrNil()
|
||||||
|
@ -71,7 +71,13 @@ func main() {
|
||||||
if *shared.FlagConfigOnly {
|
if *shared.FlagConfigOnly {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newServer()
|
if *shared.FlagStartNew {
|
||||||
|
log.Info().Msg("Starting new system")
|
||||||
|
newServer()
|
||||||
|
} else {
|
||||||
|
log.Info().Msg("Starting old system")
|
||||||
|
oldServer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLogFilePathOrNil() *string {
|
func getLogFilePathOrNil() *string {
|
||||||
|
@ -84,8 +90,57 @@ func getLogFilePathOrNil() *string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func oldServer() {
|
||||||
|
storageCache, err := cache.NewCache(
|
||||||
|
config.GlobalConfig.Storage.MaxInMemoryCacheSize,
|
||||||
|
config.GlobalConfig.Storage.RedisUrl,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Failed to start cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
// var store *storage.Storage
|
||||||
|
// if config.GlobalConfig.Storage.DbIsPostgres != nil && *config.GlobalConfig.Storage.DbIsPostgres {
|
||||||
|
// store, err = storage.NewStoragePostgres(config.GlobalConfig.Storage.DatabaseUrl, storageCache)
|
||||||
|
// } else {
|
||||||
|
// store, err = storage.NewStorageSqlite(config.GlobalConfig.Storage.DatabaseUrl, storageCache)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
store, err := storage.NewStorage(config.GlobalConfig.Storage.BuildPostgresDSN(), storageCache)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Failed to setup storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkey, err := passkey.New(passkey.Config{
|
||||||
|
WebauthnConfig: &webauthn.Config{
|
||||||
|
RPDisplayName: "Linstrom",
|
||||||
|
RPID: "localhost",
|
||||||
|
RPOrigins: []string{"http://localhost:8000"},
|
||||||
|
},
|
||||||
|
UserStore: store,
|
||||||
|
SessionStore: store,
|
||||||
|
SessionMaxAge: time.Hour * 24,
|
||||||
|
}, passkey.WithLogger(&shared.ZerologWrapper{}))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("Failed to setup passkey support")
|
||||||
|
}
|
||||||
|
|
||||||
|
server := server.NewServer(
|
||||||
|
store,
|
||||||
|
pkey,
|
||||||
|
shared.NewFSWrapper(reactiveFS, "frontend-reactive/dist/", false),
|
||||||
|
shared.NewFSWrapper(nojsFS, "frontend-noscript/", false),
|
||||||
|
&defaultDuck,
|
||||||
|
)
|
||||||
|
server.Start(":8000")
|
||||||
|
// TODO: Set up media server
|
||||||
|
// TODO: Set up queues
|
||||||
|
// TODO: Set up plugins
|
||||||
|
}
|
||||||
|
|
||||||
func newServer() {
|
func newServer() {
|
||||||
log.Info().Msg("Connecting to db")
|
log.Info().Msg("Connectin to db")
|
||||||
db, err := gorm.Open(
|
db, err := gorm.Open(
|
||||||
postgres.Open(config.GlobalConfig.Storage.BuildPostgresDSN()),
|
postgres.Open(config.GlobalConfig.Storage.BuildPostgresDSN()),
|
||||||
&gorm.Config{
|
&gorm.Config{
|
||||||
|
@ -104,49 +159,21 @@ func newServer() {
|
||||||
if err = storagenew.InsertSelf(); err != nil {
|
if err = storagenew.InsertSelf(); err != nil {
|
||||||
log.Fatal().Err(err).Msg("Failed to insert self properly")
|
log.Fatal().Err(err).Msg("Failed to insert self properly")
|
||||||
}
|
}
|
||||||
log.Info().Msg("Inserting placeholder/unknown user")
|
|
||||||
if err = storagenew.InsertUnknownActorPlaceholder(); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Failed to insert self properly")
|
|
||||||
}
|
|
||||||
debugShutdownChan := make(chan *sync.WaitGroup, 1)
|
|
||||||
interuptChan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(interuptChan, os.Interrupt)
|
|
||||||
if *shared.FlagStartDebugServer {
|
if *shared.FlagStartDebugServer {
|
||||||
go func() {
|
go func() {
|
||||||
log.Info().Msg("Starting debug server")
|
log.Info().Msg("Starting debug server")
|
||||||
s := webdebug.New(*shared.FlagDebugPort)
|
if err = webdebug.New(*shared.FlagDebugPort).Start(); err != nil {
|
||||||
go func() {
|
|
||||||
wg := <-debugShutdownChan
|
|
||||||
if err := s.Stop(); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Failed to cleanly stop debug server")
|
|
||||||
}
|
|
||||||
log.Info().Msg("Debug server stopped")
|
|
||||||
wg.Done()
|
|
||||||
}()
|
|
||||||
if err = s.Start(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatal().Err(err).Msg("Debug server failed")
|
log.Fatal().Err(err).Msg("Debug server failed")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
log.Info().Msg("Starting public server")
|
||||||
public := webpublic.New(
|
public := webpublic.New(
|
||||||
fmt.Sprintf(":%v", config.GlobalConfig.General.PrivatePort),
|
fmt.Sprintf(":%v", config.GlobalConfig.General.PrivatePort),
|
||||||
&defaultDuck,
|
&defaultDuck,
|
||||||
duckFS,
|
duckFS,
|
||||||
)
|
)
|
||||||
go func() {
|
if err = public.Start(); err != nil {
|
||||||
log.Info().Msg("Starting public server")
|
log.Fatal().Err(err).Msg("Failed to start public server")
|
||||||
if err = public.Start(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatal().Err(err).Msg("Failed to start public server")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
<-interuptChan
|
|
||||||
log.Info().Msg("Received interrupt, shutting down")
|
|
||||||
wg := sync.WaitGroup{}
|
|
||||||
wg.Add(1)
|
|
||||||
debugShutdownChan <- &wg
|
|
||||||
if err = public.Stop(); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Failed to stop public server")
|
|
||||||
}
|
}
|
||||||
log.Info().Msg("Public server stopped")
|
|
||||||
wg.Wait()
|
|
||||||
}
|
}
|
||||||
|
|
1
plugins/loader.go
Normal file
1
plugins/loader.go
Normal file
|
@ -0,0 +1 @@
|
||||||
|
package plugins
|
5
plugins/plugins.go
Normal file
5
plugins/plugins.go
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
// TODO: Think about how to enable server side processing plugins
|
||||||
|
|
||||||
|
// Options: Lua, wasm/wasi (probably prefered)
|
4
queues/inboundEventQueue/queue.go
Normal file
4
queues/inboundEventQueue/queue.go
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
// Job queue for all inbound events
|
||||||
|
// Well, I say queue but it's more of a security measure adding the inbound job to the db as backup
|
||||||
|
// in case processing fails for any reason
|
||||||
|
package inboundeventqueue
|
5
queues/outgoingEventQueue/queue.go
Normal file
5
queues/outgoingEventQueue/queue.go
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
// Queue for outbound events
|
||||||
|
// Actual queue here since other servers can't be expected to go at the same speed as Linstrom (be it slower or faster)
|
||||||
|
// This queue should enforce data consistency (new jobs are first stored in the db) and a fair processing speed for
|
||||||
|
// other servers, depending on their processing speed
|
||||||
|
package outgoingeventqueue
|
186
server/apiLinstrom.go
Normal file
186
server/apiLinstrom.go
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/goutils/other"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupLinstromApiRouter() http.Handler {
|
||||||
|
router := http.NewServeMux()
|
||||||
|
router.Handle("/v1/", http.StripPrefix("/v1", setupLinstromApiV1Router()))
|
||||||
|
router.Handle("/s2s/v1/", http.StripPrefix("/s2s/v1", setupLinstromS2SApiV1Router()))
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupLinstromApiV1Router() http.Handler {
|
||||||
|
router := http.NewServeMux()
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
|
||||||
|
// Get a note
|
||||||
|
router.HandleFunc("GET /notes/{noteId}", linstromGetNote)
|
||||||
|
// Send a new note
|
||||||
|
router.HandleFunc("POST /notes", linstromNewNote)
|
||||||
|
// Update a note
|
||||||
|
router.HandleFunc("PATCH /notes/{noteId}", linstromUpdateNote)
|
||||||
|
// Delete a note
|
||||||
|
router.HandleFunc("DELETE /notes/{noteId}", linstromDeleteNote)
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
|
||||||
|
// Get all reactions for a note
|
||||||
|
router.HandleFunc("GET /notes/{noteId}/reactions", linstromGetReactions)
|
||||||
|
// Send a new reaction to a note
|
||||||
|
router.HandleFunc("POST /notes/{noteId}/reactions", linstromAddReaction)
|
||||||
|
// Update own reaction on a note
|
||||||
|
router.HandleFunc("PATCH /notes/{noteId}/reactions", linstromUpdateReaction)
|
||||||
|
// Remove own reaction on a note
|
||||||
|
router.HandleFunc("DELETE /notes/{noteId}/reactions", linstromDeleteReaction)
|
||||||
|
|
||||||
|
// Boosts
|
||||||
|
|
||||||
|
// Get all boosters of a note
|
||||||
|
router.HandleFunc("GET /notes/{noteId}/boosts", linstromGetBoosts)
|
||||||
|
// Boost a note
|
||||||
|
router.HandleFunc("POST /notes/{noteId}/boosts", linstromAddBoost)
|
||||||
|
// Unboost a note
|
||||||
|
router.HandleFunc("DELETE /notes/{noteId}/boosts", linstromRemoveBoost)
|
||||||
|
|
||||||
|
// Quotes
|
||||||
|
|
||||||
|
// Get all quotes of a note
|
||||||
|
router.HandleFunc("GET /notes/{noteId}/quotes", linstromGetQuotes)
|
||||||
|
// Create a new quote message of a given note
|
||||||
|
router.HandleFunc("POST /notes/{noteId}/quotes", linstromAddQuote)
|
||||||
|
|
||||||
|
// Pinning
|
||||||
|
|
||||||
|
// Pin a note to account profile
|
||||||
|
router.HandleFunc("POST /notes/{noteId}/pin", linstromPinNote)
|
||||||
|
// Unpin a note from account profile
|
||||||
|
router.HandleFunc("DELETE /notes/{noteId}/pin", linstromUnpinNote)
|
||||||
|
// Reports
|
||||||
|
router.HandleFunc("POST /notes/{noteId}/report", linstromReportNote)
|
||||||
|
router.HandleFunc("DELETE /notes/{noteId}/report", linstromRetractReportNote)
|
||||||
|
// Admin
|
||||||
|
router.HandleFunc("POST /notes/{noteId}/admin/cw", linstromForceCWNote)
|
||||||
|
|
||||||
|
// Accounts
|
||||||
|
// Creating a new account happens either during fetch of a remote one or during registration with a passkey
|
||||||
|
|
||||||
|
// Get an account
|
||||||
|
router.HandleFunc("GET /accounts/{accountId}", linstromGetAccount)
|
||||||
|
// Update own account
|
||||||
|
// Technically also requires authenticated account to also be owner or correct admin perms,
|
||||||
|
// but that's annoying to handle in a general sense. So leaving that to the function
|
||||||
|
// though figuring out a nice generic-ish way to handle those checks would be nice too
|
||||||
|
router.HandleFunc(
|
||||||
|
"PATCH /accounts/{accountId}",
|
||||||
|
requireValidSessionMiddleware(linstromUpdateAccount),
|
||||||
|
)
|
||||||
|
// Delete own account
|
||||||
|
// Technically also requires authenticated account to also be owner or correct admin perms,
|
||||||
|
// but that's annoying to handle in a general sense. So leaving that to the function
|
||||||
|
router.HandleFunc(
|
||||||
|
"DELETE /accounts/{accountId}",
|
||||||
|
requireValidSessionMiddleware(linstromDeleteAccount),
|
||||||
|
)
|
||||||
|
// Follow
|
||||||
|
// Is logged in following accountId
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /accounts/{accountId}/follow/to",
|
||||||
|
requireValidSessionMiddleware(linstromIsFollowingToAccount),
|
||||||
|
)
|
||||||
|
// Is accountId following logged in
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /accounts/{accountId}/follow/from",
|
||||||
|
requireValidSessionMiddleware(linstromIsFollowingFromAccount),
|
||||||
|
)
|
||||||
|
// Send follow request to accountId
|
||||||
|
router.HandleFunc("POST /accounts/{accountId}/follow", linstromFollowAccount)
|
||||||
|
// Undo follow request to accountId
|
||||||
|
router.HandleFunc("DELETE /accounts/{accountId}/follow", linstromUnfollowAccount)
|
||||||
|
|
||||||
|
// Block
|
||||||
|
|
||||||
|
// Is logged in account blocking target account
|
||||||
|
router.HandleFunc("GET /accounts/{accountId}/block", linstromIsBlockingAccount)
|
||||||
|
// Block target account
|
||||||
|
router.HandleFunc("POST /accounts/{accountId}/block", linstromBlockAccount)
|
||||||
|
// Unblock target account
|
||||||
|
router.HandleFunc("DELETE /accounts/{accountId}/block", linstromUnblockAccount)
|
||||||
|
|
||||||
|
// Mute
|
||||||
|
|
||||||
|
// Has logged in account muted target account?
|
||||||
|
router.HandleFunc("GET /accounts/{accountId}/mute", linstromIsMutedAccount)
|
||||||
|
// Mute target account
|
||||||
|
router.HandleFunc("POST /accounts/{accountId}/mute", linstromMuteAccount)
|
||||||
|
// Unmute target account
|
||||||
|
router.HandleFunc("DELETE /accounts/{accountId}/mute", linstromUnmuteAccount)
|
||||||
|
|
||||||
|
// Report
|
||||||
|
|
||||||
|
// Report a target account
|
||||||
|
router.HandleFunc("POST /accounts/{accountId}/reports", linstromReportAccount)
|
||||||
|
// Undo report on target account
|
||||||
|
router.HandleFunc("DELETE /accounts/{accountId}/reports", linstromRetractReportAccount)
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
|
||||||
|
// Add new role to account
|
||||||
|
router.Handle(
|
||||||
|
"POST /accounts/{accountId}/admin/roles",
|
||||||
|
buildRequirePermissionsMiddleware(
|
||||||
|
&storage.Role{CanAssignRoles: other.IntoPointer(true)},
|
||||||
|
)(
|
||||||
|
http.HandlerFunc(linstromAdminAddRoleAccount),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Remove role from account
|
||||||
|
router.Handle(
|
||||||
|
"DELETE /accounts/{accountId}/admin/roles/{roleName}",
|
||||||
|
buildRequirePermissionsMiddleware(&storage.Role{CanAssignRoles: other.IntoPointer(true)})(
|
||||||
|
http.HandlerFunc(linstromAdminRemoveRoleAccount),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Send a warning to account
|
||||||
|
router.HandleFunc("POST /accounts/{accountId}/admin/warn", linstromAdminWarnAccount)
|
||||||
|
|
||||||
|
// Roles
|
||||||
|
|
||||||
|
// Get a role
|
||||||
|
router.HandleFunc("GET /roles/{roleId}", linstromGetRole)
|
||||||
|
// Create a new role
|
||||||
|
router.HandleFunc("POST /roles", linstromCreateRole)
|
||||||
|
// Update a role. Builtin roles cannot be edited
|
||||||
|
router.HandleFunc("PATCH /roles/{roleId}", linstromUpdateRole)
|
||||||
|
// Delete a role. Builtin roles cannot be deleted
|
||||||
|
router.HandleFunc("DELETE /roles/{roleId}", linstromDeleteRole)
|
||||||
|
|
||||||
|
// Media metadata
|
||||||
|
|
||||||
|
// Get the metadata for some media
|
||||||
|
router.HandleFunc("GET /media/{mediaId}", linstromGetMediaMetadata)
|
||||||
|
// Upload new media
|
||||||
|
router.HandleFunc("POST /media", linstromNewMediaMetadata)
|
||||||
|
// Update the metadata for some media
|
||||||
|
router.HandleFunc("PATCH /media/{mediaId}", linstromUpdateMediaMetadata)
|
||||||
|
// Delete a media entry
|
||||||
|
router.HandleFunc("DELETE /media/{mediaId}", linstromDeleteMediaMetadata)
|
||||||
|
|
||||||
|
// Event streams
|
||||||
|
router.HandleFunc("/streams", linstromEventStream)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupLinstromS2SApiV1Router() http.Handler {
|
||||||
|
router := http.NewServeMux()
|
||||||
|
// TODO: Figure out a decent server to server API definition
|
||||||
|
router.HandleFunc("/", placeholderEndpoint)
|
||||||
|
return router
|
||||||
|
}
|
375
server/apiLinstromAccounts.go
Normal file
375
server/apiLinstromAccounts.go
Normal file
|
@ -0,0 +1,375 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httputil "git.mstar.dev/mstar/goutils/http"
|
||||||
|
"git.mstar.dev/mstar/goutils/sliceutils"
|
||||||
|
"github.com/google/jsonapi"
|
||||||
|
"github.com/rs/zerolog/hlog"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// No create account. That happens during passkey registration
|
||||||
|
// and remote accounts are getting created at fetch time
|
||||||
|
func linstromGetAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
|
||||||
|
accId := AccountIdFromRequest(r)
|
||||||
|
acc, err := store.FindAccountById(accId)
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
// Ok, do nothing
|
||||||
|
case storage.ErrEntryNotFound:
|
||||||
|
httputil.HttpErr(w, HttpErrIdNotFound, "account not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
log.Error().Err(err).Str("account-id", accId).Msg("Failed to get account from storage")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get account from storage",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actorId, ok := r.Context().Value(ContextKeyActorId).(string)
|
||||||
|
if ok {
|
||||||
|
// Logged in user is accessing account, check if target account has them blocked
|
||||||
|
roles, err := store.FindRolesByNames(acc.Roles)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Strs("role-names", acc.Roles).
|
||||||
|
Msg("Failed to get roles from storage")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get roles of target account",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collapsedRole := storage.CollapseRolesIntoOne(roles...)
|
||||||
|
if sliceutils.Contains(collapsedRole.BlockedUsers, actorId) {
|
||||||
|
// Actor account is in list of blocked accounts, deny access
|
||||||
|
httputil.HttpErr(w, HttpErrIdNotAuthenticated, "Access forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outAccount, err := convertAccountStorageToLinstrom(acc, store)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Msg("Failed to convert storage account (and attached data) into linstrom API representation")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdConversionFailure,
|
||||||
|
"Failed to convert storage account and attached data into API representation",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = jsonapi.MarshalPayload(w, outAccount)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Any("account", outAccount).Msg("Failed to marshal and write account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func linstromUpdateAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
// Assumption: There must be a valid session once this function is called due to middlewares
|
||||||
|
actorId, _ := ActorIdFromRequest(r)
|
||||||
|
apiTarget := linstromAccount{}
|
||||||
|
err := jsonapi.UnmarshalPayload(r.Body, &apiTarget)
|
||||||
|
if err != nil {
|
||||||
|
httputil.HttpErr(w, HttpErrIdBadRequest, "bad body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetAccId := AccountIdFromRequest(r)
|
||||||
|
if apiTarget.Id != targetAccId {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdBadRequest,
|
||||||
|
"Provided entity's id doesn't match path id",
|
||||||
|
http.StatusConflict,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !(actorId == apiTarget.Id) {
|
||||||
|
httputil.HttpErr(w, HttpErrIdNotAuthenticated, "Invalid permissions", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbTarget, err := store.FindAccountById(apiTarget.Id)
|
||||||
|
// Assumption: The only sort of errors that can be returned are db failures.
|
||||||
|
// The account not existing is not possible anymore since this is in a valid session
|
||||||
|
// and a session is only injected if the actor account can be found
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("account-id", actorId).
|
||||||
|
Msg("Failed to get account from db despite valid session")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get account despite valid session",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// location, birthday, icon, banner, background, custom fields
|
||||||
|
// bluesky federation, uhhh
|
||||||
|
|
||||||
|
dbTarget.DisplayName = apiTarget.DisplayName
|
||||||
|
dbTarget.Indexable = apiTarget.Indexable
|
||||||
|
dbTarget.Description = apiTarget.Description
|
||||||
|
// TODO: Figure out how to properly update custom fields
|
||||||
|
dbTarget.Gender = apiTarget.Pronouns
|
||||||
|
dbTarget.IdentifiesAs = sliceutils.Map(
|
||||||
|
sliceutils.Filter(apiTarget.IdentifiesAs, func(t string) bool {
|
||||||
|
return storage.IsValidBeing(t)
|
||||||
|
}),
|
||||||
|
func(t string) storage.Being { return storage.Being(t) },
|
||||||
|
)
|
||||||
|
dbTarget.Indexable = apiTarget.Indexable
|
||||||
|
dbTarget.RestrictedFollow = apiTarget.RestrictedFollow
|
||||||
|
err = store.UpdateAccount(dbTarget)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to update account in db")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to update db entries",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
newAccData, err := convertAccountStorageToLinstrom(dbTarget, store)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to convert updated account back into api form")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdConversionFailure,
|
||||||
|
"Failed to convert updated account back into api form",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = jsonapi.MarshalPayload(w, newAccData)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to marshal and write updated account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func linstromDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actorId, _ := ActorIdFromRequest(r)
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
targetAccountId := AccountIdFromRequest(r)
|
||||||
|
if targetAccountId != actorId {
|
||||||
|
log.Debug().
|
||||||
|
Str("actor-id", actorId).
|
||||||
|
Str("target-id", targetAccountId).
|
||||||
|
Msg("Invalid attempt to delete account")
|
||||||
|
httputil.HttpErr(w, HttpErrIdNotAuthenticated, "Action forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info().Str("account-id", actorId).Msg("Deleting account")
|
||||||
|
acc, err := store.FindAccountById(targetAccountId)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("account-id", actorId).Msg("Failed to get account for deletion")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get account from db",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// allRoles, err := store.FindRolesByNames(acc.Roles)
|
||||||
|
// collapsedRole := storage.CollapseRolesIntoOne(allRoles...)
|
||||||
|
|
||||||
|
// TODO: Start job of sending out deletion messages to all federated servers
|
||||||
|
|
||||||
|
// Clean up related data first
|
||||||
|
// TODO: Also delete media files
|
||||||
|
err = store.DeleteRoleByName(acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("role-name", acc.ID).
|
||||||
|
Msg("Failed to delete user role for account deletion request")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to delete user role",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = store.DeleteAllUserFieldsForAccountId(acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("account-id", acc.ID).
|
||||||
|
Msg("Failed to delete custom info fields for account deletion")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to delete custom info fields",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = store.DeleteAccount(actorId)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("account-id", acc.ID).Msg("Failed to delete account")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to delete account from db",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is logged in following accountId
|
||||||
|
func linstromIsFollowingToAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
actorId, _ := ActorIdFromRequest(r)
|
||||||
|
targetId := AccountIdFromRequest(r)
|
||||||
|
|
||||||
|
relation, err := store.GetRelationBetween(actorId, targetId)
|
||||||
|
var outData linstromRelation
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
outData = linstromRelation{
|
||||||
|
Id: relation.ID,
|
||||||
|
CreatedAt: relation.CreatedAt,
|
||||||
|
UpdatedAt: relation.UpdatedAt,
|
||||||
|
FromId: relation.FromId,
|
||||||
|
ToId: relation.ToId,
|
||||||
|
Accepted: relation.Accepted,
|
||||||
|
Requested: true,
|
||||||
|
}
|
||||||
|
case storage.ErrEntryNotFound:
|
||||||
|
outData = linstromRelation{
|
||||||
|
Id: relation.ID,
|
||||||
|
CreatedAt: relation.CreatedAt,
|
||||||
|
UpdatedAt: relation.UpdatedAt,
|
||||||
|
FromId: relation.FromId,
|
||||||
|
ToId: relation.ToId,
|
||||||
|
Accepted: false,
|
||||||
|
Requested: false,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("from-id", actorId).
|
||||||
|
Str("to-id", targetId).
|
||||||
|
Msg("Failed to get follow relation")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get relation",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = jsonapi.MarshalPayload(w, outData)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to marshal response")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdJsonMarshalFail,
|
||||||
|
"Failed to marshal response",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func linstromIsFollowingFromAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
actorId, _ := ActorIdFromRequest(r)
|
||||||
|
targetId := AccountIdFromRequest(r)
|
||||||
|
|
||||||
|
relation, err := store.GetRelationBetween(targetId, actorId)
|
||||||
|
var outData linstromRelation
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
outData = linstromRelation{
|
||||||
|
Id: relation.ID,
|
||||||
|
CreatedAt: relation.CreatedAt,
|
||||||
|
UpdatedAt: relation.UpdatedAt,
|
||||||
|
FromId: relation.FromId,
|
||||||
|
ToId: relation.ToId,
|
||||||
|
Accepted: relation.Accepted,
|
||||||
|
Requested: true,
|
||||||
|
}
|
||||||
|
case storage.ErrEntryNotFound:
|
||||||
|
outData = linstromRelation{
|
||||||
|
Id: relation.ID,
|
||||||
|
CreatedAt: relation.CreatedAt,
|
||||||
|
UpdatedAt: relation.UpdatedAt,
|
||||||
|
FromId: relation.FromId,
|
||||||
|
ToId: relation.ToId,
|
||||||
|
Accepted: false,
|
||||||
|
Requested: false,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("from-id", targetId).
|
||||||
|
Str("to-id", actorId).
|
||||||
|
Msg("Failed to get follow relation")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get relation",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = jsonapi.MarshalPayload(w, outData)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to marshal response")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdJsonMarshalFail,
|
||||||
|
"Failed to marshal response",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func linstromFollowAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromUnfollowAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
func linstromIsBlockingAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromBlockAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromUnblockAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
func linstromIsMutedAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromMuteAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromUnmuteAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
func linstromReportAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromRetractReportAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
func linstromAdminAddRoleAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromAdminRemoveRoleAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromAdminWarnAccount(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
func linstromGetRole(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromCreateRole(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromUpdateRole(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromDeleteRole(w http.ResponseWriter, r *http.Request) {}
|
8
server/apiLinstromMedia.go
Normal file
8
server/apiLinstromMedia.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func linstromGetMediaMetadata(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromNewMediaMetadata(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromUpdateMediaMetadata(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromDeleteMediaMetadata(w http.ResponseWriter, r *http.Request) {}
|
158
server/apiLinstromNotes.go
Normal file
158
server/apiLinstromNotes.go
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
httputil "git.mstar.dev/mstar/goutils/http"
|
||||||
|
"github.com/google/jsonapi"
|
||||||
|
"github.com/rs/zerolog/hlog"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
func linstromGetNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
noteId := NoteIdFromRequest(r)
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
sNote, err := store.FindNoteById(noteId)
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
// Found, progress past switch statement
|
||||||
|
case storage.ErrEntryNotFound:
|
||||||
|
httputil.HttpErr(w, HttpErrIdNotFound, "Note not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
log.Error().Err(err).Str("note-id", noteId).Msg("Failed to get note from db")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get note from db",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
note, err := convertNoteStorageToLinstrom(sNote, store)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("note-id", noteId).
|
||||||
|
Msg("Failed to convert note into linstrom api form")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdConversionFailure,
|
||||||
|
"Failed to convert note",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = jsonapi.MarshalPayload(w, note)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Any("note", note).Msg("Failed to marshal and send note")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdJsonMarshalFail,
|
||||||
|
"Failed to convert note",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func linstromUpdateNote(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromNewNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
actorId, ok := ActorIdFromRequest(r)
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdNotAuthenticated,
|
||||||
|
"Needs a valid session to create new notes",
|
||||||
|
http.StatusUnauthorized,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newNote := linstromNote{}
|
||||||
|
err := jsonapi.UnmarshalPayload(r.Body, &newNote)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to unmarshal body")
|
||||||
|
httputil.HttpErr(w, HttpErrIdBadRequest, "bad body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if newNote.AuthorId != actorId {
|
||||||
|
log.Debug().
|
||||||
|
Str("actor-id", actorId).
|
||||||
|
Str("target-id", newNote.AuthorId).
|
||||||
|
Msg("Blocking attempt at creating a note for a different account")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdNotAllowed,
|
||||||
|
"creating a note for someone else is not allowed",
|
||||||
|
http.StatusForbidden,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawTags := shared.TagsFromText(newNote.RawContent)
|
||||||
|
|
||||||
|
note, err := store.CreateNoteLocal(
|
||||||
|
actorId,
|
||||||
|
newNote.RawContent,
|
||||||
|
newNote.ContentWarning,
|
||||||
|
time.Now(),
|
||||||
|
newNote.AttachmentIds,
|
||||||
|
newNote.EmoteIds,
|
||||||
|
newNote.InReplyToId,
|
||||||
|
newNote.QuotesId,
|
||||||
|
storage.NoteAccessLevel(newNote.AccessLevel),
|
||||||
|
rawTags,
|
||||||
|
// tags []string
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Any("note", newNote).Msg("Failed to insert new note into storage")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to insert new note into db",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = note
|
||||||
|
}
|
||||||
|
func linstromDeleteNote(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
func linstromGetReactions(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromAddReaction(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromDeleteReaction(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromUpdateReaction(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
// Boosts
|
||||||
|
func linstromGetBoosts(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromAddBoost(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromRemoveBoost(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
// Quotes
|
||||||
|
func linstromGetQuotes(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromAddQuote(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
// No delete quote since quotes are their own notes with an extra attribute
|
||||||
|
|
||||||
|
// Pinning
|
||||||
|
func linstromPinNote(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromUnpinNote(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
// Reporting
|
||||||
|
func linstromReportNote(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
func linstromRetractReportNote(w http.ResponseWriter, r *http.Request) {}
|
||||||
|
|
||||||
|
// Admin tools
|
||||||
|
// TODO: Figure out more admin tools for managing notes
|
||||||
|
// Delete can be done via normal note delete, common permission check
|
||||||
|
func linstromForceCWNote(w http.ResponseWriter, r *http.Request) {}
|
24
server/apiLinstromStreams.go
Normal file
24
server/apiLinstromStreams.go
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/rs/zerolog/hlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Decide where to put data stream handlers
|
||||||
|
|
||||||
|
var websocketUpgrader = websocket.Upgrader{}
|
||||||
|
|
||||||
|
// Entrypoint for a new stream will be in here at least
|
||||||
|
func linstromEventStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
conn, err := websocketUpgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to upgrade connection to websocket")
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
// TODO: Handle initial request for what events to receive
|
||||||
|
// TODO: Stream all requested events until connection closes (due to bad data from client or disconnect)
|
||||||
|
}
|
184
server/apiLinstromTypeHelpers.go
Normal file
184
server/apiLinstromTypeHelpers.go
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/goutils/sliceutils"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func convertAccountStorageToLinstrom(
|
||||||
|
acc *storage.Account,
|
||||||
|
store *storage.Storage,
|
||||||
|
) (*linstromAccount, error) {
|
||||||
|
storageServer, err := store.FindRemoteServerById(acc.ServerId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("remote server: %w", err)
|
||||||
|
}
|
||||||
|
apiServer, err := convertServerStorageToLinstrom(storageServer, store)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("remote server conversion: %w", err)
|
||||||
|
}
|
||||||
|
storageIcon, err := store.GetMediaMetadataById(acc.Icon)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("icon: %w", err)
|
||||||
|
}
|
||||||
|
var apiBanner *linstromMediaMetadata
|
||||||
|
if acc.Banner != nil {
|
||||||
|
storageBanner, err := store.GetMediaMetadataById(*acc.Banner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("banner: %w", err)
|
||||||
|
}
|
||||||
|
apiBanner = convertMediaMetadataStorageToLinstrom(storageBanner)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiBackground *linstromMediaMetadata
|
||||||
|
if acc.Background != nil {
|
||||||
|
storageBackground, err := store.GetMediaMetadataById(*acc.Background)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("banner: %w", err)
|
||||||
|
}
|
||||||
|
apiBackground = convertMediaMetadataStorageToLinstrom(storageBackground)
|
||||||
|
}
|
||||||
|
storageFields, err := store.FindMultipleUserFieldsById(acc.CustomFields)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("customFields: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &linstromAccount{
|
||||||
|
Id: acc.ID,
|
||||||
|
CreatedAt: acc.CreatedAt,
|
||||||
|
UpdatedAt: &acc.UpdatedAt,
|
||||||
|
Username: acc.Username,
|
||||||
|
OriginServer: apiServer,
|
||||||
|
OriginServerId: int(acc.ServerId),
|
||||||
|
DisplayName: acc.DisplayName,
|
||||||
|
CustomFields: sliceutils.Map(
|
||||||
|
storageFields,
|
||||||
|
func(t storage.UserInfoField) *linstromCustomAccountField {
|
||||||
|
return convertInfoFieldStorageToLinstrom(t)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CustomFieldIds: acc.CustomFields,
|
||||||
|
IsBot: acc.IsBot,
|
||||||
|
Description: acc.Description,
|
||||||
|
Icon: convertMediaMetadataStorageToLinstrom(storageIcon),
|
||||||
|
IconId: acc.Icon,
|
||||||
|
Banner: apiBanner,
|
||||||
|
BannerId: acc.Banner,
|
||||||
|
Background: apiBackground,
|
||||||
|
BackgroundId: acc.Background,
|
||||||
|
RelationIds: acc.Relations,
|
||||||
|
Indexable: acc.Indexable,
|
||||||
|
RestrictedFollow: acc.RestrictedFollow,
|
||||||
|
IdentifiesAs: sliceutils.Map(
|
||||||
|
acc.IdentifiesAs,
|
||||||
|
func(t storage.Being) string { return string(t) },
|
||||||
|
),
|
||||||
|
Pronouns: acc.Gender,
|
||||||
|
Roles: acc.Roles,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertServerStorageToLinstrom(
|
||||||
|
server *storage.RemoteServer,
|
||||||
|
store *storage.Storage,
|
||||||
|
) (*linstromOriginServer, error) {
|
||||||
|
storageMeta, err := store.GetMediaMetadataById(server.Icon)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("icon metadata: %w", err)
|
||||||
|
}
|
||||||
|
return &linstromOriginServer{
|
||||||
|
Id: server.ID,
|
||||||
|
CreatedAt: server.CreatedAt,
|
||||||
|
UpdatedAt: &server.UpdatedAt,
|
||||||
|
ServerType: string(server.ServerType),
|
||||||
|
Domain: server.Domain,
|
||||||
|
DisplayName: server.Name,
|
||||||
|
Icon: convertMediaMetadataStorageToLinstrom(storageMeta),
|
||||||
|
IsSelf: server.IsSelf,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertMediaMetadataStorageToLinstrom(metadata *storage.MediaMetadata) *linstromMediaMetadata {
|
||||||
|
return &linstromMediaMetadata{
|
||||||
|
Id: metadata.ID,
|
||||||
|
CreatedAt: metadata.CreatedAt,
|
||||||
|
UpdatedAt: &metadata.UpdatedAt,
|
||||||
|
IsRemote: metadata.Remote,
|
||||||
|
Url: metadata.Location,
|
||||||
|
MimeType: metadata.Type,
|
||||||
|
Name: metadata.Name,
|
||||||
|
AltText: metadata.AltText,
|
||||||
|
Blurred: metadata.Blurred,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertInfoFieldStorageToLinstrom(field storage.UserInfoField) *linstromCustomAccountField {
|
||||||
|
return &linstromCustomAccountField{
|
||||||
|
Id: field.ID,
|
||||||
|
CreatedAt: field.CreatedAt,
|
||||||
|
UpdatedAt: &field.UpdatedAt,
|
||||||
|
Key: field.Name,
|
||||||
|
Value: field.Value,
|
||||||
|
Verified: &field.Confirmed,
|
||||||
|
BelongsToId: field.BelongsTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertNoteStorageToLinstrom(
|
||||||
|
note *storage.Note,
|
||||||
|
store *storage.Storage,
|
||||||
|
) (*linstromNote, error) {
|
||||||
|
panic("Not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertEmoteStorageToLinstrom(
|
||||||
|
emote *storage.Emote,
|
||||||
|
store *storage.Storage,
|
||||||
|
) (*linstromEmote, error) {
|
||||||
|
storageServer, err := store.FindRemoteServerById(emote.ServerId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("server: %w", err)
|
||||||
|
}
|
||||||
|
server, err := convertServerStorageToLinstrom(storageServer, store)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("server conversion: %w", err)
|
||||||
|
}
|
||||||
|
storageMedia, err := store.GetMediaMetadataById(emote.MetadataId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("media metadata: %w", err)
|
||||||
|
}
|
||||||
|
media := convertMediaMetadataStorageToLinstrom(storageMedia)
|
||||||
|
|
||||||
|
return &linstromEmote{
|
||||||
|
Id: emote.ID,
|
||||||
|
MetadataId: emote.MetadataId,
|
||||||
|
Metadata: media,
|
||||||
|
Name: emote.Name,
|
||||||
|
ServerId: emote.ServerId,
|
||||||
|
Server: server,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertReactionStorageToLinstrom(
|
||||||
|
reaction *storage.Reaction,
|
||||||
|
store *storage.Storage,
|
||||||
|
) (*linstromReaction, error) {
|
||||||
|
storageEmote, err := store.GetEmoteById(reaction.EmoteId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("emote: %w", err)
|
||||||
|
}
|
||||||
|
emote, err := convertEmoteStorageToLinstrom(storageEmote, store)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("emote conversion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &linstromReaction{
|
||||||
|
Id: reaction.ID,
|
||||||
|
NoteId: reaction.NoteId,
|
||||||
|
ReactorId: reaction.ReactorId,
|
||||||
|
EmoteId: reaction.EmoteId,
|
||||||
|
Emote: emote,
|
||||||
|
}, nil
|
||||||
|
}
|
8
server/apiLinstromTypeHelpers_generated.go
Normal file
8
server/apiLinstromTypeHelpers_generated.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
// Code generated by cmd/RolesApiConverter DO NOT EDIT.
|
||||||
|
// If you need to refresh the content, run go generate again
|
||||||
|
package server
|
||||||
|
|
||||||
|
import "git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
func convertRoleStorageToLinstrom(r storage.Role) linstromRole {
|
||||||
|
return linstromRole{Id:r.ID,CreatedAt:r.CreatedAt,UpdatedAt:r.UpdatedAt,Name:r.Name,Priority:r.Priority,IsUserRole:r.IsUserRole,IsBuiltIn:r.IsBuiltIn,CanRecoverDeletedNotes:r.CanRecoverDeletedNotes,CanSendFollowerOnlyNotes:r.CanSendFollowerOnlyNotes,CanSendReplies:r.CanSendReplies,AutoNsfwMedia:r.AutoNsfwMedia,WithholdNotesForManualApproval:r.WithholdNotesForManualApproval,CanAffectOtherAdmins:r.CanAffectOtherAdmins,CanAssignRoles:r.CanAssignRoles,CanOverwriteDisplayNames:r.CanOverwriteDisplayNames,CanManageAvatarDecorations:r.CanManageAvatarDecorations,CanSendMedia:r.CanSendMedia,ScanCreatedLocalNotes:r.ScanCreatedLocalNotes,CanSendCustomEmotes:r.CanSendCustomEmotes,CanSendPublicNotes:r.CanSendPublicNotes,CanIncludeSurvey:r.CanIncludeSurvey,AutoCwPostsText:r.AutoCwPostsText,CanManageCustomEmotes:r.CanManageCustomEmotes,CanSendAnnouncements:r.CanSendAnnouncements,CanSendLocalNotes:r.CanSendLocalNotes,CanBoost:r.CanBoost,CanLogin:r.CanLogin,CanMentionOthers:r.CanMentionOthers,CanManageAds:r.CanManageAds,CanQuote:r.CanQuote,CanChangeDisplayName:r.CanChangeDisplayName,CanSubmitReports:r.CanSubmitReports,FullAdmin:r.FullAdmin,CanSendPrivateNotes:r.CanSendPrivateNotes,CanIncludeLinks:r.CanIncludeLinks,CanFederateFedi:r.CanFederateFedi,HasMentionCountLimit:r.HasMentionCountLimit,AutoCwPosts:r.AutoCwPosts,ScanCreatedPublicNotes:r.ScanCreatedPublicNotes,DisallowInteractionsWith:r.DisallowInteractionsWith,CanDeleteNotes:r.CanDeleteNotes,CanConfirmWithheldNotes:r.CanConfirmWithheldNotes,ScanCreatedPrivateNotes:r.ScanCreatedPrivateNotes,WithholdNotesBasedOnRegex:r.WithholdNotesBasedOnRegex,WithholdNotesRegexes:r.WithholdNotesRegexes,CanSupressInteractionsBetweenUsers:r.CanSupressInteractionsBetweenUsers,CanViewDeletedNotes:r.CanViewDeletedNotes,CanSendCustomReactions:r.CanSendCustomReactions,CanFederateBsky:r.CanFederateBsky,BlockedUsers:r.BlockedUsers,MentionLimit:r.MentionLimit,ScanCreatedFollowerOnlyNotes:r.ScanCreatedFollowerOnlyNotes,}
|
||||||
|
}
|
118
server/apiLinstromTypes.go
Normal file
118
server/apiLinstromTypes.go
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
// Contains types used by the Linstrom API. Types comply with the jsonapi spec
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = linstromRole{}
|
||||||
|
_ = linstromRelation{}
|
||||||
|
)
|
||||||
|
|
||||||
|
type linstromNote struct {
|
||||||
|
Id string `jsonapi:"primary,notes"`
|
||||||
|
RawContent string `jsonapi:"attr,content"`
|
||||||
|
OriginServer *linstromOriginServer `jsonapi:"relation,origin-server"`
|
||||||
|
OriginServerId int `jsonapi:"attr,origin-server-id"`
|
||||||
|
ReactionCount string `jsonapi:"attr,reaction-count"`
|
||||||
|
CreatedAt time.Time `jsonapi:"attr,created-at"`
|
||||||
|
UpdatedAt *time.Time `jsonapi:"attr,updated-at,omitempty"`
|
||||||
|
Author *linstromAccount `jsonapi:"relation,author"`
|
||||||
|
AuthorId string `jsonapi:"attr,author-id"`
|
||||||
|
ContentWarning *string `jsonapi:"attr,content-warning,omitempty"`
|
||||||
|
InReplyToId *string `jsonapi:"attr,in-reply-to-id,omitempty"`
|
||||||
|
QuotesId *string `jsonapi:"attr,quotes-id,omitempty"`
|
||||||
|
EmoteIds []string `jsonapi:"attr,emotes,omitempty"`
|
||||||
|
Attachments []*linstromMediaMetadata `jsonapi:"relation,attachments,omitempty"`
|
||||||
|
AttachmentIds []string `jsonapi:"attr,attachment-ids"`
|
||||||
|
AccessLevel uint8 `jsonapi:"attr,access-level"`
|
||||||
|
Pings []*linstromAccount `jsonapi:"relation,pings,omitempty"`
|
||||||
|
PingIds []string `jsonapi:"attr,ping-ids,omitempty"`
|
||||||
|
ReactionIds []uint `jsonapi:"attr,reaction-ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linstromOriginServer struct {
|
||||||
|
Id uint `jsonapi:"primary,origins"`
|
||||||
|
CreatedAt time.Time `jsonapi:"attr,created-at"`
|
||||||
|
UpdatedAt *time.Time `jsonapi:"attr,updated-at,omitempty"`
|
||||||
|
ServerType string `jsonapi:"attr,server-type"` // one of "Linstrom", "Mastodon", "Plemora", "Misskey" or "Wafrn"
|
||||||
|
Domain string `jsonapi:"attr,domain"`
|
||||||
|
DisplayName string `jsonapi:"attr,display-name"`
|
||||||
|
Icon *linstromMediaMetadata `jsonapi:"relation,icon"`
|
||||||
|
IsSelf bool `jsonapi:"attr,is-self"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linstromMediaMetadata struct {
|
||||||
|
Id string `jsonapi:"primary,media"`
|
||||||
|
CreatedAt time.Time `jsonapi:"attr,created-at"`
|
||||||
|
UpdatedAt *time.Time `jsonapi:"attr,updated-at,omitempty"`
|
||||||
|
IsRemote bool `jsonapi:"attr,is-remote"`
|
||||||
|
Url string `jsonapi:"attr,url"`
|
||||||
|
MimeType string `jsonapi:"attr,mime-type"`
|
||||||
|
Name string `jsonapi:"attr,name"`
|
||||||
|
AltText string `jsonapi:"attr,alt-text"`
|
||||||
|
Blurred bool `jsonapi:"attr,blurred"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linstromAccount struct {
|
||||||
|
Id string `jsonapi:"primary,accounts"`
|
||||||
|
CreatedAt time.Time `jsonapi:"attr,created-at"`
|
||||||
|
UpdatedAt *time.Time `jsonapi:"attr,updated-at,omitempty"`
|
||||||
|
Username string `jsonapi:"attr,username"`
|
||||||
|
OriginServer *linstromOriginServer `jsonapi:"relation,origin-server"`
|
||||||
|
OriginServerId int `jsonapi:"attr,origin-server-id"`
|
||||||
|
DisplayName string `jsonapi:"attr,display-name"`
|
||||||
|
CustomFields []*linstromCustomAccountField `jsonapi:"relation,custom-fields"`
|
||||||
|
CustomFieldIds []uint `jsonapi:"attr,custom-field-ids"`
|
||||||
|
IsBot bool `jsonapi:"attr,is-bot"`
|
||||||
|
Description string `jsonapi:"attr,description"`
|
||||||
|
Icon *linstromMediaMetadata `jsonapi:"relation,icon"`
|
||||||
|
IconId string `jsonapi:"attr,icon-id"`
|
||||||
|
Banner *linstromMediaMetadata `jsonapi:"relation,banner"`
|
||||||
|
BannerId *string `jsonapi:"attr,banner-id"`
|
||||||
|
Background *linstromMediaMetadata `jsonapi:"relation,background"`
|
||||||
|
BackgroundId *string `jsonapi:"attr,background-id"`
|
||||||
|
RelationIds []uint `jsonapi:"attr,follows-ids"`
|
||||||
|
Indexable bool `jsonapi:"attr,indexable"`
|
||||||
|
RestrictedFollow bool `jsonapi:"attr,restricted-follow"`
|
||||||
|
IdentifiesAs []string `jsonapi:"attr,identifies-as"`
|
||||||
|
Pronouns []string `jsonapi:"attr,pronouns"`
|
||||||
|
Roles []string `jsonapi:"attr,roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linstromCustomAccountField struct {
|
||||||
|
Id uint `jsonapi:"primary,custom-account-fields"`
|
||||||
|
CreatedAt time.Time `jsonapi:"attr,created-at"`
|
||||||
|
UpdatedAt *time.Time `jsonapi:"attr,updated-at,omitempty"`
|
||||||
|
Key string `jsonapi:"attr,key"`
|
||||||
|
Value string `jsonapi:"attr,value"`
|
||||||
|
Verified *bool `jsonapi:"attr,verified,omitempty"`
|
||||||
|
BelongsToId string `jsonapi:"attr,belongs-to-id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linstromRelation struct {
|
||||||
|
Id uint `jsonapi:"primary,relations"`
|
||||||
|
CreatedAt time.Time `jsonapi:"attr,created-at"`
|
||||||
|
UpdatedAt time.Time `jsonapi:"attr,updated-at"`
|
||||||
|
FromId string `jsonapi:"attr,from-id"`
|
||||||
|
ToId string `jsonapi:"attr,to-id"`
|
||||||
|
Requested bool `jsonapi:"attr,requested"`
|
||||||
|
Accepted bool `jsonapi:"attr,accepted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linstromReaction struct {
|
||||||
|
Id uint `jsonapi:"primary,reactions"`
|
||||||
|
NoteId string `jsonapi:"attr,note-id"`
|
||||||
|
ReactorId string `jsonapi:"attr,reactor-id"`
|
||||||
|
EmoteId uint `jsonapi:"attr,emote-id"`
|
||||||
|
Emote *linstromEmote `jsonapi:"relation,emote"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type linstromEmote struct {
|
||||||
|
Id uint `jsonapi:"primary,emotes"`
|
||||||
|
MetadataId string `jsonapi:"attr,metadata-id"`
|
||||||
|
Metadata *linstromMediaMetadata `jsonapi:"relation,metadata"`
|
||||||
|
Name string `jsonapi:"attr,name"`
|
||||||
|
ServerId uint `jsonapi:"attr,server-id"`
|
||||||
|
Server *linstromOriginServer `jsonapi:"relation,server"`
|
||||||
|
}
|
59
server/apiLinstromTypes_generated.go
Normal file
59
server/apiLinstromTypes_generated.go
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
// Code generated by cmd/RolesApiTypeGenerator DO NOT EDIT.
|
||||||
|
// If you need to refresh the content, run go generate again
|
||||||
|
package server
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
type linstromRole struct {
|
||||||
|
Id uint `jsonapi:"primary,roles"`
|
||||||
|
CreatedAt time.Time `jsonapi:"attr,created-at"`
|
||||||
|
UpdatedAt time.Time `jsonapi:"attr,updated-at"`
|
||||||
|
Name string `jsonapi:"attr,name"`
|
||||||
|
Priority uint32 `jsonapi:"attr,priority"`
|
||||||
|
IsUserRole bool `jsonapi:"attr,is-user-role"`
|
||||||
|
IsBuiltIn bool `jsonapi:"attr,is-built-in"`
|
||||||
|
CanIncludeLinks *bool `jsonapi:"attr,can-include-links"`
|
||||||
|
CanIncludeSurvey *bool `jsonapi:"attr,can-include-survey"`
|
||||||
|
CanLogin *bool `jsonapi:"attr,can-login"`
|
||||||
|
CanSupressInteractionsBetweenUsers *bool `jsonapi:"attr,can-supress-interactions-between-users"`
|
||||||
|
CanManageCustomEmotes *bool `jsonapi:"attr,can-manage-custom-emotes"`
|
||||||
|
CanSendAnnouncements *bool `jsonapi:"attr,can-send-announcements"`
|
||||||
|
CanSendReplies *bool `jsonapi:"attr,can-send-replies"`
|
||||||
|
CanRecoverDeletedNotes *bool `jsonapi:"attr,can-recover-deleted-notes"`
|
||||||
|
CanMentionOthers *bool `jsonapi:"attr,can-mention-others"`
|
||||||
|
CanSendFollowerOnlyNotes *bool `jsonapi:"attr,can-send-follower-only-notes"`
|
||||||
|
CanSendPrivateNotes *bool `jsonapi:"attr,can-send-private-notes"`
|
||||||
|
HasMentionCountLimit *bool `jsonapi:"attr,has-mention-count-limit"`
|
||||||
|
MentionLimit *uint32 `jsonapi:"attr,mention-limit"`
|
||||||
|
ScanCreatedFollowerOnlyNotes *bool `jsonapi:"attr,scan-created-follower-only-notes"`
|
||||||
|
FullAdmin *bool `jsonapi:"attr,full-admin"`
|
||||||
|
CanSendPublicNotes *bool `jsonapi:"attr,can-send-public-notes"`
|
||||||
|
CanFederateBsky *bool `jsonapi:"attr,can-federate-bsky"`
|
||||||
|
CanSubmitReports *bool `jsonapi:"attr,can-submit-reports"`
|
||||||
|
AutoNsfwMedia *bool `jsonapi:"attr,auto-nsfw-media"`
|
||||||
|
AutoCwPostsText *string `jsonapi:"attr,auto-cw-posts-text"`
|
||||||
|
WithholdNotesForManualApproval *bool `jsonapi:"attr,withhold-notes-for-manual-approval"`
|
||||||
|
CanManageAds *bool `jsonapi:"attr,can-manage-ads"`
|
||||||
|
CanFederateFedi *bool `jsonapi:"attr,can-federate-fedi"`
|
||||||
|
CanChangeDisplayName *bool `jsonapi:"attr,can-change-display-name"`
|
||||||
|
ScanCreatedPublicNotes *bool `jsonapi:"attr,scan-created-public-notes"`
|
||||||
|
ScanCreatedPrivateNotes *bool `jsonapi:"attr,scan-created-private-notes"`
|
||||||
|
CanAssignRoles *bool `jsonapi:"attr,can-assign-roles"`
|
||||||
|
CanSendCustomReactions *bool `jsonapi:"attr,can-send-custom-reactions"`
|
||||||
|
BlockedUsers []string `jsonapi:"attr,blocked-users"`
|
||||||
|
DisallowInteractionsWith []string `jsonapi:"attr,disallow-interactions-with"`
|
||||||
|
CanDeleteNotes *bool `jsonapi:"attr,can-delete-notes"`
|
||||||
|
CanViewDeletedNotes *bool `jsonapi:"attr,can-view-deleted-notes"`
|
||||||
|
CanManageAvatarDecorations *bool `jsonapi:"attr,can-manage-avatar-decorations"`
|
||||||
|
CanSendMedia *bool `jsonapi:"attr,can-send-media"`
|
||||||
|
CanSendLocalNotes *bool `jsonapi:"attr,can-send-local-notes"`
|
||||||
|
CanBoost *bool `jsonapi:"attr,can-boost"`
|
||||||
|
AutoCwPosts *bool `jsonapi:"attr,auto-cw-posts"`
|
||||||
|
ScanCreatedLocalNotes *bool `jsonapi:"attr,scan-created-local-notes"`
|
||||||
|
WithholdNotesRegexes []string `jsonapi:"attr,withhold-notes-regexes"`
|
||||||
|
CanSendCustomEmotes *bool `jsonapi:"attr,can-send-custom-emotes"`
|
||||||
|
WithholdNotesBasedOnRegex *bool `jsonapi:"attr,withhold-notes-based-on-regex"`
|
||||||
|
CanAffectOtherAdmins *bool `jsonapi:"attr,can-affect-other-admins"`
|
||||||
|
CanConfirmWithheldNotes *bool `jsonapi:"attr,can-confirm-withheld-notes"`
|
||||||
|
CanOverwriteDisplayNames *bool `jsonapi:"attr,can-overwrite-display-names"`
|
||||||
|
CanQuote *bool `jsonapi:"attr,can-quote"`
|
||||||
|
}
|
288
server/apiRouter.go
Normal file
288
server/apiRouter.go
Normal file
|
@ -0,0 +1,288 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// Mounted at /api
|
||||||
|
func setupApiRouter() http.Handler {
|
||||||
|
router := http.NewServeMux()
|
||||||
|
router.Handle("/linstrom/", http.StripPrefix("/linstrom", setupLinstromApiRouter()))
|
||||||
|
|
||||||
|
// Section MastoApi
|
||||||
|
// First segment are endpoints that will need to be moved to primary router since at top route
|
||||||
|
router.HandleFunc("GET /oauth/authorize", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /oauth/token", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /oauth/revoke", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /.well-known/oauth-authorization-server", placeholderEndpoint)
|
||||||
|
// These ones are actually mounted under /api/
|
||||||
|
router.HandleFunc("POST /v1/apps", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/apps/verify_credentials", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/emails/confirmations", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/verify_credentials", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PATCH /v1/accounts/update_credentials", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/{id}/statuses", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/{id}/followers", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/{id}/following", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/{id}/featured_tags", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/{id}/lists", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts/{id}/follow", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts/{id}/unfollow", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts/{id}/remove_from_followers", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts/{id}/block", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts/{id}/unblock", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts/{id}/mute", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts/{id}/unmute", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts/{id}/pin", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts/{id}/unpin", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/accounts/{id}/note", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/relationships", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/familiar_followers", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/search", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/accounts/lookup", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/bookmarks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/favourites", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/mutes", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/domain_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/domain_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/domain_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/filters", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/filters/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v2/filters", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v2/filters/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v2/filters/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/filters/:filter_id/keywords", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v2/filters/:filter_id/keywords", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/filters/keywords/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v2/filters/keywords/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v2/filters/keywords/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/filters/:filter_id/statuses", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v2/filters/:filter_id/statuses", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/filters/statuses/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v2/filters/statuses/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/reports", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/follow_requests", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/follow_requests/:account_id/authorize", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/follow_requests/:account_id/reject", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/endorsements", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/featured_tags", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/featured_tags", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/featured_tags/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/featured_tags/suggestions", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/preferences", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/followed_tags", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/suggestions", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/suggestions/:account_id", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/tags/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/tags/{id}/follow", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/tags/{id}/unfollow", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/profile/avatar", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/profile/header", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/statuses/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/statuses", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/statuses/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/statuses/{id}/context", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/translate", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/statuses/{id}/reblogged_by", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/statuses/{id}/favourited_by", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/favourite", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/unfavourite", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/reblog", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/unreblog", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/bookmark", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/unbookmark", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/mute", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/unmute", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/pin", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/statuses/{id}/unpin", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v1/statuses/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/statuses/{id}/history", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/statuses/{id}/source", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v2/media", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/media/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v1/media/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/polls/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/polls/{id}/votes", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/scheduled_statuses", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/scheduled_statuses/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v1/scheduled_statuses/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/scheduled_statuses/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/timelines/public", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/timelines/tag/:hashtag", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/timelines/home", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/timelines/link", placeholderEndpoint) // ?url=:url
|
||||||
|
router.HandleFunc("GET /v1/timelines/list/:list_id", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/conversations", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/conversations/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/conversations/{id}/read", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/lists", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/lists/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/lists", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v1/lists/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/lists/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/lists/{id}/accounts", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/lists/{id}/accounts", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/lists/{id}/accounts", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/markers", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/markers", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/streaming/health", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/streaming/user", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/streaming/user/notification", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/streaming/public", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/streaming/public/local", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/streaming/public/remote", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/streaming/hashtag", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/streaming/hashtag/local", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/streaming/list", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/streaming/direct", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/notifications", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/notifications/:group_key", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v2/notifications/:group_key/dismiss", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/notifications/:group_key/accounts", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/notifications/unread_count", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/notifications", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/notifications/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/notifications/clear", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/notifications/{id}/dismiss", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/notifications/unread_count", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/notifications/policy", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PATCH /v2/notifications/policy", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/notifications/requests", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/notifications/requests/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/notifications/requests/{id}/accept", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/notifications/requests/{id}/dismiss", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/notifications/requests/accept", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/notifications/requests/dismiss", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/notifications/requests/merged", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/push/subscription", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/push/subscription", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v1/push/subscription", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/push/subscription", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/search", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/instance", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/instance/peers", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/instance/activity", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/instance/rules", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/instance/domain_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/instance/extended_description", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/instance/translation_languages", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/trends/tags", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/trends/statuses", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/trends/links", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/directory", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/custom_emojis", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/announcements", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/announcements/{id}/dismiss", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v1/announcements/{id}/reactions/:name", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/announcements/{id}/reactions/:name", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/accounts", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v2/admin/accounts", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/accounts/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/accounts/{id}/approve", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/accounts/{id}/reject", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/admin/accounts/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/accounts/{id}/action", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/accounts/{id}/enable", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/accounts/{id}/unsilence", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/accounts/{id}/unsuspend", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/accounts/{id}/unsensitive", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/canonical_email_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/canonical_email_blocks/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/canonical_email_blocks/test", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/canonical_email_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/admin/canonical_email_blocks/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/dimensions", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/domain_allows", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/domain_allows/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/domain_allows", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/admin/domain_allows/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/domain_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/domain_blocks/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/domain_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v1/admin/domain_blocks/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/admin/domain_blocks/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/email_domain_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/email_domain_blocks/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/email_domain_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/admin/email_domain_blocks/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/ip_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/ip_blocks/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/ip_blocks", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v1/admin/ip_blocks/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("DELETE /v1/admin/ip_blocks/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/measures", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/reports", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/reports/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("PUT /v1/admin/reports/{id}", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/reports/{id}/assign_to_self", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/reports/{id}/unassign", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/reports/{id}/resolve", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/reports/{id}/reopen", placeholderEndpoint)
|
||||||
|
router.HandleFunc("POST /v1/admin/retention", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/trends/links", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/trends/statuses", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /v1/admin/trends/tags", placeholderEndpoint)
|
||||||
|
router.HandleFunc("GET /oembed", placeholderEndpoint)
|
||||||
|
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /v1/accounts/{id}/identity_proofs",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /v1/filters",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /v1/filters/{id}",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"POST /v1/filters",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"PUT /v1/filters/{id}",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"DELETE /v1/filters/{id}",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /v1/suggestions",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /v1/statuses/{id}/card",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"POST /v1/media",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /v1/timelines/direct",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"POST /v1/notifications/dismiss",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Removed
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /v1/search",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Removed
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /v1/instance",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Deprecated
|
||||||
|
router.HandleFunc(
|
||||||
|
"GET /proofs",
|
||||||
|
placeholderEndpoint,
|
||||||
|
) // Removed
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
23
server/constants.go
Normal file
23
server/constants.go
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
const ContextKeyPasskeyUsername = "context-passkey-username"
|
||||||
|
|
||||||
|
type ContextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContextKeyStorage ContextKey = "Context key for storage"
|
||||||
|
ContextKeyActorId ContextKey = "Context key for actor id"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
HttpErrIdPlaceholder = iota
|
||||||
|
HttpErrIdMissingContextValue
|
||||||
|
HttpErrIdDbFailure
|
||||||
|
HttpErrIdNotAuthenticated
|
||||||
|
HttpErrIdJsonMarshalFail
|
||||||
|
HttpErrIdBadRequest
|
||||||
|
HttpErrIdAlreadyExists
|
||||||
|
HttpErrIdNotFound
|
||||||
|
HttpErrIdConversionFailure
|
||||||
|
HttpErrIdNotAllowed
|
||||||
|
)
|
38
server/endpoints_ap.go
Normal file
38
server/endpoints_ap.go
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mount under /.well-known/webfinger
|
||||||
|
func webfingerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
logger := zerolog.Ctx(r.Context())
|
||||||
|
store := storage.Storage{}
|
||||||
|
|
||||||
|
requestedResource := r.FormValue("resource")
|
||||||
|
if requestedResource == "" {
|
||||||
|
http.Error(w, "bad request. Include \"resource\" parameter", http.StatusBadRequest)
|
||||||
|
logger.Debug().Msg("Resource parameter missing. Cancelling")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accName := strings.TrimPrefix(requestedResource, "acc:")
|
||||||
|
acc, err := store.FindAccountByFullHandle(accName)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
http.Error(w, "account not found", http.StatusNotFound)
|
||||||
|
logger.Debug().Str("account-name", accName).Msg("Account not found")
|
||||||
|
} else {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
logger.Error().Err(err).Msg("Error while searching for account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, acc)
|
||||||
|
}
|
22
server/frontendRouter.go
Normal file
22
server/frontendRouter.go
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mounted at /
|
||||||
|
func setupFrontendRouter(interactiveFs, noscriptFs fs.FS) http.Handler {
|
||||||
|
router := http.NewServeMux()
|
||||||
|
router.Handle("/noscript/", http.StripPrefix("/noscript", http.FileServerFS(noscriptFs)))
|
||||||
|
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFileFS(w, r, interactiveFs, "index.html")
|
||||||
|
})
|
||||||
|
router.Handle("/assets/", http.FileServerFS(interactiveFs))
|
||||||
|
router.HandleFunc(
|
||||||
|
"/robots.txt",
|
||||||
|
func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, interactiveFs, "robots.txt") },
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
77
server/healthAndMetrics.go
Normal file
77
server/healthAndMetrics.go
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/pprof"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
httputil "git.mstar.dev/mstar/goutils/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mounted at /profiling
|
||||||
|
func setupProfilingHandler() http.Handler {
|
||||||
|
router := http.NewServeMux()
|
||||||
|
router.HandleFunc("/", profilingRootHandler)
|
||||||
|
router.HandleFunc("GET /current-goroutines", metricActiveGoroutinesHandler)
|
||||||
|
router.HandleFunc("GET /memory", metricMemoryStatsHandler)
|
||||||
|
router.HandleFunc("GET /pprof/cpu", pprof.Profile)
|
||||||
|
router.Handle("GET /pprof/memory", pprof.Handler("heap"))
|
||||||
|
router.Handle("GET /pprof/goroutines", pprof.Handler("goroutine"))
|
||||||
|
router.Handle("GET /pprof/blockers", pprof.Handler("block"))
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAliveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, "yup")
|
||||||
|
}
|
||||||
|
|
||||||
|
func profilingRootHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(
|
||||||
|
w,
|
||||||
|
"Endpoints: /, /{memory,current-goroutines}, /pprof/{cpu,memory,goroutines,blockers}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func metricActiveGoroutinesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintf(w, "{\"goroutines\": %d}", runtime.NumGoroutine())
|
||||||
|
}
|
||||||
|
|
||||||
|
func metricMemoryStatsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type OutData struct {
|
||||||
|
CollectedAt time.Time `json:"collected_at"`
|
||||||
|
HeapUsed uint64 `json:"heap_used"`
|
||||||
|
HeapIdle uint64 `json:"heap_idle"`
|
||||||
|
StackUsed uint64 `json:"stack_used"`
|
||||||
|
GCLastFired time.Time `json:"gc_last_fired"`
|
||||||
|
GCNextTargetHeapSize uint64 `json:"gc_next_target_heap_size"`
|
||||||
|
}
|
||||||
|
stats := runtime.MemStats{}
|
||||||
|
gcStats := debug.GCStats{}
|
||||||
|
runtime.ReadMemStats(&stats)
|
||||||
|
debug.ReadGCStats(&gcStats)
|
||||||
|
outData := OutData{
|
||||||
|
CollectedAt: time.Now(),
|
||||||
|
HeapUsed: stats.HeapInuse,
|
||||||
|
HeapIdle: stats.HeapIdle,
|
||||||
|
StackUsed: stats.StackInuse,
|
||||||
|
GCLastFired: gcStats.LastGC,
|
||||||
|
GCNextTargetHeapSize: stats.NextGC,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(&outData)
|
||||||
|
if err != nil {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdJsonMarshalFail,
|
||||||
|
"Failed to encode return data",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, string(jsonData))
|
||||||
|
}
|
239
server/middlewareFixPasskeyPerms.go
Normal file
239
server/middlewareFixPasskeyPerms.go
Normal file
|
@ -0,0 +1,239 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
httputil "git.mstar.dev/mstar/goutils/http"
|
||||||
|
"github.com/rs/zerolog/hlog"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func forceCorrectPasskeyAuthFlowMiddleware(
|
||||||
|
handler http.Handler,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
// Don't fuck with the request if not intended for starting to register or login
|
||||||
|
if strings.HasSuffix(r.URL.Path, "loginFinish") {
|
||||||
|
log.Debug().Msg("Request to finish login method, doing nothing")
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
} else if strings.HasSuffix(r.URL.Path, "registerFinish") {
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
// Force unset session cookie here
|
||||||
|
w.Header().Del("Set-Cookie")
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "sid",
|
||||||
|
Value: "",
|
||||||
|
Path: "",
|
||||||
|
MaxAge: 0,
|
||||||
|
Expires: time.UnixMilli(0),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} else if strings.HasSuffix(r.URL.Path, "loginBegin") {
|
||||||
|
fuckWithLoginRequest(w, r, handler)
|
||||||
|
} else if strings.HasSuffix(r.URL.Path, "registerBegin") {
|
||||||
|
fuckWithRegisterRequest(w, r, handler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuckWithRegisterRequest(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
nextHandler http.Handler,
|
||||||
|
) {
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
log.Debug().Msg("Messing with register start request")
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
if store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cookie, cookieErr := r.Cookie("sid")
|
||||||
|
var username struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
log.Debug().Bytes("body", body).Msg("Body of auth begin request")
|
||||||
|
err := json.Unmarshal(body, &username)
|
||||||
|
if err != nil {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdBadRequest,
|
||||||
|
"Not a username json object",
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cookieErr == nil {
|
||||||
|
// Already authenticated, overwrite username to logged in account's name
|
||||||
|
// Get session from cookie
|
||||||
|
log.Debug().Msg("Session token exists, force overwriting username of register request")
|
||||||
|
session, ok := store.GetSession(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
log.Error().Str("session-id", cookie.Value).Msg("Passkey session missing")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Passkey session missing",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acc, err := store.FindAccountByPasskeyId(session.UserID)
|
||||||
|
// Assume account must exist if a session for it exists
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get account from passkey id from session")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get authenticated account",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Replace whatever username may be given with username of logged in account
|
||||||
|
newBody := strings.ReplaceAll(string(body), username.Username, acc.Username)
|
||||||
|
// Assign to request
|
||||||
|
r.Body = io.NopCloser(strings.NewReader(newBody))
|
||||||
|
r.ContentLength = int64(len(newBody))
|
||||||
|
// And pass on
|
||||||
|
nextHandler.ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
// Not authenticated, ensure that no existing name is registered with
|
||||||
|
_, err = store.FindLocalAccountByUsername(username.Username)
|
||||||
|
log.Debug().Bool("err-equals-not_found", err == storage.ErrEntryNotFound).Send()
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
// No error while getting account means account exists, refuse access
|
||||||
|
log.Info().
|
||||||
|
Str("username", username.Username).
|
||||||
|
Msg("Account with same name already exists, preventing login")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdAlreadyExists,
|
||||||
|
"Account with that name already exists",
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
case storage.ErrEntryNotFound:
|
||||||
|
// Didn't find account with that name, give access
|
||||||
|
log.Debug().
|
||||||
|
Str("username", username.Username).
|
||||||
|
Msg("No account with this username exists yet, passing through")
|
||||||
|
// Copy original body since previous reader hit EOF
|
||||||
|
r.Body = io.NopCloser(strings.NewReader(string(body)))
|
||||||
|
r.ContentLength = int64(len(body))
|
||||||
|
nextHandler.ServeHTTP(w, r)
|
||||||
|
default:
|
||||||
|
// Some other error, log it and return appropriate message
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("username", username.Username).
|
||||||
|
Msg("Failed to check if account with username already exists")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to check if account with that name already exists",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuckWithLoginRequest(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
nextHandler http.Handler,
|
||||||
|
) {
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
log.Debug().Msg("Messing with login start request")
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
if store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cookie, cookieErr := r.Cookie("sid")
|
||||||
|
var username struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
// Force ignore cookie for now
|
||||||
|
_ = cookieErr
|
||||||
|
var err error = errors.New("placeholder")
|
||||||
|
if err == nil {
|
||||||
|
// Someone is logged in, overwrite username with logged in account's one
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
log.Debug().Bytes("body", body).Msg("Body of auth begin request")
|
||||||
|
err := json.Unmarshal(body, &username)
|
||||||
|
if err != nil {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdBadRequest,
|
||||||
|
"Not a username json object",
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, ok := store.GetSession(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
log.Error().Str("session-id", cookie.Value).Msg("Passkey session missing")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Passkey session missing",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acc, err := store.FindAccountByPasskeyId(session.UserID)
|
||||||
|
// Assume account must exist if a session for it exists
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get account from passkey id from session")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get authenticated account",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Replace whatever username may be given with username of logged in account
|
||||||
|
newBody := strings.ReplaceAll(string(body), username.Username, acc.Username)
|
||||||
|
// Assign to request
|
||||||
|
r.Body = io.NopCloser(strings.NewReader(newBody))
|
||||||
|
r.ContentLength = int64(len(newBody))
|
||||||
|
// And pass on
|
||||||
|
nextHandler.ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
// No one logged in, check if user exists to prevent creating a bugged account
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
log.Debug().Bytes("body", body).Msg("Body of auth begin request")
|
||||||
|
err := json.Unmarshal(body, &username)
|
||||||
|
if err != nil {
|
||||||
|
httputil.HttpErr(w, HttpErrIdBadRequest, "Not a username json object", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = store.FindLocalAccountByUsername(username.Username)
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
// All good, account exists, keep going
|
||||||
|
// Do nothing in this branch
|
||||||
|
case storage.ErrEntryNotFound:
|
||||||
|
// Account doesn't exist, catch it
|
||||||
|
httputil.HttpErr(w, HttpErrIdNotFound, "Username not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// catch db failures
|
||||||
|
log.Error().Err(err).Str("username", username.Username).Msg("Db failure while getting account")
|
||||||
|
httputil.HttpErr(w, HttpErrIdDbFailure, "Failed to check for account in db", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Restore body as new reader of the same content
|
||||||
|
r.Body = io.NopCloser(strings.NewReader(string(body)))
|
||||||
|
nextHandler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
237
server/middlewares.go
Normal file
237
server/middlewares.go
Normal file
|
@ -0,0 +1,237 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
httputil "git.mstar.dev/mstar/goutils/http"
|
||||||
|
"github.com/rs/zerolog/hlog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/config"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HandlerBuilder func(http.Handler) http.Handler
|
||||||
|
|
||||||
|
func ChainMiddlewares(base http.Handler, links ...HandlerBuilder) http.Handler {
|
||||||
|
slices.Reverse(links)
|
||||||
|
for _, f := range links {
|
||||||
|
base = f(base)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func ContextValsMiddleware(pairs map[any]any) HandlerBuilder {
|
||||||
|
return func(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
for key, val := range pairs {
|
||||||
|
ctx = context.WithValue(ctx, key, val)
|
||||||
|
}
|
||||||
|
newRequest := r.WithContext(ctx)
|
||||||
|
h.ServeHTTP(w, newRequest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoggingMiddleware(handler http.Handler) http.Handler {
|
||||||
|
return ChainMiddlewares(handler,
|
||||||
|
hlog.NewHandler(log.Logger),
|
||||||
|
hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/assets") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hlog.FromRequest(r).Info().
|
||||||
|
Str("method", r.Method).
|
||||||
|
Stringer("url", r.URL).
|
||||||
|
Int("status", status).
|
||||||
|
Int("size", size).
|
||||||
|
Dur("duration", duration).
|
||||||
|
Send()
|
||||||
|
}),
|
||||||
|
hlog.RemoteAddrHandler("ip"),
|
||||||
|
hlog.UserAgentHandler("user_agent"),
|
||||||
|
hlog.RefererHandler("referer"),
|
||||||
|
hlog.RequestIDHandler("req_id", "Request-Id"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func passkeyIdToAccountIdTransformerMiddleware(handler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := StorageFromRequest(r)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
passkeyId, ok := r.Context().Value(ContextKeyPasskeyUsername).(string)
|
||||||
|
if !ok {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdMissingContextValue,
|
||||||
|
"Actor name missing",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug().Bytes("passkey-bytes", []byte(passkeyId)).Msg("Id from passkey auth")
|
||||||
|
acc, err := s.FindAccountByPasskeyId([]byte(passkeyId))
|
||||||
|
if err != nil {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get account from storage",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), ContextKeyActorId, acc.ID))
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func profilingAuthenticationMiddleware(handler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.FormValue("password") != config.GlobalConfig.Admin.ProfilingPassword {
|
||||||
|
httputil.HttpErr(w, HttpErrIdNotAuthenticated, "Bad password", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware for inserting a logged in account's id into the request context if a session exists
|
||||||
|
// Does not cancel requests ever. If an error occurs, it's treated as if no session is set
|
||||||
|
func checkSessionMiddleware(handler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("sid")
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
// No cookie is ok, this function is only for inserting account id into the context
|
||||||
|
// if one exists, not for checking permissions
|
||||||
|
log.Debug().Msg("No session cookie, passing along")
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
session, ok := store.GetSession(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
// Failed to get session from cookie id. Log, then move on as if no session is set
|
||||||
|
log.Warn().
|
||||||
|
Str("session-id", cookie.Value).
|
||||||
|
Msg("Cookie with session id found, but session doesn't exist")
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.Expires.Before(time.Now()) {
|
||||||
|
// Session expired. Move on as if no session was set
|
||||||
|
store.DeleteSession(cookie.Value)
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acc, err := store.FindAccountByPasskeyId(session.UserID)
|
||||||
|
switch err {
|
||||||
|
case storage.ErrEntryNotFound:
|
||||||
|
log.Info().Msg("No account for the given passkey id found. It probably got deleted")
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
case nil:
|
||||||
|
default:
|
||||||
|
// Failed to get account for passkey id. Log, then move on as if no session is set
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Bytes("passkey-id", session.UserID).
|
||||||
|
Msg("Failed to get account with passkey id while checking session. Ignoring session")
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler.ServeHTTP(
|
||||||
|
w,
|
||||||
|
r.WithContext(
|
||||||
|
context.WithValue(
|
||||||
|
r.Context(),
|
||||||
|
ContextKeyActorId,
|
||||||
|
acc.ID,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireValidSessionMiddleware(
|
||||||
|
h func(http.ResponseWriter, *http.Request),
|
||||||
|
) func(http.ResponseWriter, *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, ok := r.Context().Value(ContextKeyActorId).(string)
|
||||||
|
if !ok {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdNotAuthenticated,
|
||||||
|
"Not authenticated",
|
||||||
|
http.StatusUnauthorized,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRequirePermissionsMiddleware(permissionRole *storage.Role) HandlerBuilder {
|
||||||
|
return func(h http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accId, ok := r.Context().Value(ContextKeyActorId).(string)
|
||||||
|
if !ok {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdNotAuthenticated,
|
||||||
|
"Not authenticated",
|
||||||
|
http.StatusUnauthorized,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store := StorageFromRequest(r)
|
||||||
|
log := hlog.FromRequest(r)
|
||||||
|
acc, err := store.FindAccountById(accId)
|
||||||
|
// Assumption: If this handler is hit, the middleware for checking if a session exists at all has already passed
|
||||||
|
// and thus a valid account id must exist in the context
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("account-id", accId).
|
||||||
|
Msg("Error while getting account from session")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Error while getting account from session",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roles, err := store.FindRolesByNames(acc.Roles)
|
||||||
|
// Assumption: There will always be at least two roles per user, default user and user-specific one
|
||||||
|
if err != nil {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdDbFailure,
|
||||||
|
"Failed to get roles for account",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collapsedRole := storage.CollapseRolesIntoOne(roles...)
|
||||||
|
if !storage.CompareRoles(&collapsedRole, permissionRole) {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdNotAuthenticated,
|
||||||
|
"Insufficient permisions",
|
||||||
|
http.StatusForbidden,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
13
server/remoteServer/remoteServer.go
Normal file
13
server/remoteServer/remoteServer.go
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
package remotestorage
|
||||||
|
|
||||||
|
import "git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
|
||||||
|
// Wrapper around db storage
|
||||||
|
// storage.Storage is for the db and cache access only,
|
||||||
|
// while this one wraps storage.Storage to also provide remote fetching of missing resources.
|
||||||
|
// So if an account doesn't exist in db or cache, this wrapper will attempt to fetch it
|
||||||
|
type RemoteStorage struct {
|
||||||
|
store *storage.Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement just about everything storage has, but with remote fetching if storage fails
|
1
server/routerLinstromFe.go
Normal file
1
server/routerLinstromFe.go
Normal file
|
@ -0,0 +1 @@
|
||||||
|
package server
|
98
server/server.go
Normal file
98
server/server.go
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httputil "git.mstar.dev/mstar/goutils/http"
|
||||||
|
"github.com/mstarongithub/passkey"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
store *storage.Storage
|
||||||
|
router http.Handler
|
||||||
|
server *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(
|
||||||
|
store *storage.Storage,
|
||||||
|
pkey *passkey.Passkey,
|
||||||
|
reactiveFS, staticFS fs.FS,
|
||||||
|
placeholderFile *string,
|
||||||
|
) *Server {
|
||||||
|
handler := buildRootHandler(pkey, reactiveFS, staticFS, placeholderFile)
|
||||||
|
handler = ChainMiddlewares(handler, LoggingMiddleware, ContextValsMiddleware(map[any]any{
|
||||||
|
ContextKeyStorage: store,
|
||||||
|
}))
|
||||||
|
|
||||||
|
server := http.Server{
|
||||||
|
Handler: handler,
|
||||||
|
}
|
||||||
|
return &Server{
|
||||||
|
store: store,
|
||||||
|
router: handler,
|
||||||
|
server: &server,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRootHandler(
|
||||||
|
pkey *passkey.Passkey,
|
||||||
|
reactiveFS, staticFS fs.FS,
|
||||||
|
placeholderFile *string,
|
||||||
|
) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle(
|
||||||
|
"/webauthn/",
|
||||||
|
http.StripPrefix(
|
||||||
|
"/webauthn",
|
||||||
|
forceCorrectPasskeyAuthFlowMiddleware(buildPasskeyAuthRouter(pkey)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mux.Handle("/", setupFrontendRouter(reactiveFS, staticFS))
|
||||||
|
mux.Handle("/pk/", http.StripPrefix("/pk", http.FileServer(http.Dir("pk-auth"))))
|
||||||
|
mux.HandleFunc("/alive", isAliveHandler)
|
||||||
|
mux.Handle("/api/", http.StripPrefix("/api", checkSessionMiddleware(setupApiRouter())))
|
||||||
|
|
||||||
|
mux.Handle(
|
||||||
|
"/profiling/",
|
||||||
|
http.StripPrefix("/profiling", profilingAuthenticationMiddleware(setupProfilingHandler())),
|
||||||
|
)
|
||||||
|
// temporary until proper route structure exists
|
||||||
|
mux.Handle(
|
||||||
|
"/authonly/",
|
||||||
|
pkey.Auth(
|
||||||
|
ContextKeyPasskeyUsername,
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdNotAuthenticated,
|
||||||
|
"Not authenticated",
|
||||||
|
http.StatusUnauthorized,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)(ChainMiddlewares(setupTestEndpoints(), passkeyIdToAccountIdTransformerMiddleware)),
|
||||||
|
)
|
||||||
|
|
||||||
|
mux.HandleFunc("/placeholder-file", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, *placeholderFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPasskeyAuthRouter(pkey *passkey.Passkey) http.Handler {
|
||||||
|
router := http.NewServeMux()
|
||||||
|
pkey.MountRoutes(router, "/")
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Start(addr string) error {
|
||||||
|
log.Info().Str("addr", addr).Msg("Starting server")
|
||||||
|
s.server.Addr = addr
|
||||||
|
return s.server.ListenAndServe()
|
||||||
|
}
|
16
server/testingEndpoints.go
Normal file
16
server/testingEndpoints.go
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestEndpoints() http.Handler {
|
||||||
|
router := http.NewServeMux()
|
||||||
|
router.HandleFunc(
|
||||||
|
"/",
|
||||||
|
func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "test root") },
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
55
server/utils.go
Normal file
55
server/utils.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httputil "git.mstar.dev/mstar/goutils/http"
|
||||||
|
"github.com/rs/zerolog/hlog"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func placeholderEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hlog.FromRequest(r).Error().Stringer("url", r.URL).Msg("Placeholder endpoint accessed")
|
||||||
|
httputil.HttpErr(
|
||||||
|
w,
|
||||||
|
HttpErrIdPlaceholder,
|
||||||
|
"Endpoint not implemented yet, this is a placeholder",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StorageFromRequest(r *http.Request) *storage.Storage {
|
||||||
|
store, ok := r.Context().Value(ContextKeyStorage).(*storage.Storage)
|
||||||
|
if !ok {
|
||||||
|
hlog.FromRequest(r).Fatal().Msg("Failed to get storage reference from context")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func ActorIdFromRequest(r *http.Request) (string, bool) {
|
||||||
|
id, ok := r.Context().Value(ContextKeyActorId).(string)
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func NoteIdFromRequest(r *http.Request) string {
|
||||||
|
return r.PathValue("noteId")
|
||||||
|
}
|
||||||
|
|
||||||
|
func AccountIdFromRequest(r *http.Request) string {
|
||||||
|
return r.PathValue("accountId")
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckIfAccountIdHasPermissions(accId string, perms storage.Role, store *storage.Storage) bool {
|
||||||
|
acc, err := store.FindAccountById(accId)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
roles, err := store.FindRolesByNames(acc.Roles)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
collapsed := storage.CollapseRolesIntoOne(roles...)
|
||||||
|
return storage.CompareRoles(&collapsed, &perms)
|
||||||
|
}
|
|
@ -8,9 +8,6 @@ const (
|
||||||
// where multiple releases in a day are required
|
// where multiple releases in a day are required
|
||||||
Version = "0.0.1 pre-alpha"
|
Version = "0.0.1 pre-alpha"
|
||||||
// Username for the server actor
|
// Username for the server actor
|
||||||
ServerActorName = "server.actor"
|
ServerActorName = "server.actor"
|
||||||
// Username for the placeholder actor where the actual actor is unknown
|
|
||||||
// Examples include likes, boosts and followers / following
|
|
||||||
UnknownActorName = "unknown.actor"
|
|
||||||
FeedUsernameSuffix = "-feed"
|
FeedUsernameSuffix = "-feed"
|
||||||
)
|
)
|
||||||
|
|
|
@ -19,6 +19,7 @@ var (
|
||||||
false,
|
false,
|
||||||
"If set, the server will only validate the config (or write the default one) and then quit",
|
"If set, the server will only validate the config (or write the default one) and then quit",
|
||||||
)
|
)
|
||||||
|
FlagStartNew *bool = flag.Bool("new", false, "Start the new system")
|
||||||
FlagStartDebugServer *bool = flag.Bool(
|
FlagStartDebugServer *bool = flag.Bool(
|
||||||
"debugserver",
|
"debugserver",
|
||||||
false,
|
false,
|
||||||
|
|
|
@ -1,74 +0,0 @@
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"git.mstar.dev/mstar/goutils/other"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"git.mstar.dev/mstar/linstrom/shared"
|
|
||||||
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
|
|
||||||
"git.mstar.dev/mstar/linstrom/storage-new/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
var UnknownActorId string
|
|
||||||
|
|
||||||
func InsertUnknownActorPlaceholder() error {
|
|
||||||
dbUser, err := dbgen.User.GetByUsername(shared.UnknownActorName)
|
|
||||||
if err == nil {
|
|
||||||
UnknownActorId = dbUser.ID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != gorm.ErrRecordNotFound {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
server, err := dbgen.RemoteServer.Where(dbgen.RemoteServer.IsSelf.Is(true)).First()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
publicEdKeyBytes, privateEdKeyBytes, err := shared.GenerateKeypair(true)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
publicRsaKeyBytes, privateRsaKeyBytes, err := shared.GenerateKeypair(false)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pkeyId := make([]byte, 64)
|
|
||||||
_, err = rand.Read(pkeyId)
|
|
||||||
if err != nil {
|
|
||||||
return other.Error(
|
|
||||||
"storage",
|
|
||||||
"failed to generate passkey ID for linstrom account",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
user := models.User{
|
|
||||||
ID: shared.NewId(),
|
|
||||||
Username: shared.UnknownActorName,
|
|
||||||
Server: *server,
|
|
||||||
ServerId: server.ID,
|
|
||||||
DisplayName: "Placeholder user",
|
|
||||||
Description: "Placeholder user for when the actual users are unknown, like with Mastodon likes and boosts",
|
|
||||||
IsBot: true,
|
|
||||||
IconId: sql.NullString{Valid: false},
|
|
||||||
Background: nil,
|
|
||||||
BackgroundId: sql.NullString{Valid: false},
|
|
||||||
Banner: nil,
|
|
||||||
BannerId: sql.NullString{Valid: false},
|
|
||||||
Indexable: false,
|
|
||||||
PublicKeyEd: publicEdKeyBytes,
|
|
||||||
PrivateKeyEd: privateEdKeyBytes,
|
|
||||||
PublicKeyRsa: publicRsaKeyBytes,
|
|
||||||
PrivateKeyRsa: privateRsaKeyBytes,
|
|
||||||
Verified: true,
|
|
||||||
FinishedRegistration: true,
|
|
||||||
PasskeyId: pkeyId,
|
|
||||||
}
|
|
||||||
err = dbgen.User.Save(&user)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
44
storage/accessTokens.go
Normal file
44
storage/accessTokens.go
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccessToken struct {
|
||||||
|
gorm.Model
|
||||||
|
BelongsToUserId string
|
||||||
|
Name string
|
||||||
|
Token string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetTokensForAccId(accId uint) ([]AccessToken, error) {
|
||||||
|
// TODO: Implement me
|
||||||
|
panic("Not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) NewAccessToken(
|
||||||
|
forAccId uint,
|
||||||
|
name string,
|
||||||
|
expiresAt time.Time,
|
||||||
|
) (*AccessToken, error) {
|
||||||
|
// TODO: Implement me
|
||||||
|
panic("Not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) ExtendToken(accId uint, newExpiry time.Time) error {
|
||||||
|
// TODO: Implement me
|
||||||
|
panic("Not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) RenameToken(accId, oldName string, newName string) error {
|
||||||
|
// TODO: Implement me
|
||||||
|
panic("Not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DiscardToken(accId uint, name string) error {
|
||||||
|
// TODO: Implement me
|
||||||
|
panic("Not implemented")
|
||||||
|
}
|
25
storage/accountRelations.go
Normal file
25
storage/accountRelations.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountRelation struct {
|
||||||
|
gorm.Model
|
||||||
|
FromId string
|
||||||
|
ToId string
|
||||||
|
Accepted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetRelationBetween(fromId, toId string) (*AccountRelation, error) {
|
||||||
|
rel := AccountRelation{}
|
||||||
|
err := s.db.Where(AccountRelation{FromId: fromId, ToId: toId}).First(&rel).Error
|
||||||
|
switch err {
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
case nil:
|
||||||
|
return &rel, nil
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
121
storage/cache.go
Normal file
121
storage/cache.go
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// various prefixes for accessing items in the cache (since it's a simple key-value store)
|
||||||
|
const (
|
||||||
|
cacheUserHandleToIdPrefix = "acc-name-to-id:"
|
||||||
|
cacheLocalUsernameToIdPrefix = "acc-local-name-to-id:"
|
||||||
|
cachePasskeyIdToAccIdPrefix = "acc-pkey-id-to-id:"
|
||||||
|
cacheUserIdToAccPrefix = "acc-id-to-data:"
|
||||||
|
cacheNoteIdToNotePrefix = "note-id-to-data:"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An error describing the case where some value was just not found in the cache
|
||||||
|
var errCacheNotFound = errors.New("not found in cache")
|
||||||
|
|
||||||
|
// Find an account id in cache using a given user handle ("@bob@example.com" or "bob@example.com")
|
||||||
|
// accId contains the Id of the account if found
|
||||||
|
// err contains an error describing why an account's id couldn't be found
|
||||||
|
// The most common one should be errCacheNotFound
|
||||||
|
func (s *Storage) cacheHandleToAccUid(handle string) (accId *string, err error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
// Where to put the data (in case it's found)
|
||||||
|
var target string
|
||||||
|
found, err := s.cache.Get(cacheUserHandleToIdPrefix+strings.TrimLeft(handle, "@"), &target)
|
||||||
|
// If nothing was found, check error
|
||||||
|
if !found {
|
||||||
|
// Case error is set and NOT redis' error for nothing found: Return that error
|
||||||
|
if err != nil && !errors.Is(err, redis.Nil) {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
// Else return errCacheNotFound
|
||||||
|
return nil, errCacheNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a local account's id in cache using a given username ("bob")
|
||||||
|
// accId containst the Id of the account if found
|
||||||
|
// err contains an error describing why an account's id couldn't be found
|
||||||
|
// The most common one should be errCacheNotFound
|
||||||
|
func (s *Storage) cacheLocalUsernameToAccUid(username string) (accId *string, err error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
// Where to put the data (in case it's found)
|
||||||
|
var target string
|
||||||
|
found, err := s.cache.Get(cacheLocalUsernameToIdPrefix+username, &target)
|
||||||
|
// If nothing was found, check error
|
||||||
|
if !found {
|
||||||
|
// Case error is set and NOT redis' error for nothing found: Return that error
|
||||||
|
if err != nil && !errors.Is(err, redis.Nil) {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
// Else return errCacheNotFound
|
||||||
|
return nil, errCacheNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) cachePkeyIdToAccId(pkeyId []byte) (accId *string, err error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
// Where to put the data (in case it's found)
|
||||||
|
var target string
|
||||||
|
found, err := s.cache.Get(cachePasskeyIdToAccIdPrefix+string(pkeyId), &target)
|
||||||
|
// If nothing was found, check error
|
||||||
|
if !found {
|
||||||
|
// Case error is set and NOT redis' error for nothing found: Return that error
|
||||||
|
if err != nil && !errors.Is(err, redis.Nil) {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
// Else return errCacheNotFound
|
||||||
|
return nil, errCacheNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find an account's data in cache using a given account id
|
||||||
|
// acc contains the full account as stored last time if found
|
||||||
|
// err contains an error describing why an account couldn't be found
|
||||||
|
// The most common one should be errCacheNotFound
|
||||||
|
func (s *Storage) cacheAccIdToData(id string) (acc *Account, err error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
var target Account
|
||||||
|
found, err := s.cache.Get(cacheUserIdToAccPrefix+id, &target)
|
||||||
|
if !found {
|
||||||
|
if err != nil && !errors.Is(err, redis.Nil) {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
return nil, errCacheNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a cached note given its ID
|
||||||
|
// note contains the full note as stored last time if found
|
||||||
|
// err contains an error describing why a note couldn't be found
|
||||||
|
// The most common one should be errCacheNotFound
|
||||||
|
func (s *Storage) cacheNoteIdToData(id string) (note *Note, err error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
target := Note{}
|
||||||
|
found, err := s.cache.Get(cacheNoteIdToNotePrefix+id, &target)
|
||||||
|
if !found {
|
||||||
|
if err != nil && !errors.Is(err, redis.Nil) {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
return nil, errCacheNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &target, nil
|
||||||
|
}
|
105
storage/cache/cache.go
vendored
Normal file
105
storage/cache/cache.go
vendored
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dgraph-io/ristretto"
|
||||||
|
"github.com/eko/gocache/lib/v4/cache"
|
||||||
|
"github.com/eko/gocache/lib/v4/store"
|
||||||
|
redis_store "github.com/eko/gocache/store/redis/v4"
|
||||||
|
ristretto_store "github.com/eko/gocache/store/ristretto/v4"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/config"
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
cache *cache.ChainCache[[]byte]
|
||||||
|
decoders *DecoderPool
|
||||||
|
encoders *EncoderPool
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctxBackground = context.Background()
|
||||||
|
|
||||||
|
// TODO: Maybe also include metrics
|
||||||
|
func NewCache(maxSize int64, redisUrl *string) (*Cache, error) {
|
||||||
|
// ristretto is an in-memory cache
|
||||||
|
log.Debug().Int64("max-size", maxSize).Msg("Setting up ristretto")
|
||||||
|
ristrettoCache, err := ristretto.NewCache(&ristretto.Config{
|
||||||
|
// The *10 is a recommendation from ristretto
|
||||||
|
NumCounters: maxSize * 10,
|
||||||
|
MaxCost: maxSize,
|
||||||
|
BufferItems: 64, // Same here
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ristretto cache error: %w", err)
|
||||||
|
}
|
||||||
|
ristrettoStore := ristretto_store.NewRistretto(
|
||||||
|
ristrettoCache,
|
||||||
|
store.WithExpiration(
|
||||||
|
time.Second*time.Duration(config.GlobalConfig.Storage.MaxInMemoryCacheSize),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
var cacheManager *cache.ChainCache[[]byte]
|
||||||
|
if redisUrl != nil {
|
||||||
|
opts, err := redis.ParseURL(*redisUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
redisClient := redis.NewClient(opts)
|
||||||
|
redisStore := redis_store.NewRedis(
|
||||||
|
redisClient,
|
||||||
|
store.WithExpiration(
|
||||||
|
time.Second*time.Duration(*config.GlobalConfig.Storage.MaxRedisCacheTTL),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cacheManager = cache.NewChain(
|
||||||
|
cache.New[[]byte](ristrettoStore),
|
||||||
|
cache.New[[]byte](redisStore),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cacheManager = cache.NewChain(cache.New[[]byte](ristrettoStore))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Cache{
|
||||||
|
cache: cacheManager,
|
||||||
|
decoders: NewDecoderPool(),
|
||||||
|
encoders: NewEncoderPool(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Get(key string, target any) (bool, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return false, nil
|
||||||
|
data, err := c.cache.Get(ctxBackground, key)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
err = c.decoders.Decode(data, target)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Set(key string, value any) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return nil
|
||||||
|
data, err := c.encoders.Encode(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = c.cache.Set(ctxBackground, key, data, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Delete(key string) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
// Error return doesn't matter here. Delete is delete is gone
|
||||||
|
_ = c.cache.Delete(ctxBackground, key)
|
||||||
|
}
|
164
storage/cache/coderPools.go
vendored
Normal file
164
storage/cache/coderPools.go
vendored
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EncoderPool struct {
|
||||||
|
encoders []*gobEncoder
|
||||||
|
lock sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEncoderPool() *EncoderPool {
|
||||||
|
return &EncoderPool{
|
||||||
|
encoders: []*gobEncoder{},
|
||||||
|
lock: sync.RWMutex{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode some value with gob
|
||||||
|
func (p *EncoderPool) Encode(raw any) ([]byte, error) {
|
||||||
|
var encoder *gobEncoder
|
||||||
|
// First try to find an available encoder
|
||||||
|
// Read only lock should be fine here since locks are atomic i
|
||||||
|
//and thus no two goroutines should be able to lock the same encoder at the same time
|
||||||
|
// One of those attempts is going to fail and continue looking for another available one
|
||||||
|
p.lock.RLock()
|
||||||
|
for _, v := range p.encoders {
|
||||||
|
// If we can lock one, it's available
|
||||||
|
if v.TryLock() {
|
||||||
|
// Keep the reference, then break
|
||||||
|
encoder = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.lock.RUnlock()
|
||||||
|
// Didn't find an available encoder, create new one and add to pool
|
||||||
|
if encoder == nil {
|
||||||
|
encoder = p.expand()
|
||||||
|
}
|
||||||
|
// Ensure we free the encoder at the end
|
||||||
|
defer encoder.Unlock()
|
||||||
|
// Clear the buffer to avoid funky output from previous operations
|
||||||
|
encoder.Buffer.Reset()
|
||||||
|
if err := encoder.Encoder.Encode(raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(encoder.Buffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expands the pool of available encoders by one and returns a reference to the new one
|
||||||
|
// The new encoder is already locked and ready for use
|
||||||
|
func (p *EncoderPool) expand() *gobEncoder {
|
||||||
|
enc := newEncoder()
|
||||||
|
// Lock everything. First the pool fully since we need to overwrite the encoders slice
|
||||||
|
p.lock.Lock()
|
||||||
|
// And then the new encoder to make it available for use by the caller
|
||||||
|
// so that they don't have to search for it again
|
||||||
|
enc.Lock()
|
||||||
|
p.encoders = append(p.encoders, &enc)
|
||||||
|
p.lock.Unlock()
|
||||||
|
return &enc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune all encoders not currently used from the pool
|
||||||
|
func (p *EncoderPool) Prune() {
|
||||||
|
stillActiveEncoders := []*gobEncoder{}
|
||||||
|
p.lock.Lock()
|
||||||
|
for _, v := range p.encoders {
|
||||||
|
if !v.TryLock() {
|
||||||
|
// Can't lock, encoder in use, keep it
|
||||||
|
stillActiveEncoders = append(stillActiveEncoders, v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// If we reach here, the encoder was available (since not locked), unlock and continue
|
||||||
|
v.Unlock()
|
||||||
|
}
|
||||||
|
// Overwrite list of available encoders to only contain the ones we found to still be active
|
||||||
|
p.encoders = stillActiveEncoders
|
||||||
|
p.lock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecoderPool struct {
|
||||||
|
encoders []*gobDecoder
|
||||||
|
lock sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDecoderPool() *DecoderPool {
|
||||||
|
return &DecoderPool{
|
||||||
|
encoders: []*gobDecoder{},
|
||||||
|
lock: sync.RWMutex{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode some value with gob
|
||||||
|
func (p *DecoderPool) Decode(raw []byte, target any) error {
|
||||||
|
var encoder *gobDecoder
|
||||||
|
// First try to find an available encoder
|
||||||
|
// Read only lock should be fine here since locks are atomic i
|
||||||
|
//and thus no two goroutines should be able to lock the same encoder at the same time
|
||||||
|
// One of those attempts is going to fail and continue looking for another available one
|
||||||
|
p.lock.RLock()
|
||||||
|
for _, v := range p.encoders {
|
||||||
|
// If we can lock one, it's available
|
||||||
|
if v.TryLock() {
|
||||||
|
// Keep the reference, then break
|
||||||
|
encoder = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.lock.RUnlock()
|
||||||
|
// Didn't find an available encoder, create new one and add to pool
|
||||||
|
if encoder == nil {
|
||||||
|
encoder = p.expand()
|
||||||
|
}
|
||||||
|
// Desure we free the encoder at the end
|
||||||
|
defer encoder.Unlock()
|
||||||
|
// Clear the buffer to avoid funky output from previous operations
|
||||||
|
encoder.Buffer.Reset()
|
||||||
|
// Write the raw data to the buffer, then decode it
|
||||||
|
// The write will always succeed (or panic)
|
||||||
|
_, _ = encoder.Buffer.Write(raw)
|
||||||
|
err := encoder.Decoder.Decode(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expands the pool of available encoders by one and returns a reference to the new one
|
||||||
|
// The new encoder is already locked and ready for use
|
||||||
|
func (p *DecoderPool) expand() *gobDecoder {
|
||||||
|
enc := newDecoder()
|
||||||
|
// Lock everything. First the pool fully since we need to overwrite the encoders slice
|
||||||
|
p.lock.Lock()
|
||||||
|
// And then the new encoder to make it available for use by the caller
|
||||||
|
// so that they don't have to search for it again
|
||||||
|
enc.Lock()
|
||||||
|
p.encoders = append(p.encoders, &enc)
|
||||||
|
p.lock.Unlock()
|
||||||
|
return &enc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune all encoders not currently used from the pool
|
||||||
|
func (p *DecoderPool) Prune() {
|
||||||
|
stillActiveDecoders := []*gobDecoder{}
|
||||||
|
p.lock.Lock()
|
||||||
|
for _, v := range p.encoders {
|
||||||
|
if !v.TryLock() {
|
||||||
|
// Can't lock, encoder in use, keep it
|
||||||
|
stillActiveDecoders = append(stillActiveDecoders, v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// If we reach here, the encoder was available (since not locked), unlock and continue
|
||||||
|
v.Unlock()
|
||||||
|
}
|
||||||
|
// Overwrite list of available encoders to only contain the ones we found to still be active
|
||||||
|
p.encoders = stillActiveDecoders
|
||||||
|
p.lock.Unlock()
|
||||||
|
}
|
35
storage/cache/lockedCoders.go
vendored
Normal file
35
storage/cache/lockedCoders.go
vendored
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/gob"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gobEncoder struct {
|
||||||
|
sync.Mutex
|
||||||
|
Encoder *gob.Encoder
|
||||||
|
Buffer *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEncoder() gobEncoder {
|
||||||
|
buf := bytes.Buffer{}
|
||||||
|
return gobEncoder{
|
||||||
|
Encoder: gob.NewEncoder(&buf),
|
||||||
|
Buffer: &buf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type gobDecoder struct {
|
||||||
|
sync.Mutex
|
||||||
|
Decoder *gob.Decoder
|
||||||
|
Buffer *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDecoder() gobDecoder {
|
||||||
|
buf := bytes.Buffer{}
|
||||||
|
return gobDecoder{
|
||||||
|
Decoder: gob.NewDecoder(&buf),
|
||||||
|
Buffer: &buf,
|
||||||
|
}
|
||||||
|
}
|
25
storage/emote.go
Normal file
25
storage/emote.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
type Emote struct {
|
||||||
|
gorm.Model
|
||||||
|
// Metadata MediaMetadata // `gorm:"foreignKey:MetadataId"`
|
||||||
|
MetadataId string
|
||||||
|
Name string
|
||||||
|
// Server RemoteServer // `gorm:"foreignKey:ServerId;references:ID"`
|
||||||
|
ServerId uint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetEmoteById(id uint) (*Emote, error) {
|
||||||
|
out := Emote{}
|
||||||
|
err := s.db.First(&out, id).Error
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return &out, nil
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
15
storage/errors.go
Normal file
15
storage/errors.go
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
type ErrNotImplemented struct{}
|
||||||
|
|
||||||
|
func (n ErrNotImplemented) Error() string {
|
||||||
|
return "Not implemented yet"
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrEntryNotFound = errors.New("entry not found")
|
||||||
|
var ErrEntryAlreadyExists = errors.New("entry already exists")
|
||||||
|
var ErrNothingToChange = errors.New("nothing to change")
|
||||||
|
var ErrInvalidData = errors.New("invalid data")
|
||||||
|
var ErrNotAllowed = errors.New("action not allowed")
|
64
storage/gormLogger.go
Normal file
64
storage/gormLogger.go
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gormLogger struct {
|
||||||
|
logger zerolog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGormLogger(zerologger zerolog.Logger) *gormLogger {
|
||||||
|
return &gormLogger{zerologger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gormLogger) LogMode(newLevel logger.LogLevel) logger.Interface {
|
||||||
|
switch newLevel {
|
||||||
|
case logger.Error:
|
||||||
|
g.logger = g.logger.Level(zerolog.ErrorLevel)
|
||||||
|
case logger.Warn:
|
||||||
|
g.logger = g.logger.Level(zerolog.WarnLevel)
|
||||||
|
case logger.Info:
|
||||||
|
g.logger = g.logger.Level(zerolog.InfoLevel)
|
||||||
|
case logger.Silent:
|
||||||
|
g.logger = g.logger.Level(zerolog.Disabled)
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
func (g *gormLogger) Info(ctx context.Context, format string, args ...interface{}) {
|
||||||
|
g.logger.Info().Ctx(ctx).Msgf(format, args...)
|
||||||
|
}
|
||||||
|
func (g *gormLogger) Warn(ctx context.Context, format string, args ...interface{}) {
|
||||||
|
g.logger.Warn().Ctx(ctx).Msgf(format, args...)
|
||||||
|
}
|
||||||
|
func (g *gormLogger) Error(ctx context.Context, format string, args ...interface{}) {
|
||||||
|
g.logger.Error().Ctx(ctx).Msgf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gormLogger) Trace(
|
||||||
|
ctx context.Context,
|
||||||
|
begin time.Time,
|
||||||
|
fc func() (sql string, rowsAffected int64),
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
sql, rowsAffected := fc()
|
||||||
|
g.logger.Trace().
|
||||||
|
Ctx(ctx).
|
||||||
|
Time("gorm-begin", begin).
|
||||||
|
Err(err).
|
||||||
|
Str("gorm-query", sql).
|
||||||
|
Int64("gorm-rows-affected", rowsAffected).
|
||||||
|
Send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gormLogger) OverwriteLoggingLevel(new zerolog.Level) {
|
||||||
|
g.logger = g.logger.Level(new)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gormLogger) OverwriteLogger(new zerolog.Logger) {
|
||||||
|
g.logger = new
|
||||||
|
}
|
11
storage/housekeeping.go
Normal file
11
storage/housekeeping.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
// Contains various functions for housekeeping
|
||||||
|
// Things like true deletion of soft deleted data after some time
|
||||||
|
// Or removing inactive access tokens
|
||||||
|
// All of this will be handled by goroutines
|
||||||
|
|
||||||
|
// TODO: Delete everything soft deleted and older than a month
|
||||||
|
// TODO: Delete old tokens not in active use anymore
|
||||||
|
// TODO: Start jobs where the last check-in was more than an hour ago
|
||||||
|
//
|
68
storage/inboundJobs.go
Normal file
68
storage/inboundJobs.go
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-generate string names for the various constants
|
||||||
|
//go:generate stringer -type InboundJobSource
|
||||||
|
|
||||||
|
type InboundJobSource int
|
||||||
|
|
||||||
|
// TODO: Adjust and expand these constants later, depending on sources
|
||||||
|
const (
|
||||||
|
InJobSourceAccInbox InboundJobSource = iota
|
||||||
|
InJobSourceServerInbox
|
||||||
|
InJobSourceApiMasto
|
||||||
|
InJobSourceApiLinstrom
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store inbound jobs from api and ap in the db until they finished processing
|
||||||
|
// Ensures data consistency in case the server is forced to restart unexpectedly
|
||||||
|
// Inbound jobs must allways be processed in order from oldest to newest to ensure consistency
|
||||||
|
type InboundJob struct {
|
||||||
|
gorm.Model
|
||||||
|
// Raw data, could be json or gob data, check source for how to interpret
|
||||||
|
RawData []byte `gorm:"->;<-create"`
|
||||||
|
// Where this job is coming from. Important for figuring out how to decode the raw data and what to do with it
|
||||||
|
Source InboundJobSource `gorm:"->;<-create"`
|
||||||
|
|
||||||
|
// Section: Various data
|
||||||
|
// TODO: Expand based on needs
|
||||||
|
|
||||||
|
// If from an inbox, include the owner id here
|
||||||
|
InboxOwner *string `gorm:"->;<-create"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) AddNewInboundJob(data []byte, source InboundJobSource, inboxOwner *string) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
newJob := InboundJob{
|
||||||
|
RawData: data,
|
||||||
|
Source: source,
|
||||||
|
InboxOwner: inboxOwner,
|
||||||
|
}
|
||||||
|
s.db.Create(&newJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the specified amount of jobs, sorted by age (oldest first)
|
||||||
|
func (s *Storage) GetOldestInboundJobs(amount uint) ([]InboundJob, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
jobs := []InboundJob{}
|
||||||
|
switch err := s.db.Order("id asc, created_at asc").Limit(int(amount)).Find(jobs).Error; err {
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
case nil:
|
||||||
|
return jobs, nil
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) CompleteInboundJob(id uint) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
s.db.Delete(InboundJob{Model: gorm.Model{ID: id}})
|
||||||
|
return nil
|
||||||
|
}
|
26
storage/inboundjobsource_string.go
Normal file
26
storage/inboundjobsource_string.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// Code generated by "stringer -type InboundJobSource"; DO NOT EDIT.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
func _() {
|
||||||
|
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||||
|
// Re-run the stringer command to generate them again.
|
||||||
|
var x [1]struct{}
|
||||||
|
_ = x[InJobSourceAccInbox-0]
|
||||||
|
_ = x[InJobSourceServerInbox-1]
|
||||||
|
_ = x[InJobSourceApiMasto-2]
|
||||||
|
_ = x[InJobSourceApiLinstrom-3]
|
||||||
|
}
|
||||||
|
|
||||||
|
const _InboundJobSource_name = "InJobSourceAccInboxInJobSourceServerInboxInJobSourceApiMastoInJobSourceApiLinstrom"
|
||||||
|
|
||||||
|
var _InboundJobSource_index = [...]uint8{0, 19, 41, 60, 82}
|
||||||
|
|
||||||
|
func (i InboundJobSource) String() string {
|
||||||
|
if i < 0 || i >= InboundJobSource(len(_InboundJobSource_index)-1) {
|
||||||
|
return "InboundJobSource(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||||
|
}
|
||||||
|
return _InboundJobSource_name[_InboundJobSource_index[i]:_InboundJobSource_index[i+1]]
|
||||||
|
}
|
95
storage/mediaFile.go
Normal file
95
storage/mediaFile.go
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaMetadata contains metadata about some media
|
||||||
|
// Metadata includes whether it's a remote file or not, what the name is,
|
||||||
|
// the MIME type, and an identifier pointing to its location
|
||||||
|
type MediaMetadata struct {
|
||||||
|
ID string `gorm:"primarykey"` // The unique ID of this media file
|
||||||
|
CreatedAt time.Time // When this entry was created
|
||||||
|
UpdatedAt time.Time // When this entry was last updated
|
||||||
|
// When this entry was deleted (for soft deletions)
|
||||||
|
// Soft delete means that this entry still exists in the db, but gorm won't include it anymore unless specifically told to
|
||||||
|
// If not null, this entry is marked as deleted
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
|
OwnedBy string // Account id this media belongs to
|
||||||
|
Remote bool // whether the attachment is a remote one
|
||||||
|
// Where the media is stored. Url
|
||||||
|
Location string
|
||||||
|
Type string // What media type this is following mime types, eg image/png
|
||||||
|
// Name of the file
|
||||||
|
// Could be <emote-name>.png, <server-name>.webp for example. Or the name the file was uploaded with
|
||||||
|
Name string
|
||||||
|
// Alternative description of the media file's content
|
||||||
|
AltText string
|
||||||
|
// Whether the media is to be blurred by default
|
||||||
|
Blurred bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) NewMediaMetadata(
|
||||||
|
ownerId, location, mediaType, name string,
|
||||||
|
) (*MediaMetadata, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
newMedia := MediaMetadata{
|
||||||
|
OwnedBy: ownerId,
|
||||||
|
Location: location,
|
||||||
|
Name: name,
|
||||||
|
Type: mediaType,
|
||||||
|
}
|
||||||
|
s.db.Create(&newMedia)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FuzzyFindMediaMetadataByName(name string) ([]MediaMetadata, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
notes := []MediaMetadata{}
|
||||||
|
err := s.db.Where("name LIKE %?%", name).Find(notes).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return notes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetMediaMetadataById(id string) (*MediaMetadata, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
media := MediaMetadata{ID: id}
|
||||||
|
err := s.db.First(&media).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &media, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FuzzyFindMediaMetadataByLocation(location string) ([]MediaMetadata, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
data := []MediaMetadata{}
|
||||||
|
if err := s.db.Where("location LIKE %?%", location).Find(data).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteMediaMetadataById(id string) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return s.db.Delete(MediaMetadata{ID: id}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteMediaMetadataByFuzzyLocation(location string) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
var tmp MediaMetadata
|
||||||
|
return s.db.Where("location LIKE %?%", location).Delete(&tmp).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteMediaMetadataByFuzzyName(name string) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
var tmp MediaMetadata
|
||||||
|
return s.db.Where("name LIKE %?%", name).Delete(&tmp).Error
|
||||||
|
}
|
91
storage/mediaProvider/preprocessor.go
Normal file
91
storage/mediaProvider/preprocessor.go
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
package mediaprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/gen2brain/avif"
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
"golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUnknownImageType = errors.New("unknown image format")
|
||||||
|
|
||||||
|
func Compress(dataReader io.Reader, mimeType *string) (io.Reader, error) {
|
||||||
|
// TODO: Get inspired by GTS and use wasm ffmpeg (https://codeberg.org/gruf/go-ffmpreg) for compression
|
||||||
|
data, err := io.ReadAll(dataReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mimeType == nil {
|
||||||
|
tmp := mimetype.Detect(data).String()
|
||||||
|
mimeType = &tmp
|
||||||
|
}
|
||||||
|
uberType, subType, _ := strings.Cut(*mimeType, "/")
|
||||||
|
var dataOut []byte
|
||||||
|
switch uberType {
|
||||||
|
case "text":
|
||||||
|
case "application":
|
||||||
|
case "image":
|
||||||
|
dataOut, err = compressImage(data, subType, 1280, 720)
|
||||||
|
case "video":
|
||||||
|
dataOut, err = compressVideo(data, subType)
|
||||||
|
case "audio":
|
||||||
|
case "font":
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if err != nil && err != ErrUnknownImageType {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dataOut = compressBase64(dataOut)
|
||||||
|
return bytes.NewReader(dataOut), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressVideo(dataIn []byte, subType string) (dataOut []byte, err error) {
|
||||||
|
// TODO: Implement me
|
||||||
|
panic("Implement me")
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressImage(
|
||||||
|
dataIn []byte,
|
||||||
|
subType string,
|
||||||
|
maxSizeX, maxSizeY uint,
|
||||||
|
) (dataOut []byte, err error) {
|
||||||
|
imageSize := image.Rect(0, 0, int(maxSizeX), int(maxSizeY))
|
||||||
|
dst := image.NewRGBA(imageSize)
|
||||||
|
var sourceImage image.Image
|
||||||
|
switch subType {
|
||||||
|
case "png":
|
||||||
|
sourceImage, err = png.Decode(bytes.NewReader(dataIn))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case "jpg", "jpeg":
|
||||||
|
sourceImage, err = jpeg.Decode(bytes.NewReader(dataIn))
|
||||||
|
case "webp":
|
||||||
|
sourceImage, err = webp.Decode(bytes.NewReader(dataIn))
|
||||||
|
case "avif":
|
||||||
|
sourceImage, err = avif.Decode(bytes.NewReader(dataIn))
|
||||||
|
default:
|
||||||
|
return nil, ErrUnknownImageType
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
draw.CatmullRom.Scale(dst, imageSize, sourceImage, sourceImage.Bounds(), draw.Src, nil)
|
||||||
|
return dst.Pix, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressBase64(dataIn []byte) []byte {
|
||||||
|
result := []byte{}
|
||||||
|
base64.StdEncoding.Encode(result, dataIn)
|
||||||
|
return result
|
||||||
|
}
|
59
storage/mediaProvider/provider.go
Normal file
59
storage/mediaProvider/provider.go
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
package mediaprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
"git.mstar.dev/mstar/linstrom/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
client *minio.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new storage provider using the values from the global config
|
||||||
|
// It also sets up the bucket in the s3 provider if it doesn't exist yet
|
||||||
|
func NewProviderFromConfig() (*Provider, error) {
|
||||||
|
client, err := minio.New(config.GlobalConfig.S3.Endpoint, &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4(
|
||||||
|
config.GlobalConfig.S3.KeyId,
|
||||||
|
config.GlobalConfig.S3.Secret,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
Region: config.GlobalConfig.S3.Region,
|
||||||
|
Secure: config.GlobalConfig.S3.UseSSL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = setupBucket(client, config.GlobalConfig.S3.BucketName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Provider{
|
||||||
|
client: client,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupBucket(client *minio.Client, bucketName string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
// Give it half a minute tops to check if a bucket with the given name already exists
|
||||||
|
existsContext, cancel := context.WithTimeout(ctx, time.Second*30)
|
||||||
|
defer cancel()
|
||||||
|
ok, err := client.BucketExists(existsContext, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Same timeout for creating the new bucket if it doesn't exist
|
||||||
|
createContext, createCancel := context.WithTimeout(ctx, time.Second*30)
|
||||||
|
defer createCancel()
|
||||||
|
err = client.MakeBucket(createContext, bucketName, minio.MakeBucketOptions{
|
||||||
|
Region: config.GlobalConfig.S3.Region,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
41
storage/noteTargets.go
Normal file
41
storage/noteTargets.go
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// For pretty printing during debug
|
||||||
|
// If `go generate` is run, it'll generate the necessary function and data for pretty printing
|
||||||
|
//go:generate stringer -type NoteAccessLevel
|
||||||
|
|
||||||
|
// What feed a note is targeting (public, home, followers or dm)
|
||||||
|
type NoteAccessLevel uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The note is intended for the public
|
||||||
|
NOTE_TARGET_PUBLIC NoteAccessLevel = 0
|
||||||
|
// The note is intended only for the home screen
|
||||||
|
// not really any idea what the difference is compared to public
|
||||||
|
// Maybe home notes don't show up on the server feed but still for everyone's home feed if it reaches them via follow or boost
|
||||||
|
NOTE_TARGET_HOME NoteAccessLevel = 1 << iota
|
||||||
|
// The note is intended only for followers
|
||||||
|
NOTE_TARGET_FOLLOWERS
|
||||||
|
// The note is intended only for a DM to one or more targets
|
||||||
|
NOTE_TARGET_DM
|
||||||
|
)
|
||||||
|
|
||||||
|
// Converts the NoteTarget value into a type the DB can use
|
||||||
|
func (n *NoteAccessLevel) Value() (driver.Value, error) {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts the raw value from the DB into a NoteTarget
|
||||||
|
func (n *NoteAccessLevel) Scan(value any) error {
|
||||||
|
vBig, ok := value.(int64)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("not an int64")
|
||||||
|
}
|
||||||
|
*n = NoteAccessLevel(vBig)
|
||||||
|
return nil
|
||||||
|
}
|
37
storage/noteaccesslevel_string.go
Normal file
37
storage/noteaccesslevel_string.go
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
// Code generated by "stringer -type NoteAccessLevel"; DO NOT EDIT.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
func _() {
|
||||||
|
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||||
|
// Re-run the stringer command to generate them again.
|
||||||
|
var x [1]struct{}
|
||||||
|
_ = x[NOTE_TARGET_PUBLIC-0]
|
||||||
|
_ = x[NOTE_TARGET_HOME-2]
|
||||||
|
_ = x[NOTE_TARGET_FOLLOWERS-4]
|
||||||
|
_ = x[NOTE_TARGET_DM-8]
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
_NoteAccessLevel_name_0 = "NOTE_TARGET_PUBLIC"
|
||||||
|
_NoteAccessLevel_name_1 = "NOTE_TARGET_HOME"
|
||||||
|
_NoteAccessLevel_name_2 = "NOTE_TARGET_FOLLOWERS"
|
||||||
|
_NoteAccessLevel_name_3 = "NOTE_TARGET_DM"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i NoteAccessLevel) String() string {
|
||||||
|
switch {
|
||||||
|
case i == 0:
|
||||||
|
return _NoteAccessLevel_name_0
|
||||||
|
case i == 2:
|
||||||
|
return _NoteAccessLevel_name_1
|
||||||
|
case i == 4:
|
||||||
|
return _NoteAccessLevel_name_2
|
||||||
|
case i == 8:
|
||||||
|
return _NoteAccessLevel_name_3
|
||||||
|
default:
|
||||||
|
return "NoteAccessLevel(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||||
|
}
|
||||||
|
}
|
164
storage/notes.go
Normal file
164
storage/notes.go
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note represents an ActivityPub note
|
||||||
|
// ActivityPub notes can be quite a few things, depending on fields provided.
|
||||||
|
// A survey, a reply, a quote of another note, etc
|
||||||
|
// And depending on the origin server of a note, they are treated differently
|
||||||
|
// with for example rendering or available actions
|
||||||
|
// This struct attempts to contain all information necessary for easily working with a note
|
||||||
|
type Note struct {
|
||||||
|
ID string `gorm:"primarykey"` // Make ID a string (uuid) for other implementations
|
||||||
|
CreatedAt time.Time // When this entry was created
|
||||||
|
UpdatedAt time.Time // When this entry was last updated
|
||||||
|
// When this entry was deleted (for soft deletions)
|
||||||
|
// Soft delete means that this entry still exists in the db, but gorm won't include it anymore unless specifically told to
|
||||||
|
// If not null, this entry is marked as deleted
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
|
// Creator Account // `gorm:"foreignKey:CreatorId;references:ID"` // Account that created the post
|
||||||
|
CreatorId string
|
||||||
|
Remote bool // Whether the note is originally a remote one and just "cached"
|
||||||
|
// Raw content of the note. So without additional formatting applied
|
||||||
|
// Might already have formatting applied beforehand from the origin server
|
||||||
|
RawContent string
|
||||||
|
ContentWarning *string // Content warnings of the note, if it contains any
|
||||||
|
Attachments []string `gorm:"serializer:json"` // List of Ids for mediaFiles
|
||||||
|
Emotes []string `gorm:"serializer:json"` // Emotes used in that message
|
||||||
|
RepliesTo *string // Url of the message this replies to
|
||||||
|
Quotes *string // url of the message this note quotes
|
||||||
|
AccessLevel NoteAccessLevel // Where to send this message to (public, home, followers, dm)
|
||||||
|
Pings []string `gorm:"serializer:json"` // Who is being tagged in this message. Also serves as DM targets
|
||||||
|
OriginServer string // Url of the origin server. Also the primary key for those
|
||||||
|
Tags []string `gorm:"serializer:json"` // Hashtags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindNoteById(id string) (*Note, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
note := &Note{}
|
||||||
|
cacheNote, err := s.cacheNoteIdToData(id)
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return cacheNote, nil
|
||||||
|
// Empty case, not found in cache means check db
|
||||||
|
case errCacheNotFound:
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch err {
|
||||||
|
|
||||||
|
}
|
||||||
|
err = s.db.Find(note, id).Error
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
if err = s.cache.Set(cacheNoteIdToNotePrefix+id, note); err != nil {
|
||||||
|
log.Warn().Err(err).Str("note-id", id).Msg("Failed to place note in cache")
|
||||||
|
}
|
||||||
|
return note, nil
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindNotesByFuzzyContent(fuzzyContent string) ([]Note, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
notes := []Note{}
|
||||||
|
// TODO: Figure out if cache can be used here too
|
||||||
|
err := s.db.Where("raw_content LIKE %?%", fuzzyContent).Find(notes).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return notes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindNotesByAuthorHandle(handle string) ([]Note, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
acc, err := s.FindAccountByFullHandle(handle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("account with handle %s not found: %w", handle, err)
|
||||||
|
}
|
||||||
|
return s.FindNotesByAuthorId(acc.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindNotesByAuthorId(id string) ([]Note, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
notes := []Note{}
|
||||||
|
err := s.db.Where("creator = ?", id).Find(notes).Error
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return notes, nil
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateNote(note *Note) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
if note == nil || note.ID == "" {
|
||||||
|
return ErrInvalidData
|
||||||
|
}
|
||||||
|
err := s.db.Save(note).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = s.cache.Set(cacheNoteIdToNotePrefix+note.ID, note)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().
|
||||||
|
Err(err).
|
||||||
|
Msg("Failed to update note into cache. Cache and db might be out of sync, a force sync is recommended")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) CreateNoteLocal(
|
||||||
|
creatorId string,
|
||||||
|
rawContent string,
|
||||||
|
contentWarning *string,
|
||||||
|
timestamp time.Time,
|
||||||
|
attachmentIds []string,
|
||||||
|
emoteIds []string,
|
||||||
|
repliesToId *string,
|
||||||
|
quotesId *string,
|
||||||
|
accessLevel NoteAccessLevel,
|
||||||
|
tags []string,
|
||||||
|
) (*Note, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
// TODO: Implement me
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) CreateNoteRemote(
|
||||||
|
creatorId string,
|
||||||
|
rawContent string,
|
||||||
|
contentWarning *string,
|
||||||
|
timestamp time.Time,
|
||||||
|
attachmentIds []string,
|
||||||
|
emoteIds []string,
|
||||||
|
repliesToId *string,
|
||||||
|
quotesId *string,
|
||||||
|
accessLevel NoteAccessLevel,
|
||||||
|
tags []string,
|
||||||
|
originId string,
|
||||||
|
) (*Note, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
// TODO: Implement me
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteNote(id string) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
s.cache.Delete(cacheNoteIdToNotePrefix + id)
|
||||||
|
s.db.Delete(Note{ID: id})
|
||||||
|
}
|
65
storage/outboundJobs.go
Normal file
65
storage/outboundJobs.go
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OutboundJob struct {
|
||||||
|
gorm.Model // Include full model. Gives ID, created and updated at timestamps as well as soft deletes
|
||||||
|
// Read (and create) only values to ensure consistency
|
||||||
|
TargetServer string `gorm:"->;<-:create"` // The url of the target server
|
||||||
|
TargetPath string `gorm:"->;<-:create"` // The full path of api endpoint targeted
|
||||||
|
Data []byte `gorm:"->;<-:create"` // The raw data to send
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) AddNewOutboundJob(data []byte, targetDomain string, targetUrl string) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
newJob := OutboundJob{
|
||||||
|
Data: data,
|
||||||
|
TargetServer: targetDomain,
|
||||||
|
TargetPath: targetUrl,
|
||||||
|
}
|
||||||
|
s.db.Create(&newJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the specified amount of jobs, sorted by age (oldest first)
|
||||||
|
func (s *Storage) GetOldestOutboundJobs(amount uint) ([]OutboundJob, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
jobs := []OutboundJob{}
|
||||||
|
err := s.db.Order("id asc, created_at asc").Limit(int(amount)).Find(jobs).Error
|
||||||
|
switch err {
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
case nil:
|
||||||
|
return jobs, nil
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetOutboundJobsForDomain(domain string, amount uint) ([]OutboundJob, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
jobs := []OutboundJob{}
|
||||||
|
err := s.db.Where("target_server = ?", domain).
|
||||||
|
Order("id asc, created_at asc").
|
||||||
|
Limit(int(amount)).
|
||||||
|
Find(jobs).
|
||||||
|
Error
|
||||||
|
switch err {
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
case nil:
|
||||||
|
return jobs, nil
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) CompleteOutboundJob(id uint) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
s.db.Delete(OutboundJob{Model: gorm.Model{ID: id}})
|
||||||
|
return nil
|
||||||
|
}
|
60
storage/passkeySessions.go
Normal file
60
storage/passkeySessions.go
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session data used during login attempts with a passkey
|
||||||
|
// Not actually used afterwards to verify a normal session
|
||||||
|
// NOTE: Doesn't contain a DeletedAt field, thus deletions are automatically hard and not reversible
|
||||||
|
type PasskeySession struct {
|
||||||
|
ID string `gorm:"primarykey"`
|
||||||
|
Data webauthn.SessionData `gorm:"serializer:json"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Section SessionStore
|
||||||
|
|
||||||
|
// Generate some id for a new session. Just returns a new uuid
|
||||||
|
func (s *Storage) GenSessionID() (string, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
x := uuid.NewString()
|
||||||
|
log.Debug().Str("session-id", x).Msg("Generated new passkey session id")
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for an active session with a given id
|
||||||
|
// Returns the session if found and a bool indicating if a session was found
|
||||||
|
func (s *Storage) GetSession(sessionId string) (*webauthn.SessionData, bool) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Debug().Str("id", sessionId).Msg("Looking for passkey session")
|
||||||
|
session := PasskeySession{}
|
||||||
|
res := s.db.Where("id = ?", sessionId).First(&session)
|
||||||
|
if res.Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
log.Debug().Str("id", sessionId).Any("webauthn-data", &session).Msg("Found passkey session")
|
||||||
|
return &session.Data, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save (or update) a session with the new data
|
||||||
|
func (s *Storage) SaveSession(token string, data *webauthn.SessionData) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Debug().Str("id", token).Any("webauthn-data", data).Msg("Saving passkey session")
|
||||||
|
session := PasskeySession{
|
||||||
|
ID: token,
|
||||||
|
Data: *data,
|
||||||
|
}
|
||||||
|
s.db.Save(&session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a session
|
||||||
|
// NOTE: This is a hard delete since the session struct contains no DeletedAt field
|
||||||
|
func (s *Storage) DeleteSession(token string) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Debug().Str("id", token).Msg("Deleting passkey session (if one exists)")
|
||||||
|
s.db.Delete(&PasskeySession{ID: token})
|
||||||
|
}
|
10
storage/reactions.go
Normal file
10
storage/reactions.go
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
type Reaction struct {
|
||||||
|
gorm.Model
|
||||||
|
NoteId string
|
||||||
|
ReactorId string
|
||||||
|
EmoteId uint
|
||||||
|
}
|
113
storage/remoteServerInfo.go
Normal file
113
storage/remoteServerInfo.go
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RemoteServer struct {
|
||||||
|
gorm.Model
|
||||||
|
ServerType RemoteServerType // What software the server is running. Useful for formatting
|
||||||
|
Domain string // `gorm:"primaryKey"` // Domain the server exists under. Additional primary key
|
||||||
|
Name string // What the server wants to be known as (usually same as url)
|
||||||
|
Icon string // ID of a media file
|
||||||
|
IsSelf bool // Whether this server is yours truly
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindRemoteServerByDomain(url string) (*RemoteServer, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
server := RemoteServer{}
|
||||||
|
err := s.db.Where("domain = ?").First(&server).Error
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return &server, nil
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a remote server with a given display name
|
||||||
|
func (s *Storage) FindRemoteServerByDisplayName(displayName string) (*RemoteServer, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
server := RemoteServer{}
|
||||||
|
err := s.db.Where("name = ?", displayName).First(&server).Error
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return &server, nil
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindRemoteServerById(id uint) (*RemoteServer, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
server := RemoteServer{}
|
||||||
|
err := s.db.First(&server, id).Error
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return &server, nil
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new remote server
|
||||||
|
func (s *Storage) NewRemoteServer(
|
||||||
|
url, displayName, icon string,
|
||||||
|
serverType RemoteServerType,
|
||||||
|
) (*RemoteServer, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
_, err := s.FindRemoteServerByDomain(url)
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return nil, ErrEntryAlreadyExists
|
||||||
|
case ErrEntryNotFound: // Empty case, not found is what we want
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
server := RemoteServer{
|
||||||
|
Domain: url,
|
||||||
|
Name: displayName,
|
||||||
|
Icon: icon,
|
||||||
|
ServerType: serverType,
|
||||||
|
}
|
||||||
|
err = s.db.Create(&server).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a remote server with the given url
|
||||||
|
// If displayName is set, update that
|
||||||
|
// If icon is set, update that
|
||||||
|
// Returns the updated version
|
||||||
|
func (s *Storage) UpdateRemoteServer(url string, displayName, icon *string) (*RemoteServer, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
if displayName == nil && icon == nil {
|
||||||
|
return nil, ErrNothingToChange
|
||||||
|
}
|
||||||
|
server, err := s.FindRemoteServerByDomain(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if displayName != nil {
|
||||||
|
server.Name = *displayName
|
||||||
|
}
|
||||||
|
if icon != nil {
|
||||||
|
server.Icon = *icon
|
||||||
|
}
|
||||||
|
err = s.db.Save(server).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return server, nil
|
||||||
|
}
|
7
storage/remoteUser.go
Normal file
7
storage/remoteUser.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
// TODO: More helper stuff
|
||||||
|
|
||||||
|
func (s *Storage) NewRemoteUser(fullHandle string) (*Account, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
253
storage/roles.go
Normal file
253
storage/roles.go
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.mstar.dev/mstar/goutils/sliceutils"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Could I collapse all these go:generate command into more condensed ones?
|
||||||
|
// Yes
|
||||||
|
// Will I do that?
|
||||||
|
// No
|
||||||
|
// This is explicit in what is being done. And easier to understand
|
||||||
|
|
||||||
|
//go:generate go build -o RolesGenerator ../cmd/RolesGenerator/main.go
|
||||||
|
//go:generate ./RolesGenerator -input=roles.go -output=rolesshared_generated.go
|
||||||
|
//go:generate rm RolesGenerator
|
||||||
|
|
||||||
|
//go:generate go build -o ApiGenerator ../cmd/RolesApiTypeGenerator/main.go
|
||||||
|
//go:generate ./ApiGenerator -input=roles.go -output=../server/apiLinstromTypes_generated.go
|
||||||
|
//go:generate rm ApiGenerator
|
||||||
|
|
||||||
|
//go:generate go build -o HelperGenerator ../cmd/RolesApiConverter/main.go
|
||||||
|
//go:generate ./HelperGenerator -input=roles.go -output=../server/apiLinstromTypeHelpers_generated.go
|
||||||
|
//go:generate rm HelperGenerator
|
||||||
|
|
||||||
|
//go:generate go build -o FrontendGenerator ../cmd/RolesFrontendGenerator/main.go
|
||||||
|
//go:generate ./FrontendGenerator -input=roles.go -output=../frontend-reactive/app/models/role.ts
|
||||||
|
//go:generate rm FrontendGenerator
|
||||||
|
|
||||||
|
// A role is, in concept, similar to how Discord handles roles
|
||||||
|
// Some permission can be either disallowed (&false), don't care (nil) or allowed (&true)
|
||||||
|
// Don't care just says to use the value from the next lower role where it is set
|
||||||
|
type Role struct {
|
||||||
|
// TODO: More control options
|
||||||
|
// Extend upon whatever Masto, Akkoma and Misskey have
|
||||||
|
// Lots of details please
|
||||||
|
|
||||||
|
// --- Role metadata ---
|
||||||
|
|
||||||
|
// Include full db model stuff
|
||||||
|
gorm.Model
|
||||||
|
|
||||||
|
// Name of the role
|
||||||
|
Name string `gorm:"primaryKey;unique"`
|
||||||
|
|
||||||
|
// Priority of the role
|
||||||
|
// Lower priority gets applied first and thus overwritten by higher priority ones
|
||||||
|
// If two roles have the same priority, the order is undetermined and may be random
|
||||||
|
// Default priority for new roles is 1 to always overwrite default user
|
||||||
|
// And full admin has max priority possible
|
||||||
|
Priority uint32
|
||||||
|
// Whether this role is for a for a single user only (like custom, per user permissions in Discord)
|
||||||
|
// If yes, Name will be the id of the user in question
|
||||||
|
IsUserRole bool
|
||||||
|
|
||||||
|
// Whether this role is one built into Linstrom from the start or not
|
||||||
|
// Note: Built-in roles can't be modified
|
||||||
|
IsBuiltIn bool
|
||||||
|
|
||||||
|
// --- User permissions ---
|
||||||
|
CanSendMedia *bool // Local & remote
|
||||||
|
CanSendCustomEmotes *bool // Local & remote
|
||||||
|
CanSendCustomReactions *bool // Local & remote
|
||||||
|
CanSendPublicNotes *bool // Local & remote
|
||||||
|
CanSendLocalNotes *bool // Local & remote
|
||||||
|
CanSendFollowerOnlyNotes *bool // Local & remote
|
||||||
|
CanSendPrivateNotes *bool // Local & remote
|
||||||
|
CanSendReplies *bool // Local & remote
|
||||||
|
CanQuote *bool // Local only
|
||||||
|
CanBoost *bool // Local only
|
||||||
|
CanIncludeLinks *bool // Local & remote
|
||||||
|
CanIncludeSurvey *bool // Local
|
||||||
|
CanFederateFedi *bool // Local & remote
|
||||||
|
CanFederateBsky *bool // Local
|
||||||
|
|
||||||
|
CanChangeDisplayName *bool // Local
|
||||||
|
|
||||||
|
// Internal ids of accounts blocked by this role
|
||||||
|
BlockedUsers []string `gorm:"type:bytes;serializer:gob"` // Local
|
||||||
|
CanSubmitReports *bool // Local & remote
|
||||||
|
// If disabled, an account can no longer be interacted with. The owner can no longer change anything about it
|
||||||
|
// And the UI will show a notice (and maybe include that info in the AP data too)
|
||||||
|
// Only moderators and admins will be able to edit the account's roles
|
||||||
|
CanLogin *bool // Local
|
||||||
|
|
||||||
|
CanMentionOthers *bool // Local & remote
|
||||||
|
HasMentionCountLimit *bool // Local & remote
|
||||||
|
MentionLimit *uint32 // Local & remote
|
||||||
|
|
||||||
|
// CanViewBoosts *bool
|
||||||
|
// CanViewQuotes *bool
|
||||||
|
// CanViewMedia *bool
|
||||||
|
// CanViewCustomEmotes *bool
|
||||||
|
|
||||||
|
// --- Automod ---
|
||||||
|
AutoNsfwMedia *bool // Local & remote
|
||||||
|
AutoCwPosts *bool // Local & remote
|
||||||
|
AutoCwPostsText *string // Local & remote
|
||||||
|
ScanCreatedPublicNotes *bool // Local & remote
|
||||||
|
ScanCreatedLocalNotes *bool // Local & remote
|
||||||
|
ScanCreatedFollowerOnlyNotes *bool // Local & remote
|
||||||
|
ScanCreatedPrivateNotes *bool // Local & remote
|
||||||
|
// Blocks all interactions and federation between users with the role and all included ids/handles
|
||||||
|
// TODO: Decide whether this is a list of handles or of account ids
|
||||||
|
// Handles would increase the load due to having to search for them first
|
||||||
|
// while ids would require to store every single account mentioned
|
||||||
|
// which could cause escalating storage costs
|
||||||
|
DisallowInteractionsWith []string `gorm:"type:bytes;serializer:gob"` // Local & remote
|
||||||
|
|
||||||
|
WithholdNotesForManualApproval *bool // Local & remote
|
||||||
|
WithholdNotesBasedOnRegex *bool // Local & remote
|
||||||
|
WithholdNotesRegexes []string `gorm:"type:bytes;serializer:gob"` // Local & remote
|
||||||
|
|
||||||
|
// --- Admin perms ---
|
||||||
|
// If set, counts as all permissions being set as given and all restrictions being disabled
|
||||||
|
FullAdmin *bool // Local
|
||||||
|
CanAffectOtherAdmins *bool // Local
|
||||||
|
CanDeleteNotes *bool // Local
|
||||||
|
CanConfirmWithheldNotes *bool // Local
|
||||||
|
CanAssignRoles *bool // Local
|
||||||
|
CanSupressInteractionsBetweenUsers *bool // Local
|
||||||
|
CanOverwriteDisplayNames *bool // Local
|
||||||
|
CanManageCustomEmotes *bool // Local
|
||||||
|
CanViewDeletedNotes *bool // Local
|
||||||
|
CanRecoverDeletedNotes *bool // Local
|
||||||
|
CanManageAvatarDecorations *bool // Local
|
||||||
|
CanManageAds *bool // Local
|
||||||
|
CanSendAnnouncements *bool // Local
|
||||||
|
CanDeleteAccounts *bool // Local
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Mastodon permissions (highest permission to lowest):
|
||||||
|
- Admin
|
||||||
|
- Devops
|
||||||
|
- View Audit log
|
||||||
|
- View Dashboard
|
||||||
|
- Manage Reports
|
||||||
|
- Manage Federation
|
||||||
|
- Manage Settings
|
||||||
|
- Manage Blocks
|
||||||
|
- Manage Taxonomies
|
||||||
|
- Manage Appeals
|
||||||
|
- Manage Users
|
||||||
|
- Manage Invites
|
||||||
|
- Manage Rules
|
||||||
|
- Manage Announcements
|
||||||
|
- Manage Custom Emojis
|
||||||
|
- Manage Webhooks
|
||||||
|
- Invite Users
|
||||||
|
- Manage Roles
|
||||||
|
- Manage User Access
|
||||||
|
- Delete User Data
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
Misskey "permissions" (no order):
|
||||||
|
- Global timeline available (interact with global timeline I think)
|
||||||
|
- Local timeline available (same as global, but for local)
|
||||||
|
- b-something timeline available
|
||||||
|
- Can send public notes
|
||||||
|
- How many mentions a note can have
|
||||||
|
- Can invite others
|
||||||
|
- How many invites can be sent
|
||||||
|
- InviteLimitCycle (whatever that means)
|
||||||
|
- Invite Expiration time (duration of how long invites stay valid I think)
|
||||||
|
- Manage custom emojis
|
||||||
|
- Manage custom avatar decorations
|
||||||
|
- Seach for notes
|
||||||
|
- Use translator
|
||||||
|
- Hide ads from self
|
||||||
|
- How much storage space the user has
|
||||||
|
- Whether to mark all posts from account as nsfw
|
||||||
|
- max number of pinned messages
|
||||||
|
- max number of antennas
|
||||||
|
- max number of muted words
|
||||||
|
- max number of webhooks
|
||||||
|
- max number of clips
|
||||||
|
- max number of notes contained in a clip (? I think. Don't know enough about clips)
|
||||||
|
- Max number of lists of users
|
||||||
|
- max number of users in a user list
|
||||||
|
- rate limit multiplier
|
||||||
|
- max number of applied avatar decorations
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (s *Storage) NewEmptyRole(name string) (*Role, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
// Check if a role with the given name already exists
|
||||||
|
_, err := s.FindRoleByName(name)
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return nil, ErrEntryAlreadyExists
|
||||||
|
case ErrEntryNotFound: // Empty case, since this is what we want
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// New roles have a priority of 1 by default
|
||||||
|
newRole := Role{Name: name, Priority: 1}
|
||||||
|
err = s.db.Create(&newRole).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &newRole, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindRoleByName(name string) (*Role, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
role := Role{}
|
||||||
|
err := s.db.Where("name = ?", name).First(&role).Error
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return &role, nil
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindRolesByNames(names []string) ([]Role, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
roles := []Role{}
|
||||||
|
err := s.db.Where("name IN ?", names).Find(&roles).Error
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return roles, nil
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateRole(role *Role) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return s.db.Save(role).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteRoleByName(name string) error {
|
||||||
|
// Prevent deletion of built-in roles
|
||||||
|
if sliceutils.Contains(
|
||||||
|
sliceutils.Map(allDefaultRoles, func(t *Role) string { return t.Name }),
|
||||||
|
name,
|
||||||
|
) {
|
||||||
|
return ErrNotAllowed
|
||||||
|
}
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return s.db.Where(&Role{Name: name, IsBuiltIn: false}).Delete(&Role{}).Error
|
||||||
|
}
|
244
storage/rolesDefaults.go
Normal file
244
storage/rolesDefaults.go
Normal file
|
@ -0,0 +1,244 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/goutils/other"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default role every user has. Defines sane defaults for a normal user
|
||||||
|
// Will get overwritten by just about every other role due to every other role having higher priority
|
||||||
|
var DefaultUserRole = Role{
|
||||||
|
Name: "Default",
|
||||||
|
Priority: 0,
|
||||||
|
IsUserRole: false,
|
||||||
|
IsBuiltIn: true,
|
||||||
|
|
||||||
|
CanSendMedia: other.IntoPointer(true),
|
||||||
|
CanSendCustomEmotes: other.IntoPointer(true),
|
||||||
|
CanSendCustomReactions: other.IntoPointer(true),
|
||||||
|
CanSendPublicNotes: other.IntoPointer(true),
|
||||||
|
CanSendLocalNotes: other.IntoPointer(true),
|
||||||
|
CanSendFollowerOnlyNotes: other.IntoPointer(true),
|
||||||
|
CanSendPrivateNotes: other.IntoPointer(true),
|
||||||
|
CanSendReplies: other.IntoPointer(true),
|
||||||
|
CanQuote: other.IntoPointer(true),
|
||||||
|
CanBoost: other.IntoPointer(true),
|
||||||
|
CanIncludeLinks: other.IntoPointer(true),
|
||||||
|
CanIncludeSurvey: other.IntoPointer(true),
|
||||||
|
CanFederateFedi: other.IntoPointer(true),
|
||||||
|
CanFederateBsky: other.IntoPointer(true),
|
||||||
|
|
||||||
|
CanChangeDisplayName: other.IntoPointer(true),
|
||||||
|
|
||||||
|
BlockedUsers: []string{},
|
||||||
|
CanSubmitReports: other.IntoPointer(true),
|
||||||
|
CanLogin: other.IntoPointer(true),
|
||||||
|
|
||||||
|
CanMentionOthers: other.IntoPointer(true),
|
||||||
|
HasMentionCountLimit: other.IntoPointer(false),
|
||||||
|
MentionLimit: other.IntoPointer(
|
||||||
|
uint32(math.MaxUint32),
|
||||||
|
), // Set this to max, even if not used due to *HasMentionCountLimit == false
|
||||||
|
|
||||||
|
AutoNsfwMedia: other.IntoPointer(false),
|
||||||
|
AutoCwPosts: other.IntoPointer(false),
|
||||||
|
AutoCwPostsText: nil,
|
||||||
|
WithholdNotesForManualApproval: other.IntoPointer(false),
|
||||||
|
ScanCreatedPublicNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedLocalNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedFollowerOnlyNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedPrivateNotes: other.IntoPointer(false),
|
||||||
|
DisallowInteractionsWith: []string{},
|
||||||
|
|
||||||
|
FullAdmin: other.IntoPointer(false),
|
||||||
|
CanAffectOtherAdmins: other.IntoPointer(false),
|
||||||
|
CanDeleteNotes: other.IntoPointer(false),
|
||||||
|
CanConfirmWithheldNotes: other.IntoPointer(false),
|
||||||
|
CanAssignRoles: other.IntoPointer(false),
|
||||||
|
CanSupressInteractionsBetweenUsers: other.IntoPointer(false),
|
||||||
|
CanOverwriteDisplayNames: other.IntoPointer(false),
|
||||||
|
CanManageCustomEmotes: other.IntoPointer(false),
|
||||||
|
CanViewDeletedNotes: other.IntoPointer(false),
|
||||||
|
CanRecoverDeletedNotes: other.IntoPointer(false),
|
||||||
|
CanManageAvatarDecorations: other.IntoPointer(false),
|
||||||
|
CanManageAds: other.IntoPointer(false),
|
||||||
|
CanSendAnnouncements: other.IntoPointer(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role providing maximum permissions
|
||||||
|
var FullAdminRole = Role{
|
||||||
|
Name: "fullAdmin",
|
||||||
|
Priority: math.MaxUint32,
|
||||||
|
IsUserRole: false,
|
||||||
|
IsBuiltIn: true,
|
||||||
|
|
||||||
|
CanSendMedia: other.IntoPointer(true),
|
||||||
|
CanSendCustomEmotes: other.IntoPointer(true),
|
||||||
|
CanSendCustomReactions: other.IntoPointer(true),
|
||||||
|
CanSendPublicNotes: other.IntoPointer(true),
|
||||||
|
CanSendLocalNotes: other.IntoPointer(true),
|
||||||
|
CanSendFollowerOnlyNotes: other.IntoPointer(true),
|
||||||
|
CanSendPrivateNotes: other.IntoPointer(true),
|
||||||
|
CanQuote: other.IntoPointer(true),
|
||||||
|
CanBoost: other.IntoPointer(true),
|
||||||
|
CanIncludeLinks: other.IntoPointer(true),
|
||||||
|
CanIncludeSurvey: other.IntoPointer(true),
|
||||||
|
|
||||||
|
CanChangeDisplayName: other.IntoPointer(true),
|
||||||
|
|
||||||
|
BlockedUsers: []string{},
|
||||||
|
CanSubmitReports: other.IntoPointer(true),
|
||||||
|
CanLogin: other.IntoPointer(true),
|
||||||
|
|
||||||
|
CanMentionOthers: other.IntoPointer(true),
|
||||||
|
HasMentionCountLimit: other.IntoPointer(false),
|
||||||
|
MentionLimit: other.IntoPointer(
|
||||||
|
uint32(math.MaxUint32),
|
||||||
|
), // Set this to max, even if not used due to *HasMentionCountLimit == false
|
||||||
|
|
||||||
|
AutoNsfwMedia: other.IntoPointer(false),
|
||||||
|
AutoCwPosts: other.IntoPointer(false),
|
||||||
|
AutoCwPostsText: nil,
|
||||||
|
WithholdNotesForManualApproval: other.IntoPointer(false),
|
||||||
|
ScanCreatedPublicNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedLocalNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedFollowerOnlyNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedPrivateNotes: other.IntoPointer(false),
|
||||||
|
DisallowInteractionsWith: []string{},
|
||||||
|
|
||||||
|
FullAdmin: other.IntoPointer(true),
|
||||||
|
CanAffectOtherAdmins: other.IntoPointer(true),
|
||||||
|
CanDeleteNotes: other.IntoPointer(true),
|
||||||
|
CanConfirmWithheldNotes: other.IntoPointer(true),
|
||||||
|
CanAssignRoles: other.IntoPointer(true),
|
||||||
|
CanSupressInteractionsBetweenUsers: other.IntoPointer(true),
|
||||||
|
CanOverwriteDisplayNames: other.IntoPointer(true),
|
||||||
|
CanManageCustomEmotes: other.IntoPointer(true),
|
||||||
|
CanViewDeletedNotes: other.IntoPointer(true),
|
||||||
|
CanRecoverDeletedNotes: other.IntoPointer(true),
|
||||||
|
CanManageAvatarDecorations: other.IntoPointer(true),
|
||||||
|
CanManageAds: other.IntoPointer(true),
|
||||||
|
CanSendAnnouncements: other.IntoPointer(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role for totally freezing an account, blocking all activity from it
|
||||||
|
var AccountFreezeRole = Role{
|
||||||
|
Name: "accountFreeze",
|
||||||
|
Priority: math.MaxUint32 - 1,
|
||||||
|
IsUserRole: false,
|
||||||
|
IsBuiltIn: true,
|
||||||
|
|
||||||
|
CanSendMedia: other.IntoPointer(false),
|
||||||
|
CanSendCustomEmotes: other.IntoPointer(false),
|
||||||
|
CanSendCustomReactions: other.IntoPointer(false),
|
||||||
|
CanSendPublicNotes: other.IntoPointer(false),
|
||||||
|
CanSendLocalNotes: other.IntoPointer(false),
|
||||||
|
CanSendFollowerOnlyNotes: other.IntoPointer(false),
|
||||||
|
CanSendPrivateNotes: other.IntoPointer(false),
|
||||||
|
CanSendReplies: other.IntoPointer(false),
|
||||||
|
CanQuote: other.IntoPointer(false),
|
||||||
|
CanBoost: other.IntoPointer(false),
|
||||||
|
CanIncludeLinks: other.IntoPointer(false),
|
||||||
|
CanIncludeSurvey: other.IntoPointer(false),
|
||||||
|
CanFederateBsky: other.IntoPointer(false),
|
||||||
|
CanFederateFedi: other.IntoPointer(false),
|
||||||
|
|
||||||
|
CanChangeDisplayName: other.IntoPointer(false),
|
||||||
|
|
||||||
|
BlockedUsers: []string{},
|
||||||
|
CanSubmitReports: other.IntoPointer(false),
|
||||||
|
CanLogin: other.IntoPointer(false),
|
||||||
|
|
||||||
|
CanMentionOthers: other.IntoPointer(false),
|
||||||
|
HasMentionCountLimit: other.IntoPointer(false),
|
||||||
|
MentionLimit: other.IntoPointer(
|
||||||
|
uint32(math.MaxUint32),
|
||||||
|
), // Set this to max, even if not used due to *HasMentionCountLimit == false
|
||||||
|
|
||||||
|
AutoNsfwMedia: other.IntoPointer(true),
|
||||||
|
AutoCwPosts: other.IntoPointer(false),
|
||||||
|
AutoCwPostsText: other.IntoPointer("Account frozen"),
|
||||||
|
WithholdNotesForManualApproval: other.IntoPointer(true),
|
||||||
|
ScanCreatedPublicNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedLocalNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedFollowerOnlyNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedPrivateNotes: other.IntoPointer(false),
|
||||||
|
DisallowInteractionsWith: []string{},
|
||||||
|
|
||||||
|
FullAdmin: other.IntoPointer(false),
|
||||||
|
CanAffectOtherAdmins: other.IntoPointer(false),
|
||||||
|
CanDeleteNotes: other.IntoPointer(false),
|
||||||
|
CanConfirmWithheldNotes: other.IntoPointer(false),
|
||||||
|
CanAssignRoles: other.IntoPointer(false),
|
||||||
|
CanSupressInteractionsBetweenUsers: other.IntoPointer(false),
|
||||||
|
CanOverwriteDisplayNames: other.IntoPointer(false),
|
||||||
|
CanManageCustomEmotes: other.IntoPointer(false),
|
||||||
|
CanViewDeletedNotes: other.IntoPointer(false),
|
||||||
|
CanRecoverDeletedNotes: other.IntoPointer(false),
|
||||||
|
CanManageAvatarDecorations: other.IntoPointer(false),
|
||||||
|
CanManageAds: other.IntoPointer(false),
|
||||||
|
CanSendAnnouncements: other.IntoPointer(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
var ServerActorRole = Role{
|
||||||
|
Name: "ServerActor",
|
||||||
|
Priority: math.MaxUint32,
|
||||||
|
IsUserRole: true,
|
||||||
|
IsBuiltIn: true,
|
||||||
|
|
||||||
|
CanSendMedia: other.IntoPointer(true),
|
||||||
|
CanSendCustomEmotes: other.IntoPointer(true),
|
||||||
|
CanSendCustomReactions: other.IntoPointer(true),
|
||||||
|
CanSendPublicNotes: other.IntoPointer(true),
|
||||||
|
CanSendLocalNotes: other.IntoPointer(true),
|
||||||
|
CanSendFollowerOnlyNotes: other.IntoPointer(true),
|
||||||
|
CanSendPrivateNotes: other.IntoPointer(true),
|
||||||
|
CanQuote: other.IntoPointer(true),
|
||||||
|
CanBoost: other.IntoPointer(true),
|
||||||
|
CanIncludeLinks: other.IntoPointer(true),
|
||||||
|
CanIncludeSurvey: other.IntoPointer(true),
|
||||||
|
|
||||||
|
CanChangeDisplayName: other.IntoPointer(true),
|
||||||
|
|
||||||
|
BlockedUsers: []string{},
|
||||||
|
CanSubmitReports: other.IntoPointer(true),
|
||||||
|
CanLogin: other.IntoPointer(true),
|
||||||
|
|
||||||
|
CanMentionOthers: other.IntoPointer(true),
|
||||||
|
HasMentionCountLimit: other.IntoPointer(false),
|
||||||
|
MentionLimit: other.IntoPointer(
|
||||||
|
uint32(math.MaxUint32),
|
||||||
|
), // Set this to max, even if not used due to *HasMentionCountLimit == false
|
||||||
|
|
||||||
|
AutoNsfwMedia: other.IntoPointer(false),
|
||||||
|
AutoCwPosts: other.IntoPointer(false),
|
||||||
|
AutoCwPostsText: nil,
|
||||||
|
WithholdNotesForManualApproval: other.IntoPointer(false),
|
||||||
|
ScanCreatedPublicNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedLocalNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedFollowerOnlyNotes: other.IntoPointer(false),
|
||||||
|
ScanCreatedPrivateNotes: other.IntoPointer(false),
|
||||||
|
DisallowInteractionsWith: []string{},
|
||||||
|
|
||||||
|
FullAdmin: other.IntoPointer(true),
|
||||||
|
CanAffectOtherAdmins: other.IntoPointer(true),
|
||||||
|
CanDeleteNotes: other.IntoPointer(true),
|
||||||
|
CanConfirmWithheldNotes: other.IntoPointer(true),
|
||||||
|
CanAssignRoles: other.IntoPointer(true),
|
||||||
|
CanSupressInteractionsBetweenUsers: other.IntoPointer(true),
|
||||||
|
CanOverwriteDisplayNames: other.IntoPointer(true),
|
||||||
|
CanManageCustomEmotes: other.IntoPointer(true),
|
||||||
|
CanViewDeletedNotes: other.IntoPointer(true),
|
||||||
|
CanRecoverDeletedNotes: other.IntoPointer(true),
|
||||||
|
CanManageAvatarDecorations: other.IntoPointer(true),
|
||||||
|
CanManageAds: other.IntoPointer(true),
|
||||||
|
CanSendAnnouncements: other.IntoPointer(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
var allDefaultRoles = []*Role{
|
||||||
|
&DefaultUserRole,
|
||||||
|
&FullAdminRole,
|
||||||
|
&AccountFreezeRole,
|
||||||
|
&ServerActorRole,
|
||||||
|
}
|
336
storage/rolesUtil_generated.go
Normal file
336
storage/rolesUtil_generated.go
Normal file
File diff suppressed because one or more lines are too long
39
storage/serverTypes.go
Normal file
39
storage/serverTypes.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Decide whether to turn this into an int too to save resources
|
||||||
|
// And then use go:generate instead for pretty printing
|
||||||
|
|
||||||
|
// What software a server is running
|
||||||
|
// Mostly important for rendering
|
||||||
|
type RemoteServerType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Includes forks like glitch-soc, etc
|
||||||
|
REMOTE_SERVER_MASTODON = RemoteServerType("Mastodon")
|
||||||
|
// Includes forks like Ice Shrimp, Sharkey, Cutiekey, etc
|
||||||
|
REMOTE_SERVER_MISSKEY = RemoteServerType("Misskey")
|
||||||
|
// Includes Akkoma
|
||||||
|
REMOTE_SERVER_PLEMORA = RemoteServerType("Plemora")
|
||||||
|
// Wafrn is a new entry
|
||||||
|
REMOTE_SERVER_WAFRN = RemoteServerType("Wafrn")
|
||||||
|
// And of course, yours truly
|
||||||
|
REMOTE_SERVER_LINSTROM = RemoteServerType("Linstrom")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *RemoteServerType) Value() (driver.Value, error) {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteServerType) Scan(raw any) error {
|
||||||
|
if v, ok := raw.(string); ok {
|
||||||
|
*r = RemoteServerType(v)
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
return errors.New("value not a string")
|
||||||
|
}
|
||||||
|
}
|
164
storage/storage.go
Normal file
164
storage/storage.go
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
// TODO: Unify function names
|
||||||
|
|
||||||
|
// Storage is the handler for cache and db access
|
||||||
|
// It handles storing various data in the database as well as caching that data
|
||||||
|
// Said data includes notes, accounts, metadata about media files, servers and similar
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/config"
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Always keep a reference of the server's own RemoteServer entry here
|
||||||
|
// Removes the need to perform a db request every time a new local anything
|
||||||
|
// is created
|
||||||
|
var serverSelf RemoteServer
|
||||||
|
|
||||||
|
// Storage is responsible for all database, cache and media related actions
|
||||||
|
// and serves as the lowest layer of the cake
|
||||||
|
type Storage struct {
|
||||||
|
db *gorm.DB
|
||||||
|
cache *cache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStorage(dbUrl string, cache *cache.Cache) (*Storage, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
db, err := gorm.Open(postgres.Open(dbUrl), &gorm.Config{
|
||||||
|
Logger: newGormLogger(log.Logger),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = db.AutoMigrate(
|
||||||
|
MediaMetadata{},
|
||||||
|
Account{},
|
||||||
|
RemoteServer{},
|
||||||
|
Note{},
|
||||||
|
Role{},
|
||||||
|
PasskeySession{},
|
||||||
|
InboundJob{},
|
||||||
|
OutboundJob{},
|
||||||
|
AccessToken{},
|
||||||
|
Emote{},
|
||||||
|
UserInfoField{},
|
||||||
|
AccountRelation{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to apply migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Storage{db, cache}
|
||||||
|
|
||||||
|
if err = s.insertDefaultRoles(); err != nil {
|
||||||
|
return nil, fmt.Errorf("default roles insertion failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.insertPlaceholderFile(); err != nil {
|
||||||
|
return nil, fmt.Errorf("placeholder file insertion failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.insertSelfFromConfig(); err != nil {
|
||||||
|
return nil, fmt.Errorf("self insertion failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) insertSelfFromConfig() error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
const ServerActorId = "self"
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Insert server info
|
||||||
|
serverData := RemoteServer{}
|
||||||
|
err = s.db.Where("id = 1").
|
||||||
|
// Set once on creation
|
||||||
|
Attrs(RemoteServer{
|
||||||
|
Domain: config.GlobalConfig.General.GetFullDomain(),
|
||||||
|
}).
|
||||||
|
// Set every time
|
||||||
|
Assign(RemoteServer{
|
||||||
|
IsSelf: true,
|
||||||
|
Name: config.GlobalConfig.Self.ServerDisplayName,
|
||||||
|
ServerType: REMOTE_SERVER_LINSTROM,
|
||||||
|
Icon: "placeholder", // TODO: Set to server icon media
|
||||||
|
}).FirstOrCreate(&serverData).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Set module specific global var
|
||||||
|
serverSelf = serverData
|
||||||
|
|
||||||
|
// Insert server actor
|
||||||
|
serverActor := Account{}
|
||||||
|
serverActorPublicKey, serverActorPrivateKey, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = s.db.Where(Account{ID: ServerActorId}).
|
||||||
|
// Values to always (re)set after launch
|
||||||
|
Assign(Account{
|
||||||
|
Username: "self",
|
||||||
|
DisplayName: config.GlobalConfig.Self.ServerActorDisplayName,
|
||||||
|
// Server: serverData,
|
||||||
|
ServerId: serverData.ID,
|
||||||
|
// CustomFields: []uint{},
|
||||||
|
Description: "Server actor of a Linstrom server",
|
||||||
|
// Tags: []string{},
|
||||||
|
IsBot: true,
|
||||||
|
// Followers: []string{},
|
||||||
|
// Follows: []string{},
|
||||||
|
Indexable: false,
|
||||||
|
RestrictedFollow: false,
|
||||||
|
IdentifiesAs: []Being{},
|
||||||
|
Gender: []string{},
|
||||||
|
Roles: []string{"ServerActor"}, // TODO: Add server actor role once created
|
||||||
|
}).
|
||||||
|
// Values that'll only be set on first creation
|
||||||
|
Attrs(Account{
|
||||||
|
PublicKey: serverActorPublicKey,
|
||||||
|
PrivateKey: serverActorPrivateKey,
|
||||||
|
Icon: "placeholder",
|
||||||
|
Background: nil,
|
||||||
|
Banner: nil,
|
||||||
|
}).
|
||||||
|
FirstOrCreate(&serverActor).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) insertDefaultRoles() error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
for _, role := range allDefaultRoles {
|
||||||
|
log.Debug().Str("role-name", role.Name).Msg("Inserting default role")
|
||||||
|
if err := s.db.FirstOrCreate(role).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) insertPlaceholderFile() error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return s.db.Model(&MediaMetadata{}).Assign(&MediaMetadata{
|
||||||
|
ID: "placeholder",
|
||||||
|
Type: "image/webp",
|
||||||
|
Name: "placeholderFile",
|
||||||
|
Blurred: false,
|
||||||
|
Remote: false,
|
||||||
|
Location: "/placeholder-file",
|
||||||
|
AltText: "Greyscale image of a pidgeon, captioned with the text \"Duck\"",
|
||||||
|
}).FirstOrCreate(&MediaMetadata{}).Error
|
||||||
|
}
|
60
storage/storage.go.old
Normal file
60
storage/storage.go.old
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"git.mstar.dev/mstar/linstrom/storage/cache"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Storage is responsible for all database, cache and media related actions
|
||||||
|
// and serves as the lowest layer of the cake
|
||||||
|
type Storage struct {
|
||||||
|
db *gorm.DB
|
||||||
|
cache *cache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrInvalidData = errors.New("invalid data")
|
||||||
|
|
||||||
|
// Build a new storage using sqlite as database backend
|
||||||
|
func NewStorageSqlite(filePath string, cache *cache.Cache) (*Storage, error) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(filePath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return storageFromEmptyDb(db, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStoragePostgres(dbUrl string, cache *cache.Cache) (*Storage, error) {
|
||||||
|
db, err := gorm.Open(postgres.Open(dbUrl))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return storageFromEmptyDb(db, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storageFromEmptyDb(db *gorm.DB, cache *cache.Cache) (*Storage, error) {
|
||||||
|
// AutoMigrate ensures the db is in a state where all the structs given here
|
||||||
|
// have their own tables and relations setup. It also updates tables if necessary
|
||||||
|
err := db.AutoMigrate(
|
||||||
|
MediaMetadata{},
|
||||||
|
Account{},
|
||||||
|
RemoteServer{},
|
||||||
|
Note{},
|
||||||
|
Role{},
|
||||||
|
PasskeySession{},
|
||||||
|
InboundJob{},
|
||||||
|
OutboundJob{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// And finally, build the actual storage struct
|
||||||
|
return &Storage{
|
||||||
|
db: db,
|
||||||
|
cache: cache,
|
||||||
|
}, nil
|
||||||
|
}
|
483
storage/user.go
Normal file
483
storage/user.go
Normal file
|
@ -0,0 +1,483 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/activitypub"
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/mstarongithub/passkey"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Database representation of a user account
|
||||||
|
// This can be a bot, remote or not
|
||||||
|
// If remote, this is used for caching the account
|
||||||
|
type Account struct {
|
||||||
|
ID string `gorm:"primarykey"` // ID is a uuid for this account
|
||||||
|
// Username of the user (eg "max" if the full username is @max@example.com)
|
||||||
|
// Assume unchangable (once set by a user) to be kind to other implementations
|
||||||
|
// Would be an easy avenue to fuck with them though
|
||||||
|
Username string `gorm:"unique"`
|
||||||
|
CreatedAt time.Time // When this entry was created. Automatically set by gorm
|
||||||
|
// When this account was last updated. Will also be used for refreshing remote accounts. Automatically set by gorm
|
||||||
|
UpdatedAt time.Time
|
||||||
|
// When this entry was deleted (for soft deletions)
|
||||||
|
// Soft delete means that this entry still exists in the db, but gorm won't include it anymore unless specifically told to
|
||||||
|
// If not null, this entry is marked as deleted
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
|
// Server RemoteServer // `gorm:"foreignKey:ServerId;references:ID"` // The server this user is from
|
||||||
|
ServerId uint // Id of the server this user is from, needed for including RemoteServer
|
||||||
|
DisplayName string // The display name of the user. Can be different from the handle
|
||||||
|
CustomFields []uint `gorm:"serializer:json"` // IDs to the custom fields a user has
|
||||||
|
Description string // The description of a user account
|
||||||
|
Tags []string `gorm:"serializer:json"` // Hashtags
|
||||||
|
IsBot bool // Whether to mark this account as a script controlled one
|
||||||
|
Relations []uint `gorm:"serializer:json"` // List of ids of all relations this account has. Both follows and followers
|
||||||
|
Icon string // ID of a media file used as icon
|
||||||
|
Background *string // ID of a media file used as background image
|
||||||
|
Banner *string // ID of a media file used as banner
|
||||||
|
Indexable bool // Whether this account can be found by crawlers
|
||||||
|
PublicKey []byte // The public key of the account
|
||||||
|
// Whether this account restricts following
|
||||||
|
// If true, the owner must approve of a follow request first
|
||||||
|
RestrictedFollow bool
|
||||||
|
// List of things the owner identifies as
|
||||||
|
// Example [cat human robot] means that the owner probably identifies as
|
||||||
|
// a cyborg-catgirl/boy/human or a cathuman shaped robot, refer to Gender for pronouns
|
||||||
|
IdentifiesAs []Being `gorm:"serializer:json"`
|
||||||
|
// List of pronouns the owner identifies with
|
||||||
|
// An unordered list since the owner can freely set it
|
||||||
|
// Examples: [she her], [it they its them] or, if you want to go fancy, [this is super serious]
|
||||||
|
Gender []string `gorm:"serializer:json"`
|
||||||
|
// The roles assocciated with an account. Values are the names of the roles
|
||||||
|
Roles []string `gorm:"serializer:json"`
|
||||||
|
|
||||||
|
Location *string
|
||||||
|
Birthday *time.Time
|
||||||
|
|
||||||
|
// --- And internal account stuff ---
|
||||||
|
// Still public fields since they wouldn't be able to be stored in the db otherwise
|
||||||
|
PrivateKey []byte // The private key of the account. Nil if remote user
|
||||||
|
WebAuthnId []byte // The unique and random ID of this account used for passkey authentication
|
||||||
|
// Whether the account got verified and is allowed to be active
|
||||||
|
// For local accounts being active means being allowed to login and perform interactions
|
||||||
|
// For remote users, if an account is not verified, any interactions it sends are discarded
|
||||||
|
Verified bool
|
||||||
|
// TODO: Turn this into a map to give passkeys names.
|
||||||
|
// Needed for supporting a decent passkey management interface.
|
||||||
|
// Or check if webauthn.Credential has sufficiently easy to identify data
|
||||||
|
// to use instead of a string mapping
|
||||||
|
PasskeyCredentials []webauthn.Credential `gorm:"serializer:json"` // Webauthn credentials data
|
||||||
|
// Has a RemoteAccountLinks included if remote user
|
||||||
|
RemoteLinks *RemoteAccountLinks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains static and cached info about a remote account, mostly links
|
||||||
|
type RemoteAccountLinks struct {
|
||||||
|
// ---- Section: gorm
|
||||||
|
// Sets this struct up as a value that an Account may have
|
||||||
|
gorm.Model
|
||||||
|
AccountID string
|
||||||
|
|
||||||
|
// Just about every link here is optional to accomodate for servers with only minimal accounts
|
||||||
|
// Minimal being handle, ap link and inbox
|
||||||
|
ApLink string
|
||||||
|
ViewLink *string
|
||||||
|
FollowersLink *string
|
||||||
|
FollowingLink *string
|
||||||
|
InboxLink string
|
||||||
|
OutboxLink *string
|
||||||
|
FeaturedLink *string
|
||||||
|
FeaturedTagsLink *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find an account in the db using a given full handle (@max@example.com)
|
||||||
|
// Returns an account and nil if an account is found, otherwise nil and the error
|
||||||
|
func (s *Storage) FindAccountByFullHandle(handle string) (*Account, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Debug().Str("account-handle", handle).Msg("Looking for account by handle")
|
||||||
|
log.Debug().Str("account-handle", handle).Msg("Checking if there's a cache hit")
|
||||||
|
|
||||||
|
// Try and find the account in cache first
|
||||||
|
cacheAccId, err := s.cacheHandleToAccUid(handle)
|
||||||
|
if err == nil {
|
||||||
|
log.Info().Str("account-handle", handle).Msg("Hit account handle in cache")
|
||||||
|
// Then always load via id since unique key access should be faster than string matching
|
||||||
|
return s.FindAccountById(*cacheAccId)
|
||||||
|
} else {
|
||||||
|
if !errors.Is(err, errCacheNotFound) {
|
||||||
|
log.Error().Err(err).Str("account-handle", handle).Msg("Problem while checking cache for account")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed to find in cache, go the slow route of hitting the db
|
||||||
|
log.Debug().Str("account-handle", handle).Msg("Didn't hit account in cache, going to db")
|
||||||
|
name, server, err := activitypub.SplitFullHandle(handle)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Str("account-handle", handle).Msg("Failed to split up account handle")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
acc := Account{}
|
||||||
|
res := s.db.Where("name = ?", name).Where("server = ?", server).First(&acc)
|
||||||
|
if res.Error != nil {
|
||||||
|
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||||
|
log.Info().Str("account-handle", handle).Msg("Account with handle not found")
|
||||||
|
} else {
|
||||||
|
log.Error().Err(err).Str("account-handle", handle).Msg("Failed to get account with handle")
|
||||||
|
}
|
||||||
|
return nil, res.Error
|
||||||
|
}
|
||||||
|
log.Info().Str("account-handle", handle).Msg("Found account, also inserting into cache")
|
||||||
|
if err = s.cache.Set(cacheUserIdToAccPrefix+acc.ID, &acc); err != nil {
|
||||||
|
log.Warn().
|
||||||
|
Err(err).
|
||||||
|
Str("account-handle", handle).
|
||||||
|
Msg("Found account but failed to insert into cache")
|
||||||
|
}
|
||||||
|
if err = s.cache.Set(cacheUserHandleToIdPrefix+strings.TrimLeft(handle, "@"), acc.ID); err != nil {
|
||||||
|
log.Warn().
|
||||||
|
Err(err).
|
||||||
|
Str("account-handle", handle).
|
||||||
|
Msg("Failed to store handle to id in cache")
|
||||||
|
}
|
||||||
|
return &acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find an account given a specific ID
|
||||||
|
func (s *Storage) FindAccountById(id string) (*Account, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Debug().Str("account-id", id).Msg("Looking for account by id")
|
||||||
|
log.Debug().Str("account-id", id).Msg("First trying to hit cache")
|
||||||
|
acc, err := s.cacheAccIdToData(id)
|
||||||
|
if err == nil {
|
||||||
|
log.Info().Str("account-id", id).Msg("Found account in cache")
|
||||||
|
return acc, nil
|
||||||
|
} else if !errors.Is(err, errCacheNotFound) {
|
||||||
|
log.Error().Err(err).Str("account-id", id).Msg("Error while looking for account in cache")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Str("account-id", id).Msg("Didn't hit account in cache, checking db")
|
||||||
|
acc = &Account{}
|
||||||
|
res := s.db.Where(Account{ID: id}).First(acc)
|
||||||
|
if res.Error != nil {
|
||||||
|
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||||
|
log.Warn().Str("account-id", id).Msg("Account not found")
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
} else {
|
||||||
|
log.Error().Err(res.Error).Str("account-id", id).Msg("Failed to look for account")
|
||||||
|
return nil, res.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Info().Str("account-id", id).Msg("Found account in db, also adding to cache")
|
||||||
|
if err = s.cache.Set(cacheUserIdToAccPrefix+id, acc); err != nil {
|
||||||
|
log.Warn().Err(err).Str("account-id", id).Msg("Failed to add account to cache")
|
||||||
|
}
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindLocalAccountByUsername(username string) (*Account, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Debug().Str("account-username", username).Msg("Looking for local account")
|
||||||
|
log.Debug().Str("account-username", username).Msg("Checking cache first")
|
||||||
|
|
||||||
|
// Try and find the account in cache first
|
||||||
|
cacheAccId, err := s.cacheLocalUsernameToAccUid(username)
|
||||||
|
if err == nil {
|
||||||
|
log.Info().Str("account-username", username).Msg("Hit account handle in cache")
|
||||||
|
// Then always load via id since unique key access should be faster than string matching
|
||||||
|
return s.FindAccountById(*cacheAccId)
|
||||||
|
} else {
|
||||||
|
if err != errCacheNotFound {
|
||||||
|
log.Error().Err(err).Str("account-username", username).Msg("Problem while checking cache for account")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed to find in cache, go the slow route of hitting the db
|
||||||
|
log.Debug().Str("account-username", username).Msg("Didn't hit account in cache, going to db")
|
||||||
|
|
||||||
|
acc := Account{}
|
||||||
|
res := s.db.Where("username = ?", username).
|
||||||
|
Where("server_id = ?", serverSelf.ID).
|
||||||
|
First(&acc)
|
||||||
|
if res.Error != nil {
|
||||||
|
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||||
|
log.Info().
|
||||||
|
Str("account-username", username).
|
||||||
|
Msg("Local account with username not found")
|
||||||
|
} else {
|
||||||
|
log.Error().Err(err).Str("account-username", username).Msg("Failed to get local account with username")
|
||||||
|
}
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
}
|
||||||
|
log.Info().Str("account-username", username).Msg("Found account, also inserting into cache")
|
||||||
|
if err = s.cache.Set(cacheUserIdToAccPrefix+acc.ID, &acc); err != nil {
|
||||||
|
log.Warn().
|
||||||
|
Err(err).
|
||||||
|
Str("account-username", username).
|
||||||
|
Msg("Found account but failed to insert into cache")
|
||||||
|
}
|
||||||
|
if err = s.cache.Set(cacheLocalUsernameToIdPrefix+username, acc.ID); err != nil {
|
||||||
|
log.Warn().
|
||||||
|
Err(err).
|
||||||
|
Str("account-username", username).
|
||||||
|
Msg("Failed to store local username to id in cache")
|
||||||
|
}
|
||||||
|
return &acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindAccountByPasskeyId(pkeyId []byte) (*Account, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Debug().Bytes("account-passkey-id", pkeyId).Msg("Looking for account")
|
||||||
|
log.Debug().Bytes("account-passkey-id", pkeyId).Msg("Checking cache first")
|
||||||
|
|
||||||
|
// Try and find the account in cache first
|
||||||
|
cacheAccId, err := s.cachePkeyIdToAccId(pkeyId)
|
||||||
|
if err == nil {
|
||||||
|
log.Info().Bytes("account-passkey-id", pkeyId).Msg("Hit passkey id in cache")
|
||||||
|
// Then always load via id since unique key access should be faster than string matching
|
||||||
|
return s.FindAccountById(*cacheAccId)
|
||||||
|
} else {
|
||||||
|
if err != errCacheNotFound {
|
||||||
|
log.Error().Err(err).Bytes("account-passkey-id", pkeyId).Msg("Problem while checking cache for account")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed to find in cache, go the slow route of hitting the db
|
||||||
|
log.Debug().Bytes("account-passkey-id", pkeyId).Msg("Didn't hit account in cache, going to db")
|
||||||
|
|
||||||
|
acc := Account{}
|
||||||
|
res := s.db.Where("web_authn_id = ?", pkeyId).
|
||||||
|
First(&acc)
|
||||||
|
if res.Error != nil {
|
||||||
|
if res.Error == gorm.ErrRecordNotFound {
|
||||||
|
log.Info().
|
||||||
|
Bytes("account-passkey-id", pkeyId).
|
||||||
|
Msg("Local account with passkey id not found")
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
} else {
|
||||||
|
log.Error().Err(res.Error).Bytes("account-passkey-id", pkeyId).Msg("Failed to get local account with passkey id")
|
||||||
|
return nil, res.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Info().Bytes("account-passkey-id", pkeyId).Msg("Found account, also inserting into cache")
|
||||||
|
// if err = s.cache.Set(cacheUserIdToAccPrefix+acc.ID, &acc); err != nil {
|
||||||
|
// log.Warn().
|
||||||
|
// Err(err).
|
||||||
|
// Bytes("account-passkey-id", pkeyId).
|
||||||
|
// Msg("Found account but failed to insert into cache")
|
||||||
|
// }
|
||||||
|
// if err = s.cache.Set(cachePasskeyIdToAccIdPrefix+string(pkeyId), acc.ID); err != nil {
|
||||||
|
// log.Warn().
|
||||||
|
// Err(err).
|
||||||
|
// Bytes("account-passkey-id", pkeyId).
|
||||||
|
// Msg("Failed to store local username to id in cache")
|
||||||
|
// }
|
||||||
|
return &acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a given account in storage and cache
|
||||||
|
func (s *Storage) UpdateAccount(acc *Account) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
// If the account is nil or doesn't have an id, error out
|
||||||
|
if acc == nil || acc.ID == "" {
|
||||||
|
return ErrInvalidData
|
||||||
|
}
|
||||||
|
res := s.db.Save(acc)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if err := s.cache.Set(cacheUserIdToAccPrefix+acc.ID, acc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new empty account for future use
|
||||||
|
func (s *Storage) NewEmptyAccount() (*Account, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Debug().Msg("Creating new empty account")
|
||||||
|
acc := Account{}
|
||||||
|
// Generate the 64 bit id for passkey and webauthn stuff
|
||||||
|
log.Debug().Msg("Creating webauthn id for new account")
|
||||||
|
data := make([]byte, 64)
|
||||||
|
c, err := rand.Read(data)
|
||||||
|
for err != nil || c != len(data) || c < 64 {
|
||||||
|
data = make([]byte, 64)
|
||||||
|
c, err = rand.Read(data)
|
||||||
|
}
|
||||||
|
log.Debug().Msg("Random webauthn id for new account created")
|
||||||
|
acc.ID = uuid.NewString()
|
||||||
|
|
||||||
|
accountRole, err := s.NewEmptyRole(acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate account role for new account: %w", err)
|
||||||
|
}
|
||||||
|
accountRole.IsUserRole = true
|
||||||
|
if err = s.UpdateRole(accountRole); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate account role for new account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.WebAuthnId = data
|
||||||
|
acc.Relations = []uint{}
|
||||||
|
acc.Tags = []string{}
|
||||||
|
acc.Gender = []string{}
|
||||||
|
acc.CustomFields = []uint{}
|
||||||
|
acc.IdentifiesAs = []Being{}
|
||||||
|
acc.PasskeyCredentials = []webauthn.Credential{}
|
||||||
|
acc.Roles = []string{DefaultUserRole.Name, accountRole.Name}
|
||||||
|
acc.Icon = "placeholder"
|
||||||
|
log.Debug().Any("account", &acc).Msg("Saving new account in db")
|
||||||
|
res := s.db.Save(&acc)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Error().Err(res.Error).Msg("Failed to safe new account")
|
||||||
|
return nil, res.Error
|
||||||
|
}
|
||||||
|
log.Info().Str("account-id", acc.ID).Msg("Created new account")
|
||||||
|
return &acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new local account using the given handle
|
||||||
|
// The handle in this case is only the part before the domain (example: @bob@example.com would have a handle of bob)
|
||||||
|
// It also sets up a bunch of values that tend to be obvious for local accounts
|
||||||
|
func (s *Storage) NewLocalAccount(handle string) (*Account, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Trace().Caller().Send()
|
||||||
|
log.Debug().Str("account-handle", handle).Msg("Creating new local account")
|
||||||
|
acc, err := s.NewEmptyAccount()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to create empty account for use")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
acc.Username = handle
|
||||||
|
// acc.Server = serverSelf
|
||||||
|
acc.ServerId = serverSelf.ID
|
||||||
|
acc.DisplayName = handle
|
||||||
|
|
||||||
|
publicKey, privateKey, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to generate key pair for new local account")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
acc.PrivateKey = privateKey
|
||||||
|
acc.PublicKey = publicKey
|
||||||
|
|
||||||
|
log.Debug().
|
||||||
|
Str("account-handle", handle).
|
||||||
|
Str("account-id", acc.ID).
|
||||||
|
Msg("Saving new local account")
|
||||||
|
res := s.db.Save(acc)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Error().Err(res.Error).Any("account-full", acc).Msg("Failed to save local account")
|
||||||
|
return nil, res.Error
|
||||||
|
}
|
||||||
|
log.Info().
|
||||||
|
Str("account-handle", handle).
|
||||||
|
Str("account-id", acc.ID).
|
||||||
|
Msg("Created new local account")
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteAccount(accId string) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return s.db.Delete(&Account{ID: accId}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Section WebAuthn.User
|
||||||
|
// Implements the webauthn.User interface for interaction with passkeys
|
||||||
|
|
||||||
|
func (a *Account) WebAuthnID() []byte {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return a.WebAuthnId
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Account) WebAuthnName() string {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Account) WebAuthnDisplayName() string {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return u.DisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Account) WebAuthnCredentials() []webauthn.Credential {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return u.PasskeyCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Account) WebAuthnIcon() string {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Section passkey.User
|
||||||
|
// Implements the passkey.User interface
|
||||||
|
|
||||||
|
func (u *Account) PutCredential(new webauthn.Credential) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
u.PasskeyCredentials = append(u.PasskeyCredentials, new)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section passkey.UserStore
|
||||||
|
// Implements the passkey.UserStore interface
|
||||||
|
|
||||||
|
func (s *Storage) GetOrCreateUser(userID string) passkey.User {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Debug().
|
||||||
|
Str("account-handle", userID).
|
||||||
|
Msg("Looking for or creating account for passkey stuff")
|
||||||
|
acc := &Account{}
|
||||||
|
res := s.db.Where(Account{Username: userID, ServerId: serverSelf.ID}).
|
||||||
|
First(acc)
|
||||||
|
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||||
|
log.Debug().Str("account-handle", userID)
|
||||||
|
var err error
|
||||||
|
acc, err = s.NewLocalAccount(userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("account-handle", userID).
|
||||||
|
Msg("Failed to create new account for webauthn request")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetUserByWebAuthnId(id []byte) passkey.User {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
log.Debug().Bytes("webauthn-id", id).Msg("Looking for account with webauthn id")
|
||||||
|
acc := Account{}
|
||||||
|
res := s.db.Where(Account{WebAuthnId: id}).First(&acc)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(res.Error).
|
||||||
|
Bytes("webauthn-id", id).
|
||||||
|
Msg("Failed to find user with webauthn ID")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Info().Msg("Found account with given webauthn id")
|
||||||
|
return &acc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) SaveUser(rawUser passkey.User) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
user, ok := rawUser.(*Account)
|
||||||
|
if !ok {
|
||||||
|
log.Error().Any("raw-user", rawUser).Msg("Failed to cast raw user to db account")
|
||||||
|
}
|
||||||
|
s.db.Save(user)
|
||||||
|
}
|
21
storage/userIdentType.go
Normal file
21
storage/userIdentType.go
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import "git.mstar.dev/mstar/goutils/sliceutils"
|
||||||
|
|
||||||
|
// What kind of being a user identifies as
|
||||||
|
type Being string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BEING_HUMAN = Being("human")
|
||||||
|
BEING_CAT = Being("cat")
|
||||||
|
BEING_FOX = Being("fox")
|
||||||
|
BEING_DOG = Being("dog")
|
||||||
|
BEING_ROBOT = Being("robot")
|
||||||
|
BEING_DOLL = Being("doll")
|
||||||
|
)
|
||||||
|
|
||||||
|
var allBeings = []Being{BEING_HUMAN, BEING_CAT, BEING_FOX, BEING_DOG, BEING_ROBOT, BEING_DOLL}
|
||||||
|
|
||||||
|
func IsValidBeing(toCheck string) bool {
|
||||||
|
return sliceutils.Contains(allBeings, Being(toCheck))
|
||||||
|
}
|
71
storage/userInfoFields.go
Normal file
71
storage/userInfoFields.go
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.mstar.dev/mstar/linstrom/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Describes a custom attribute field for accounts
|
||||||
|
type UserInfoField struct {
|
||||||
|
gorm.Model // Can actually just embed this as is here as those are not something directly exposed :3
|
||||||
|
Name string
|
||||||
|
Value string
|
||||||
|
LastUrlCheckDate *time.Time // Used if the value is an url to somewhere. Empty if value is not an url
|
||||||
|
// If the value is an url, this attribute indicates whether Linstrom was able to verify ownership
|
||||||
|
// of the provided url via the common method of
|
||||||
|
// "Does the target url contain a rel='me' link to the owner's account"
|
||||||
|
Confirmed bool
|
||||||
|
BelongsTo string // Id of account this info field belongs to
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add functions to store, load, update and delete these
|
||||||
|
|
||||||
|
func (s *Storage) FindUserFieldById(id uint) (*UserInfoField, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
entry := UserInfoField{}
|
||||||
|
err := s.db.First(&entry, id).Error
|
||||||
|
switch err {
|
||||||
|
case nil:
|
||||||
|
return &entry, nil
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) FindMultipleUserFieldsById(ids []uint) ([]UserInfoField, error) {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
entries := []UserInfoField{}
|
||||||
|
err := s.db.Where(ids).Find(&entries).Error
|
||||||
|
switch err {
|
||||||
|
case gorm.ErrRecordNotFound:
|
||||||
|
return nil, ErrEntryNotFound
|
||||||
|
case nil:
|
||||||
|
return entries, nil
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) AddNewUserField(name, value, belongsToId string) (*UserInfoField, error) {
|
||||||
|
// TODO: Implement me
|
||||||
|
panic("Not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteUserField(id uint) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return s.db.Delete(UserInfoField{Model: gorm.Model{ID: id}}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteAllUserFieldsForAccountId(id string) error {
|
||||||
|
defer shared.Untrace(shared.Trace(&log.Logger))
|
||||||
|
return s.db.Model(&UserInfoField{}).
|
||||||
|
Where(&UserInfoField{BelongsTo: id}).
|
||||||
|
Delete(&UserInfoField{}).
|
||||||
|
Error
|
||||||
|
}
|
Loading…
Reference in a new issue