Compare commits

...

2 commits

Author SHA1 Message Date
6cc699cbbd
Clean up unused code and better shutdown in main
All checks were successful
/ docker (push) Successful in 4m4s
2025-05-22 16:56:56 +02:00
b01f60d273
Add placeholder user for events by unknown actors 2025-05-22 15:22:54 +02:00
63 changed files with 118 additions and 5560 deletions

View file

@ -1,17 +0,0 @@
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}
}

View file

@ -1,116 +0,0 @@
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
View file

@ -5,24 +5,22 @@ import (
"embed"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"path"
"time"
"sync"
"git.mstar.dev/mstar/goutils/other"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/mstarongithub/passkey"
"github.com/rs/zerolog/log"
"gopkg.in/natefinch/lumberjack.v2"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/config"
"git.mstar.dev/mstar/linstrom/server"
"git.mstar.dev/mstar/linstrom/shared"
"git.mstar.dev/mstar/linstrom/storage"
storagenew "git.mstar.dev/mstar/linstrom/storage-new"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage/cache"
webdebug "git.mstar.dev/mstar/linstrom/web/debug"
webpublic "git.mstar.dev/mstar/linstrom/web/public"
)
@ -43,6 +41,8 @@ var defaultDuck string
var duckFS embed.FS
func main() {
_ = reactiveFS
_ = nojsFS
other.SetupFlags()
flag.Parse()
logfile := getLogFilePathOrNil()
@ -71,13 +71,7 @@ func main() {
if *shared.FlagConfigOnly {
return
}
if *shared.FlagStartNew {
log.Info().Msg("Starting new system")
newServer()
} else {
log.Info().Msg("Starting old system")
oldServer()
}
newServer()
}
func getLogFilePathOrNil() *string {
@ -90,57 +84,8 @@ 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() {
log.Info().Msg("Connectin to db")
log.Info().Msg("Connecting to db")
db, err := gorm.Open(
postgres.Open(config.GlobalConfig.Storage.BuildPostgresDSN()),
&gorm.Config{
@ -159,21 +104,49 @@ func newServer() {
if err = storagenew.InsertSelf(); err != nil {
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 {
go func() {
log.Info().Msg("Starting debug server")
if err = webdebug.New(*shared.FlagDebugPort).Start(); err != nil {
s := webdebug.New(*shared.FlagDebugPort)
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.Info().Msg("Starting public server")
public := webpublic.New(
fmt.Sprintf(":%v", config.GlobalConfig.General.PrivatePort),
&defaultDuck,
duckFS,
)
if err = public.Start(); err != nil {
log.Fatal().Err(err).Msg("Failed to start public server")
go func() {
log.Info().Msg("Starting 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()
}

View file

@ -1 +0,0 @@
package plugins

View file

@ -1,5 +0,0 @@
package plugins
// TODO: Think about how to enable server side processing plugins
// Options: Lua, wasm/wasi (probably prefered)

View file

@ -1,4 +0,0 @@
// 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

View file

@ -1,5 +0,0 @@
// 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

View file

@ -1,186 +0,0 @@
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
}

View file

@ -1,375 +0,0 @@
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) {}

View file

@ -1,8 +0,0 @@
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) {}

View file

@ -1,158 +0,0 @@
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) {}

View file

@ -1,24 +0,0 @@
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)
}

View file

@ -1,184 +0,0 @@
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
}

View file

@ -1,8 +0,0 @@
// 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,}
}

View file

@ -1,118 +0,0 @@
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"`
}

View file

@ -1,59 +0,0 @@
// 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"`
}

View file

@ -1,288 +0,0 @@
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
}

View file

@ -1,23 +0,0 @@
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
)

View file

@ -1,38 +0,0 @@
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)
}

View file

@ -1,22 +0,0 @@
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
}

View file

@ -1,77 +0,0 @@
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))
}

View file

@ -1,239 +0,0 @@
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)
}
}

View file

@ -1,237 +0,0 @@
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)
})
}
}

View file

@ -1,13 +0,0 @@
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

View file

@ -1 +0,0 @@
package server

View file

@ -1,98 +0,0 @@
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()
}

View file

@ -1,16 +0,0 @@
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
}

View file

@ -1,55 +0,0 @@
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)
}

View file

@ -8,6 +8,9 @@ const (
// where multiple releases in a day are required
Version = "0.0.1 pre-alpha"
// 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"
)

View file

@ -19,7 +19,6 @@ var (
false,
"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(
"debugserver",
false,

74
storage-new/unknown.go Normal file
View file

@ -0,0 +1,74 @@
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
}

View file

@ -1,44 +0,0 @@
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")
}

View file

@ -1,25 +0,0 @@
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
}
}

View file

@ -1,121 +0,0 @@
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
View file

@ -1,105 +0,0 @@
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)
}

View file

@ -1,164 +0,0 @@
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()
}

View file

@ -1,35 +0,0 @@
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,
}
}

View file

@ -1,25 +0,0 @@
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
}
}

View file

@ -1,15 +0,0 @@
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")

View file

@ -1,64 +0,0 @@
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
}

View file

@ -1,11 +0,0 @@
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
//

View file

@ -1,68 +0,0 @@
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
}

View file

@ -1,26 +0,0 @@
// 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]]
}

View file

@ -1,95 +0,0 @@
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
}

View file

@ -1,91 +0,0 @@
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
}

View file

@ -1,59 +0,0 @@
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
}

View file

@ -1,41 +0,0 @@
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
}

View file

@ -1,37 +0,0 @@
// 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) + ")"
}
}

View file

@ -1,164 +0,0 @@
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})
}

View file

@ -1,65 +0,0 @@
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
}

View file

@ -1,60 +0,0 @@
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})
}

View file

@ -1,10 +0,0 @@
package storage
import "gorm.io/gorm"
type Reaction struct {
gorm.Model
NoteId string
ReactorId string
EmoteId uint
}

View file

@ -1,113 +0,0 @@
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
}

View file

@ -1,7 +0,0 @@
package storage
// TODO: More helper stuff
func (s *Storage) NewRemoteUser(fullHandle string) (*Account, error) {
return nil, nil
}

View file

@ -1,253 +0,0 @@
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
}

View file

@ -1,244 +0,0 @@
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,
}

File diff suppressed because one or more lines are too long

View file

@ -1,39 +0,0 @@
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")
}
}

View file

@ -1,164 +0,0 @@
// 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
}

View file

@ -1,60 +0,0 @@
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
}

View file

@ -1,483 +0,0 @@
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)
}

View file

@ -1,21 +0,0 @@
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))
}

View file

@ -1,71 +0,0 @@
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
}