Compare commits

..

5 commits

Author SHA1 Message Date
8f8ad3035a
Comment all new code
Some checks are pending
/ test (push) Waiting to run
2025-04-02 15:33:07 +02:00
b6f12b7acf
Update code style 2025-04-02 13:45:54 +02:00
17e8c1073f
Note for a fix needed to be done 2025-04-02 13:45:32 +02:00
77f06c752e
Also add error for previous changes 2025-04-02 13:45:04 +02:00
420f6e46c0
Add initial feed structure, fix passkey id usage 2025-04-02 13:44:33 +02:00
38 changed files with 326 additions and 214 deletions

View file

@ -12,9 +12,11 @@
- Always use zerolog for console output. In http handlers, - Always use zerolog for console output. In http handlers,
use `hlog.FromRequest` to get a logger instance prepared with a bunch of metadata use `hlog.FromRequest` to get a logger instance prepared with a bunch of metadata
- As Linstrom is both intended for active use as well as providing a learning resource, - As Linstrom is both intended for active use as well as providing a learning resource,
all functions and structs must be documented all functions and structs must be documented and follow the conventions on [https://tip.golang.org/doc/comment]
- Errors returned from public functions must be wrapped with `git.mstar.dev/mstar/goutils/other.Error` - Errors returned from public functions must either be wrapped with `git.mstar.dev/mstar/goutils/other.Error`
and given appropriate descriptive information and given appropriate descriptive information
_or_ return an error defined (as public variable) in that package.
Example: `other.Error("auth", "failed to do something important", originalError)`
## JS/TS ## JS/TS

View file

@ -13,10 +13,13 @@ import (
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
) )
// An Authenticator is used for authenticating user requests against the server
type Authenticator struct { type Authenticator struct {
webauthn *webauthn.WebAuthn webauthn *webauthn.WebAuthn
recentlyUsedTotpTokens map[string]time.Time recentlyUsedTotpTokens map[string]time.Time
} }
// The next state of a login process
type LoginNextState uint8 type LoginNextState uint8
const ( const (

View file

@ -21,6 +21,11 @@ var (
// The given totp token was recently (90 seconds) used for that username // The given totp token was recently (90 seconds) used for that username
// For security reasons, this case will be caught and blocked // For security reasons, this case will be caught and blocked
ErrTotpRecentlyUsed = errors.New("totp token was used too recently") ErrTotpRecentlyUsed = errors.New("totp token was used too recently")
// The stored data for a passkey registration wasn't formatted correctly
// and thus can't be used
ErrInvalidPasskeyRegistrationData = errors.New(
"stored passkey registration data was formatted badly",
)
) )
// Helper error type to combine two errors into one // Helper error type to combine two errors into one

View file

@ -18,6 +18,7 @@ import (
// Len of salt for passwords in bytes // Len of salt for passwords in bytes
const saltLen = 32 const saltLen = 32
// Generate a random salt with the given nr of bytes
func generateSalt(length int) ([]byte, error) { func generateSalt(length int) ([]byte, error) {
salt := make([]byte, length) salt := make([]byte, length)
if _, err := rand.Read(salt); err != nil { if _, err := rand.Read(salt); err != nil {
@ -26,18 +27,19 @@ func generateSalt(length int) ([]byte, error) {
return salt, nil return salt, nil
} }
func hashPassword(password string) ([]byte, error) { // Hash a password with salt
func hashPassword(password string) (hash []byte, err error) {
salt, err := generateSalt(saltLen) salt, err := generateSalt(saltLen)
if err != nil { if err != nil {
return nil, err return nil, err
} }
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) hash = argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
hash = append(hash, salt...) hash = append(hash, salt...)
// return bcrypt.GenerateFromPassword([]byte(password), 14) // return bcrypt.GenerateFromPassword([]byte(password), 14)
return hash, nil return hash, nil
} }
// Compare a raw password against a hash + salt // Check wether a password matches a hash
func comparePassword(password string, hash []byte) bool { func comparePassword(password string, hash []byte) bool {
// Hash is actually hash(password)+salt // Hash is actually hash(password)+salt
salt := hash[len(hash)-saltLen:] salt := hash[len(hash)-saltLen:]
@ -47,6 +49,7 @@ func comparePassword(password string, hash []byte) bool {
// Copied and adjusted from: https://bruinsslot.jp/post/golang-crypto/ // Copied and adjusted from: https://bruinsslot.jp/post/golang-crypto/
// Encrypt some data using a key
func Encrypt(key, data []byte) ([]byte, error) { func Encrypt(key, data []byte) ([]byte, error) {
key, salt, err := deriveKey(key, nil) key, salt, err := deriveKey(key, nil)
if err != nil { if err != nil {
@ -75,6 +78,7 @@ func Encrypt(key, data []byte) ([]byte, error) {
return ciphertext, nil return ciphertext, nil
} }
// Decrypt some data using a key
func Decrypt(key, data []byte) ([]byte, error) { func Decrypt(key, data []byte) ([]byte, error) {
salt, data := data[len(data)-32:], data[:len(data)-32] salt, data := data[len(data)-32:], data[:len(data)-32]
@ -103,6 +107,8 @@ func Decrypt(key, data []byte) ([]byte, error) {
return plaintext, nil return plaintext, nil
} }
// Derive a key from a password and optionally salt.
// Returns the hash and salt used
func deriveKey(password, salt []byte) ([]byte, []byte, error) { func deriveKey(password, salt []byte) ([]byte, []byte, error) {
if salt == nil { if salt == nil {
salt = make([]byte, 32) salt = make([]byte, 32)
@ -115,12 +121,16 @@ func deriveKey(password, salt []byte) ([]byte, []byte, error) {
return key, salt, nil return key, salt, nil
} }
// Calculate the expiration timestamp from the call of the function
func calcAccessExpirationTimestamp() time.Time { func calcAccessExpirationTimestamp() time.Time {
// For now, the default expiration is one month after creation // For now, the default expiration is one month after creation
// though "never" might also be a good option // though "never" might also be a good option
return time.Now().Add(time.Hour * 24 * 30) return time.Now().Add(time.Hour * 24 * 30)
} }
// Convert a list of authentication methods into a [LoginNextState] bitflag.
// [isStart] determines whether to allow authentication methods that start
// a login process or complete one
func ConvertNewStorageAuthMethodsToLoginState( func ConvertNewStorageAuthMethodsToLoginState(
methods []models.AuthenticationMethodType, methods []models.AuthenticationMethodType,
isStart bool, isStart bool,
@ -141,6 +151,7 @@ func ConvertNewStorageAuthMethodsToLoginState(
) )
} }
// Translate one [models.AuthenticationMethodType] to one [LoginNextState]
func oneStorageAuthToLoginState(in models.AuthenticationMethodType) LoginNextState { func oneStorageAuthToLoginState(in models.AuthenticationMethodType) LoginNextState {
switch in { switch in {
case models.AuthMethodGAuth: case models.AuthMethodGAuth:

View file

@ -3,6 +3,7 @@ package auth
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"time" "time"
"git.mstar.dev/mstar/goutils/other" "git.mstar.dev/mstar/goutils/other"
@ -26,38 +27,40 @@ import (
// Returns the credential options the passkey needs to sign // Returns the credential options the passkey needs to sign
func (a *Authenticator) StartPasskeyLogin( func (a *Authenticator) StartPasskeyLogin(
username string, username string,
) (*protocol.CredentialAssertion, uint64, error) { ) (*protocol.CredentialAssertion, string, error) {
if ok, err := a.canUsernameLogin(username); !ok { if ok, err := a.canUsernameLogin(username); !ok {
return nil, 0, other.Error("auth", "user may not login", err) return nil, "", other.Error("auth", "user may not login", err)
} }
acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First() acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
if err != nil { if err != nil {
return nil, 0, other.Error("auth", "failed to acquire user for login", err) return nil, "", other.Error("auth", "failed to acquire user for login", err)
} }
wrappedAcc := fakeUser{acc} wrappedAcc := fakeUser{acc}
options, session, err := a.webauthn.BeginLogin(&wrappedAcc) options, session, err := a.webauthn.BeginLogin(&wrappedAcc)
if err != nil { if err != nil {
return nil, 0, other.Error("auth", "failed to initiate passkey login", err) return nil, "", other.Error("auth", "failed to initiate passkey login", err)
} }
pkeySession := models.LoginProcessToken{ pkeySession := models.LoginProcessToken{
User: *acc, User: *acc,
UserId: acc.ID, UserId: acc.ID,
ExpiresAt: time.Now().Add(time.Minute * 3), ExpiresAt: time.Now().Add(time.Minute * 3),
Token: string(other.Must(json.Marshal(session))), // Abuse name for session storage since token must be a uuid
Name: "", Name: string(other.Must(json.Marshal(session))),
} }
err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).Create(&pkeySession) err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).
Omit(dbgen.LoginProcessToken.Token).
Create(&pkeySession)
if err != nil { if err != nil {
return nil, 0, other.Error("auth", "failed to create login process token", err) return nil, "", other.Error("auth", "failed to create login process token", err)
} }
return options, pkeySession.ID, nil return options, pkeySession.Token, nil
} }
// Complete a passkey login request // Complete a passkey login request
// Takes the username logging in as well as the raw request containing the passkey response // Takes the username logging in as well as the raw request containing the passkey response
func (a *Authenticator) CompletePasskeyLogin( func (a *Authenticator) CompletePasskeyLogin(
username string, username string,
sessionId uint64, sessionId string,
response *http.Request, response *http.Request,
) (accessToken string, err error) { ) (accessToken string, err error) {
// Get user in question // Get user in question
@ -66,7 +69,7 @@ func (a *Authenticator) CompletePasskeyLogin(
return "", other.Error("auth", "failed to get user for passkey login completion", err) return "", other.Error("auth", "failed to get user for passkey login completion", err)
} }
// Get latest login token data // Get latest login token data
loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.ID.Eq(sessionId)). loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.Token.Eq(sessionId)).
First() First()
if err != nil { if err != nil {
return "", other.Error( return "", other.Error(
@ -80,7 +83,7 @@ func (a *Authenticator) CompletePasskeyLogin(
return "", ErrProcessTimeout return "", ErrProcessTimeout
} }
var pkeySession webauthn.SessionData var pkeySession webauthn.SessionData
err = json.Unmarshal([]byte(loginToken.Token), &pkeySession) err = json.Unmarshal([]byte(loginToken.Name), &pkeySession)
if err != nil { if err != nil {
return "", other.Error("auth", "failed to unmarshal passkey session for user", err) return "", other.Error("auth", "failed to unmarshal passkey session for user", err)
} }
@ -89,6 +92,9 @@ func (a *Authenticator) CompletePasskeyLogin(
if err != nil { if err != nil {
return "", other.Error("auth", "passkey completion failed", err) return "", other.Error("auth", "passkey completion failed", err)
} }
// TODO: Utilise clone warning
// newSession.Authenticator.CloneWarning
jsonSessionId, err := json.Marshal(newSession.ID) jsonSessionId, err := json.Marshal(newSession.ID)
if err != nil { if err != nil {
return "", other.Error("auth", "failed to marshal session", err) return "", other.Error("auth", "failed to marshal session", err)
@ -129,98 +135,84 @@ func (a *Authenticator) CompletePasskeyLogin(
// Start the process of registrating a passkey to an account // Start the process of registrating a passkey to an account
func (a *Authenticator) StartPasskeyRegistration( func (a *Authenticator) StartPasskeyRegistration(
username string, username string,
) (*protocol.CredentialAssertion, error) { passkeyName string,
// p.l.Infof("begin registration") ) (*protocol.CredentialCreation, string, error) {
// if ok, err := a.canUsernameLogin(username); !ok {
// // can we actually do not use the username at all? return nil, "", other.Error("auth", "user may not login", err)
// username, err := getUsername(r) }
// acc, err := dbgen.User.Where(dbgen.User.Username.Eq(username)).First()
// if err != nil { if err != nil {
// p.l.Errorf("can't get username: %s", err.Error()) return nil, "", other.Error("auth", "failed to acquire user for login", err)
// JSONResponse(w, fmt.Sprintf("can't get username: %s", err.Error()), http.StatusBadRequest) }
// wrappedAcc := fakeUser{acc}
// return options, session, err := a.webauthn.BeginRegistration(&wrappedAcc)
// } jsonSession, err := json.Marshal(session)
// if err != nil {
// user := p.userStore.GetOrCreateUser(username) return nil, "", other.Error("auth", "failed to marshal session to json", err)
// }
// options, session, err := p.webAuthn.BeginRegistration(user) pkeySession := models.LoginProcessToken{
// User: *acc,
// if err != nil { UserId: acc.ID,
// msg := fmt.Sprintf("can't begin registration: %s", err.Error()) ExpiresAt: time.Now().Add(time.Minute * 3),
// p.l.Errorf(msg) // Abuse name for storing session and passkey name since token must be a uuid
// JSONResponse(w, msg, http.StatusBadRequest) Name: passkeyName + "---" + string(jsonSession),
// }
// return err = dbgen.LoginProcessToken.Clauses(clause.OnConflict{UpdateAll: true}).
// } Omit(dbgen.LoginProcessToken.Token).
// Create(&pkeySession)
// // Make a session key and store the sessionData values if err != nil {
// t, err := p.sessionStore.GenSessionID() return nil, "", other.Error("auth", "failed to create login process token", err)
// }
// if err != nil { return options, pkeySession.Token, nil
// p.l.Errorf("can't generate session id: %s", err.Error())
// JSONResponse(
// w,
// fmt.Sprintf("can't generate session id: %s", err.Error()),
// http.StatusInternalServerError,
// )
//
// return
// }
//
// p.sessionStore.SaveSession(t, session)
// p.setSessionCookie(w, t)
//
// // return the options generated with the session key
// // options.publicKey contain our registration options
// JSONResponse(w, options, http.StatusOK)
panic("Not implemented") // TODO: Implement me
} }
func (a *Authenticator) CompletePasskeyRegistration(username string) error { func (a *Authenticator) CompletePasskeyRegistration(
// // Get the session key from cookie username string,
// sid, err := r.Cookie(p.cookieSettings.Name) sessionId string,
// response *http.Request,
// if err != nil { ) error {
// p.l.Errorf("can't get session id: %s", err.Error()) // Get latest login token data
// JSONResponse(w, fmt.Sprintf("can't get session id: %s", err.Error()), http.StatusBadRequest) loginToken, err := dbgen.LoginProcessToken.Where(dbgen.LoginProcessToken.Token.Eq(sessionId)).
// First()
// return if err != nil {
// } return other.Error(
// "auth",
// // Get the session data stored from the function above "failed to get user's login token for passkey login completion",
// session, ok := p.sessionStore.GetSession(sid.Value) err,
// )
// if !ok { }
// p.l.Errorf("can't get session data") // Check if that token has expired
// JSONResponse(w, "can't get session data", http.StatusBadRequest) if loginToken.ExpiresAt.Before(time.Now()) {
// return ErrProcessTimeout
// return }
// } var pkeySession webauthn.SessionData
// passkeyName, jsonSession, found := strings.Cut(loginToken.Name, "---")
// user := p.userStore.GetUserByWebAuthnId(session.UserID) // Get the user if !found {
// return ErrInvalidPasskeyRegistrationData
// credential, err := p.webAuthn.FinishRegistration(user, *session, r) }
// err = json.Unmarshal([]byte(jsonSession), &pkeySession)
// if err != nil { if err != nil {
// msg := fmt.Sprintf("can't finish registration: %s", err.Error()) return other.Error("auth", "failed to unmarshal passkey session for user", err)
// p.l.Errorf(msg) }
// wrappedAcc := fakeUser{&loginToken.User}
// p.deleteSessionCookie(w) credential, err := a.webauthn.FinishRegistration(&wrappedAcc, pkeySession, response)
// JSONResponse(w, msg, http.StatusBadRequest) if err != nil {
// return other.Error("auth", "failed to complete passkey registration", err)
// return }
// } jsonCredential, err := json.Marshal(credential)
// if err != nil {
// // If creation was successful, store the credential object return other.Error("auth", "failed to marshal credential to json", err)
// user.PutCredential(*credential) }
// p.userStore.SaveUser(user) authData := models.UserAuthMethod{
// User: loginToken.User,
// p.sessionStore.DeleteSession(sid.Value) UserId: loginToken.UserId,
// p.deleteSessionCookie(w) AuthMethod: models.AuthMethodPasskey,
// Name: passkeyName,
// p.l.Infof("finish registration") Token: jsonCredential,
// JSONResponse(w, "Registration Success", http.StatusOK) }
panic("Not implemented") // TODO: Implement me err = dbgen.UserAuthMethod.Create(&authData)
if err != nil {
return other.Error("auth", "failed to insert new auth method into db", err)
}
return nil
} }

View file

@ -20,6 +20,7 @@ import (
const totpUnverifiedSuffix = "-NOT_VERIFIED" const totpUnverifiedSuffix = "-NOT_VERIFIED"
const totpTokenNoLongerRecentlyUsed = time.Second * 90 const totpTokenNoLongerRecentlyUsed = time.Second * 90
// Perform a 2nd factor totp based login
func (a *Authenticator) PerformTotpLogin( func (a *Authenticator) PerformTotpLogin(
username string, username string,
sessionId uint64, sessionId uint64,
@ -119,6 +120,8 @@ func (a *Authenticator) PerformTotpLogin(
return LoginNextSucess, token.Token, nil return LoginNextSucess, token.Token, nil
} }
// Create a new totp key for a user.
// The key is marked as not verified until it is sucessfully used once
func (a *Authenticator) StartTotpRegistration( func (a *Authenticator) StartTotpRegistration(
username string, username string,
tokenName string, tokenName string,

View file

@ -1,5 +1,5 @@
/* /*
Tool for generating helper functions for storage.Role structs inside of the storage package Tool for generating helper functions for [new-storage.Role] structs inside of the storage package
It generates the following functions: It generates the following functions:
- CollapseRolesIntoOne: Collapse a list of roles into one singular role. Each value will be set to the - CollapseRolesIntoOne: Collapse a list of roles into one singular role. Each value will be set to the
value of the role with the highest priority value of the role with the highest priority
@ -116,7 +116,12 @@ func main() {
// Build role collapse function // Build role collapse function
outBuilder.WriteString( outBuilder.WriteString(
`func CollapseRolesIntoOne(roles ...models.Role) models.Role { `// CollapseRolesIntoOne takes a list of roles and collapses them down into one.
// It ensures to follow the priority of each role.
// All results will use [models.DefaultUserRole] as the baseline.
// The resulting role will have each entry filled with the value of the highest priority.
// If multiple roles have the same priority, the order in which they are applied is not stable.
func CollapseRolesIntoOne(roles ...models.Role) models.Role {
startingRole := RoleDeepCopy(models.DefaultUserRole) startingRole := RoleDeepCopy(models.DefaultUserRole)
slices.SortFunc(roles, func(a, b models.Role) int { return int(int64(a.Priority)-int64(b.Priority)) }) slices.SortFunc(roles, func(a, b models.Role) int { return int(int64(a.Priority)-int64(b.Priority)) })
for _, role := range roles { for _, role := range roles {
@ -143,7 +148,12 @@ func main() {
`) `)
// Then build the deep copy function // Then build the deep copy function
outBuilder.WriteString("\nfunc RoleDeepCopy(o models.Role) models.Role {\n") outBuilder.WriteString(`
// RoleDeepCopy performs a deep copy of a given role.
// Each element will point to a newly stored value.
// The new and old role will contain identical information.
func RoleDeepCopy(o models.Role) models.Role {
`)
outBuilder.WriteString(` n := models.Role{} outBuilder.WriteString(` n := models.Role{}
n.Model = o.Model n.Model = o.Model
n.Name = o.Name n.Name = o.Name
@ -165,7 +175,10 @@ func main() {
outBuilder.WriteString(" return n\n}\n\n") outBuilder.WriteString(" return n\n}\n\n")
// Build compare function // Build compare function
outBuilder.WriteString("func CompareRoles(a, b *models.Role) bool {\n") outBuilder.WriteString(`// Compare two roles for equality.
// If a permission is nil in one of the roles, that permission is ignored.
func CompareRoles(a, b *models.Role) bool {
`)
outBuilder.WriteString(" return ") outBuilder.WriteString(" return ")
lastName, lastType := "", "" lastName, lastType := "", ""
for valName, valType := range nameTypeMap { for valName, valType := range nameTypeMap {

View file

@ -1,3 +1,7 @@
/*
migrate-new is a helper script for auto-migrating Linstrom's
database layout into a database defined in the given config
*/
package main package main
import ( import (

View file

@ -1,3 +1,7 @@
/*
model-gen generates the gorm-gen interface for interacting
with the database. It does not perform migrations on the database.
*/
package main package main
import ( import (

View file

@ -11,6 +11,7 @@ import (
"git.mstar.dev/mstar/linstrom/storage-new/models" "git.mstar.dev/mstar/linstrom/storage-new/models"
) )
// Auto-migrate all tables and types used
func Migrate(db *gorm.DB) error { func Migrate(db *gorm.DB) error {
if err := createAccountAuthMethodType(db); err != nil { if err := createAccountAuthMethodType(db); err != nil {
return other.Error("storage", "Failed to create Auth Method type", err) return other.Error("storage", "Failed to create Auth Method type", err)

View file

@ -1,11 +1,14 @@
package models package models
// A list of all models stored in the database
var AllTypes = []any{ var AllTypes = []any{
&Emote{}, &Emote{},
&Feed{},
&MediaMetadata{}, &MediaMetadata{},
&Note{}, &Note{},
&NoteToAttachment{}, &NoteToAttachment{},
&NoteToEmote{}, &NoteToEmote{},
&NoteToFeed{},
&NoteToPing{}, &NoteToPing{},
&NoteTag{}, &NoteTag{},
&Reaction{}, &Reaction{},

View file

@ -2,15 +2,17 @@ package models
import "gorm.io/gorm" import "gorm.io/gorm"
// An emote is effectively an assignment of a name and server // Emotes are combinations of a name, the server it originated from
// and the media for it
//
// TODO: Include the case of unicode icons being used as emote
type Emote struct { type Emote struct {
gorm.Model gorm.Model // Standard gorm model for id and timestamps
// Media used for this emote Metadata MediaMetadata // The media used by this emote
Metadata MediaMetadata // `gorm:"foreignKey:MetadataId"` MetadataId string // Id of the media information, primarily for gorm
MetadataId string
// Name of the emote. Also the text for using it in a message (ex. :bob:) // Name of the emote. Also the text for using it in a message (ex. :bob:)
Name string Name string
// Server the emote is from
Server RemoteServer // `gorm:"foreignKey:ServerId;references:ID"` Server RemoteServer // Server the emote is from
ServerId uint ServerId uint // Id of the server
} }

View file

@ -0,0 +1,33 @@
package models
import (
"database/sql"
"gorm.io/gorm"
)
// A feed is the initial entry point for inbound Activitypub events.
// However, its primary and only user-facing use case is to be a collection
// of inbound messages, nothing else.
//
// Feeds are split into two groups, default and non-default.
// Default feeds are feeds automatically created for each user, where their normal
// timeline lives in. Additionally, they also relay inbound non-note events,
// such as likes/reactions, boosts or follow requests, to their owner.
// Default feeds also act using their owner's username (others would ge a follow request from
// `username@host`).
//
// Non-default feeds, in comparison, are explicitly created by users and can be shared
// between them. Thus, they also only accept note events, dropping everything else.
// They also are explicitly labeled as such when issuing follow requests (ex `somename-feed@host`)
type Feed struct {
gorm.Model
// The name of the feed. Will be equal to the owner username if a default feed
Name string
Owner User // The owner of the feed
OwnerId string // Id of the owner
IsDefault bool // Whether the feed is the default one for the user
// If a feed is the default one for a user, use that user's public key.
// Otherwise, use its own key
PublicKey sql.NullString
}

View file

@ -1,5 +0,0 @@
package models
// TODO: Struct for mapping a note to a user for their personal feed
// Storing timeline info in redis could also be an idea, but I kinda like
// everything being in one place

View file

@ -6,9 +6,10 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// Metadata for describing some media // MediaMetadata contains metadata about some media file.
// Media is, at least for Linstrom, always stored on a separate server, // These files are never stored directly by Linstrom.
// be that the remote server it originated from or an s3 bucket // Instead, they are either stored on the remote server they originated from
// or an s3 bucket if uploaded to Linstrom.
type MediaMetadata struct { type MediaMetadata struct {
ID string `gorm:"primarykey;type:uuid;default:gen_random_uuid()"` // The unique ID of this media file ID string `gorm:"primarykey;type:uuid;default:gen_random_uuid()"` // The unique ID of this media file
CreatedAt time.Time // When this entry was created CreatedAt time.Time // When this entry was created
@ -17,7 +18,7 @@ type MediaMetadata struct {
// Soft delete means that this entry still exists in the db, but gorm won't include it anymore unless specifically told to // 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 // If not null, this entry is marked as deleted
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
OwnedBy string // Account id this media belongs to OwnedById string // Account id this media belongs to
Remote bool // whether the attachment is a remote one Remote bool // whether the attachment is a remote one
// Where the media is stored. Url // Where the media is stored. Url
Location string Location string

View file

@ -6,14 +6,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// User created content, containing some message, maybe attachments, // A note describes some user generated text content.
// tags, pings or other extra things
//
// Data defined in extra structs (links are included at the bottom):
// - Attachments: models.NoteToAttachment
// - Emotes: models.NoteToEmote
// - Pings: models.NoteToPing
// - Tags: models.NoteTag
type Note struct { type Note struct {
ID string `gorm:"primarykey;type:uuid;default:gen_random_uuid()"` // Make ID a string (uuid) for other implementations ID string `gorm:"primarykey;type:uuid;default:gen_random_uuid()"` // Make ID a string (uuid) for other implementations
CreatedAt time.Time // When this entry was created CreatedAt time.Time // When this entry was created
@ -22,9 +15,9 @@ type Note struct {
// Soft delete means that this entry still exists in the db, but gorm won't include it anymore unless specifically told to // 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 // If not null, this entry is marked as deleted
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
Creator User Creator User // The user that created this note
CreatorId string CreatorId string // Id of the creator user
Remote bool // Whether the note is originally a remote one and just "cached" Remote bool // Whether the note is originally a remote one and just "cached"
// Raw content of the note. So without additional formatting applied // Raw content of the note. So without additional formatting applied
// Might already have formatting applied beforehand from the origin server // Might already have formatting applied beforehand from the origin server
RawContent string RawContent string
@ -34,8 +27,8 @@ type Note struct {
AccessLevel NoteAccessLevel // Where to send this message to (public, home, followers, dm) AccessLevel NoteAccessLevel // Where to send this message to (public, home, followers, dm)
OriginServer string // Url of the origin server. Also the primary key for those OriginServer string // Url of the origin server. Also the primary key for those
AttachmentRelations []NoteToAttachment `gorm:"foreignKey:NoteId"` AttachmentRelations []NoteToAttachment `gorm:"foreignKey:NoteId"` // Attachments added on to this note
EmoteRelations []NoteToEmote `gorm:"foreignKey:NoteId"` EmoteRelations []NoteToEmote `gorm:"foreignKey:NoteId"` // Emotes used in this note
PingRelations []NoteToPing `gorm:"foreignKey:NoteId"` PingRelations []NoteToPing `gorm:"foreignKey:NoteId"` // Pings/mentions this note performs
Tags []NoteTag `gorm:"foreignKey:NoteId"` Tags []NoteTag `gorm:"foreignKey:NoteId"` // Tags this note contains
} }

View file

@ -3,8 +3,8 @@ package models
// A binding of one note to one media attachment // A binding of one note to one media attachment
type NoteToAttachment struct { type NoteToAttachment struct {
ID uint64 `gorm:"primarykey"` ID uint64 `gorm:"primarykey"`
Note Note Note Note // The note being bound
NoteId string NoteId string
Attachment MediaMetadata Attachment MediaMetadata // The media being bound to
AttachmentId string AttachmentId string
} }

View file

@ -3,8 +3,8 @@ package models
// A binding of one note to one emote // A binding of one note to one emote
type NoteToEmote struct { type NoteToEmote struct {
ID uint64 `gorm:"primarykey"` ID uint64 `gorm:"primarykey"`
Note Note Note Note // The note being bound
NoteId string NoteId string
Emote Emote Emote Emote // The emote being included
EmoteId string EmoteId string
} }

View file

@ -0,0 +1,31 @@
package models
import "time"
// TODO: Struct for mapping a note to a user for their personal feed
// Storing timeline info in redis could also be an idea, but I kinda like
// everything being in one place
// Data needed:
// - Which note
// - Who's feed
// - Which feed (once separate feeds are implemented)
// - Reason:
// - Boost
// - Follow person
// - Follow tag
//
// Also need to store the boosts a user has performed somewhere
// Maybe adjust Reaction? Though a separate table might be a better option
// Assigns a note to a feed.
// Multiple notes may be assigned to multiple feeds, but each feed may contain
// one note at most once.
type NoteToFeed struct {
ID uint64 `gorm:"primarykey"`
CreatedAt time.Time
Note Note // The note being assigned
NoteId string
// Feed Feed
// FeedId uint64
// Reason AppearanceReason
}

View file

@ -1,10 +1,12 @@
package models package models
// A binding of one note to one mentioned account // A binding of one note to one mentioned account.
// A note may ping multiple users, but each ping wil be stored
// at most once
type NoteToPing struct { type NoteToPing struct {
ID uint64 `gorm:"primarykey"` ID uint64 `gorm:"primarykey"`
Note Note Note Note // The note mentioning an account
NoteId string NoteId string
PingTarget User PingTarget User // The account being mentioned
PingTargetId string PingTargetId string
} }

View file

@ -1,9 +1,11 @@
package models package models
// A binding of one note to one string (hash)tag // A binding of one note to one string (hash)tag.
// A note may contain multiple tags, but each tag will be stored at most
// once per note
type NoteTag struct { type NoteTag struct {
ID uint64 `gorm:"primarykey"` ID uint64 `gorm:"primarykey"`
Note Note Note Note // The note containing a tag
NoteId string NoteId string
Tag string Tag string // The tag contained
} }

View file

@ -2,7 +2,7 @@ package models
import "gorm.io/gorm" import "gorm.io/gorm"
// A binding of one note to one account reacting to it with one emote // A Reaction is a user liking a note using an emote
type Reaction struct { type Reaction struct {
gorm.Model gorm.Model
Note Note Note Note

View file

@ -1,6 +1,10 @@
package models package models
import "gorm.io/gorm" import (
"database/sql"
"gorm.io/gorm"
)
// RemoteServer describes an ActivityPub server // RemoteServer describes an ActivityPub server
// This includes self too // This includes self too
@ -9,7 +13,7 @@ type RemoteServer struct {
ServerType ServerSoftwareType // What software the server is running. Useful for formatting ServerType ServerSoftwareType // What software the server is running. Useful for formatting
Domain string // `gorm:"primaryKey"` // Domain the server exists under. Additional primary key 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) Name string // What the server wants to be known as (usually same as url)
Icon MediaMetadata Icon *MediaMetadata // The icon used by the server. May be empty
IconId string // ID of a media file IconId sql.NullString // ID of a media file
IsSelf bool // Whether this server is yours truly IsSelf bool // Whether this server is yours truly
} }

View file

@ -8,18 +8,19 @@ import (
type ServerSoftwareType string type ServerSoftwareType string
const ( const (
// Includes forks like glitch-soc, etc // Mastodon and forks (glitch-soc, etc)
ServerSoftwareMastodon = ServerSoftwareType("Mastodon") ServerSoftwareMastodon = ServerSoftwareType("Mastodon")
// Includes forks like Ice Shrimp, Sharkey, Cutiekey, etc // Misskey and forks (Ice Shrimp, Sharkey, Cutiekey, etc)
ServerSoftwareMisskey = ServerSoftwareType("Misskey") ServerSoftwareMisskey = ServerSoftwareType("Misskey")
// Includes Akkoma // Plemora and Akkoma
ServerSoftwarePlemora = ServerSoftwareType("Plemora") ServerSoftwarePlemora = ServerSoftwareType("Plemora")
// Wafrn is a new entry // Wafrn with no known forks
ServerSoftwareWafrn = ServerSoftwareType("Wafrn") ServerSoftwareWafrn = ServerSoftwareType("Wafrn")
// And of course, yours truly // Linstrom with no known forks
ServerSoftwareLinstrom = ServerSoftwareType("Linstrom") ServerSoftwareLinstrom = ServerSoftwareType("Linstrom")
) )
// A list of all known server software systems
var AllServerSoftwareTypes = []ServerSoftwareType{ var AllServerSoftwareTypes = []ServerSoftwareType{
ServerSoftwareMastodon, ServerSoftwareMastodon,
ServerSoftwareMisskey, ServerSoftwareMisskey,

View file

@ -2,21 +2,19 @@ package models
import ( import (
"time" "time"
"gorm.io/gen"
) )
// AccessToken maps a unique token to one account.
// Access to server resource may only happen with a valid access token.
// Access tokens are granted by [auth-new/auth.Authenticator].
// Each account may have multiple access tokens at any time
type AccessToken struct { type AccessToken struct {
User User User User // The account the token belongs to
UserId string UserId string
Token string `gorm:"primarykey;type:uuid;default:gen_random_uuid()"` // The token itself is a uuid value
Name string // Token name will be empty if autogenerated with sucessful login Token string `gorm:"primarykey;type:uuid;default:gen_random_uuid()"`
Name string // Token name will be empty if autogenerated with sucessful login
// Every token expires, even if set to "not expire". If set to "not expire", it just expires // Every token expires, even if set to "not expire". If set to "not expire", it just expires
// at a point in the future this server should never reach // at a point in the future this server should never reach
ExpiresAt time.Time `gorm:"default:TIMESTAMP WITH TIME ZONE '9999-12-30 23:59:59+00'"` ExpiresAt time.Time `gorm:"default:TIMESTAMP WITH TIME ZONE '9999-12-30 23:59:59+00'"`
} }
type IAccessToken interface {
// INSERT INTO @@table (user_id, token, name, {{if expiresAt != nil}}, )
NewToken(user *User, name string, expiresAt *time.Time) (gen.T, error)
}

View file

@ -2,10 +2,10 @@ package models
import "time" import "time"
// A token used during the login process // A LoginProcessToken contains a token used during the login process.
// Each user may only have at most one login process active at the same time // Login tokens are used during login to identify and track the login process
// Technically, that could be used to permanently block someone from logging in // if said process involves multiple steps (2fa and passkey)..
// by starting a new login process every time the target has just started one // Each user may have multiple active login processes at the same time.
type LoginProcessToken struct { type LoginProcessToken struct {
ID uint64 `gorm:"primarykey"` ID uint64 `gorm:"primarykey"`
User User User User

View file

@ -7,18 +7,11 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// A user describes an account for creating content. // A user describes an account for creating content and events.
// This may be controlled by either a human or some external script // This may be controlled by either a human or some external script
// //
// Data stored in external types: // Data stored externally:
// - Custom info fields // - Feed connections (which note belongs in the feed of this user, for what reason), see [NoteToFeed]
// - Being types
// - Tags
// - Relations
// - Pronouns
// - Roles
// - AP remote links
// - Auth methods and tokens (hashed pw, totp key, passkey id)
type User struct { type User struct {
// ID is a uuid for this account // ID is a uuid for this account
// //

View file

@ -2,6 +2,12 @@ package models
import "gorm.io/gorm" import "gorm.io/gorm"
// FIXME: Move passkeys to separate table
// Storing passkey credentials in this table requires marshalling
// them to json as of right now
// This causes problems with updating if encryption has to be added
// Same also needs to be done for the login process
// One authentication method linked to one account. // One authentication method linked to one account.
// Contains the method and whatever the token may be // Contains the method and whatever the token may be
// For a password, this would be a hash of that password, // For a password, this would be a hash of that password,

View file

@ -6,13 +6,15 @@ import "database/sql/driver"
type AuthenticationMethodType string type AuthenticationMethodType string
const ( const (
AuthMethodPassword AuthenticationMethodType = "password" AuthMethodPassword AuthenticationMethodType = "password" // Password based authentication
AuthMethodGAuth AuthenticationMethodType = "g-auth" // Google Authenticator / totp AuthMethodGAuth AuthenticationMethodType = "g-auth" // Totp based 2nd factor
AuthMethodMail AuthenticationMethodType = "mail" AuthMethodMail AuthenticationMethodType = "mail" // Mail based 2nd factor. Unused
AuthMethodPasskey2fa AuthenticationMethodType = "passkey-2fa" // Passkey used as 2fa factor AuthMethodPasskey2fa AuthenticationMethodType = "passkey-2fa" // Passkey based 2nd factor. Unused
AuthMethodPasskey AuthenticationMethodType = "passkey" // Passkey as only auth key AuthMethodPasskey AuthenticationMethodType = "passkey" // Passkey as only auth key
) )
// A list of all known authentication methods.
// Known != supported
var AllAuthMethods = []AuthenticationMethodType{ var AllAuthMethods = []AuthenticationMethodType{
AuthMethodPassword, AuthMethodGAuth, AuthMethodMail, AuthMethodPasskey, AuthMethodPasskey2fa, AuthMethodPassword, AuthMethodGAuth, AuthMethodMail, AuthMethodPasskey, AuthMethodPasskey2fa,
} }

View file

@ -10,12 +10,12 @@ import (
type BeingType string type BeingType string
const ( const (
BEING_HUMAN = BeingType("human") BEING_HUMAN = BeingType("human") // Is a human
BEING_CAT = BeingType("cat") BEING_CAT = BeingType("cat") // Is a cat
BEING_FOX = BeingType("fox") BEING_FOX = BeingType("fox") // Is a fox
BEING_DOG = BeingType("dog") BEING_DOG = BeingType("dog") // Is a dog
BEING_ROBOT = BeingType("robot") BEING_ROBOT = BeingType("robot") // Is a robot
BEING_DOLL = BeingType("doll") BEING_DOLL = BeingType("doll") // Is a doll
) )
var AllBeings = []BeingType{BEING_HUMAN, BEING_CAT, BEING_FOX, BEING_DOG, BEING_ROBOT, BEING_DOLL} var AllBeings = []BeingType{BEING_HUMAN, BEING_CAT, BEING_FOX, BEING_DOG, BEING_ROBOT, BEING_DOLL}

View file

@ -1,7 +1,9 @@
package models package models
// Defines an account to be a being of the set type // Defines an user to be a being of the set type
// Multiple are possible for combination // Each user may have multiple mappings
//
// TODO: Decide whether Being here could be changed to an open string instead
type UserToBeing struct { type UserToBeing struct {
ID uint64 `gorm:"primarykey"` ID uint64 `gorm:"primarykey"`
User User User User

View file

@ -6,7 +6,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// One key-value field attached to an account // A UserInfoField describes one custom key-value information field.
// Each user may have none, one or more of these fields.
// If the value is an uri, the server may attempt to verify ownership // If the value is an uri, the server may attempt to verify ownership
// over that uri by checking the content for a `rel="me"` anchor // over that uri by checking the content for a `rel="me"` anchor
// linking back to the account the field is attached to // linking back to the account the field is attached to

View file

@ -6,14 +6,14 @@ import "database/sql/driver"
type RelationType string type RelationType string
const ( const (
RelationFollow RelationType = "follow" RelationFollow RelationType = "follow" // X follows Y
RelationMute RelationType = "mute" RelationMute RelationType = "mute" // X has Y muted (X doesn't see Y, but Y still X)
RelationNoBoosts RelationType = "no-boosts" RelationNoBoosts RelationType = "no-boosts" // X has Ys boosts muted
RelationBlock RelationType = "block" RelationBlock RelationType = "block" // X has Y blocked (X doesn't see Y and Y doesn't see X)
RelationPreventFollow RelationType = "prevent-follow" RelationPreventFollow RelationType = "prevent-follow" // X blocks Y from following (Y can still see X)
) )
// var AllBeings = []BeingType{BEING_HUMAN, BEING_CAT, BEING_FOX, BEING_DOG, BEING_ROBOT, BEING_DOLL} // List of all relation types known
var AllRelations = []RelationType{ var AllRelations = []RelationType{
RelationFollow, RelationFollow,
RelationMute, RelationMute,

View file

@ -2,7 +2,7 @@ package models
import "gorm.io/gorm" import "gorm.io/gorm"
// "Cached" extra data for accounts, in case they are remote // UserRemoteLinks contains cached links for remote users
type UserRemoteLinks struct { type UserRemoteLinks struct {
// ---- Section: gorm // ---- Section: gorm
// Sets this struct up as a value that an Account may have // Sets this struct up as a value that an Account may have

View file

@ -1,7 +1,8 @@
package models package models
// A (hash)tag appearing on an account's profile description // A (hash)tag appearing on an account's profile description
// Accounts may have multiple tags, but each tag may only be stored once at most // Users may have multiple tags, but each user to tag relation may only
// appear at most once
type UserToTag struct { type UserToTag struct {
ID uint64 `gorm:"primarykey"` ID uint64 `gorm:"primarykey"`
User User User User

View file

@ -1,5 +1,8 @@
package models package models
// Adds one pronoun to a user.
// Each user may have zero, one or more pronouns
// but each user to pronoun relation may appear at most once
type UserToPronoun struct { type UserToPronoun struct {
ID uint64 `gorm:"primarykey"` ID uint64 `gorm:"primarykey"`
User User User User

View file

@ -1,8 +1,9 @@
package models package models
// A link of one account to one role // A link of one account to one role
// There may be multiple of these links per user and per role // Each user may have one or more roles
// But a role may only be linked at most once to the same user // (every user has the default role and their personal one)
// but each user to role combination may appear at most once
type UserToRole struct { type UserToRole struct {
ID uint64 `gorm:"primarykey"` ID uint64 `gorm:"primarykey"`
User User User User

View file

@ -5,9 +5,9 @@ package models
// each describing a different aspect // each describing a different aspect
type UserToUserRelation struct { type UserToUserRelation struct {
ID uint64 `gorm:"primarykey"` ID uint64 `gorm:"primarykey"`
User User User User // The user X described in [RelationType]
UserId string UserId string
TargetUser User TargetUser User // The user Y described in [RelationType]
TargetUserId string TargetUserId string
Relation RelationType `gorm:"type:relation_type"` Relation RelationType `gorm:"type:relation_type"`
} }