User import now works
Some checks failed
/ docker (push) Failing after 3m56s

This commit is contained in:
Melody Becker 2025-04-15 17:18:56 +02:00
parent 08f6de0bd7
commit 5e93ecee73
Signed by: mstar
SSH key fingerprint: SHA256:9VAo09aaVNTWKzPW7Hq2LW+ox9OdwmTSHRoD4mlz1yI
12 changed files with 241 additions and 109 deletions

View file

@ -2,6 +2,7 @@ package activitypub
import (
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
@ -19,48 +20,39 @@ import (
webshared "git.mstar.dev/mstar/linstrom/web/shared"
)
var interestingMetadata = []string{
"nodeDescription",
"maintainer",
"tosUrl",
"nodeAdmins",
"privacyPolicyUrl",
"inquiryUrl",
"impressumUrl",
"donationUrl",
"staffAccounts",
}
func ImportRemoteAccount(targetName string) (string, error) {
type InboundUserKey struct {
type inboundImportUserKey struct {
Id string `json:"id"`
Owner string `json:"owner"`
Pem string `json:"publicKeyPem"`
}
type InboundUserMedia struct {
}
type inboundImportUserMedia struct {
Type string `json:"type"`
Url string `json:"url"`
MediaType string `json:"mediaType"`
}
type InboundUser struct {
}
type inboundImportUser struct {
Id string `json:"id"`
Type string `json:"type"`
PreferredUsername string `json:"preferredUsername"`
Inbox string `json:"inbox"`
PublicKey *InboundUserKey `json:"publicKey"`
Outbox *string `json:"outbox"`
PublicKey *inboundImportUserKey `json:"publicKey"`
Published *time.Time `json:"published"`
DisplayName *string `json:"name"`
Description *string `json:"summary,omitempty"`
PublicUrl *string `json:"url"`
Icon *InboundUserMedia `json:"icon,omitempty"`
Banner *InboundUserMedia `json:"image,omitempty"`
Icon *inboundImportUserMedia `json:"icon,omitempty"`
Banner *inboundImportUserMedia `json:"image,omitempty"`
Discoverable *bool `json:"discoverable"`
Location *string `json:"vcard:Address,omitempty"`
Birthday *string `json:"vcard:bday,omitempty"`
SpeakAsCat bool `json:"speakAsCat"`
IsCat bool `json:"isCat"`
RestrictedFollow *bool `json:"manuallyApprovesFollowers"`
}
}
func ImportRemoteAccount(targetName string) (string, error) {
// Get the target user's link first
webfinger, err := GetAccountWebfinger(targetName)
if err != nil {
@ -93,14 +85,137 @@ func ImportRemoteAccount(targetName string) (string, error) {
if response.StatusCode != 200 {
return "", fmt.Errorf("activitypub: invalid status code: %v", response.StatusCode)
}
var data InboundUser
var data inboundImportUser
err = json.Unmarshal(body, &data)
if err != nil {
return "", other.Error("activitypub", "failed to unmarshal response", err)
}
log.Debug().Any("received-data", data).Msg("Response data")
// TODO: Store received user in db
_, host, _ := SplitFullHandle(targetName)
hostId, err := ImportRemoteServer(host)
if err != nil {
return "", other.Error("activitypub", "failed to import host of target user", err)
}
user, err := dbgen.User.
Where(dbgen.User.Username.Eq(targetName)).
Where(dbgen.User.ServerId.Eq(hostId)).
Preload(dbgen.User.RemoteInfo).
Preload(dbgen.User.InfoFields).
Preload(dbgen.User.BeingTypes).
FirstOrCreate()
if err != nil {
return "", other.Error("activitypub", "failed to find or create user in db", err)
}
user.Verified = true
user.FinishedRegistration = true
if user.RemoteInfo == nil {
user.RemoteInfo = &models.UserRemoteLinks{
UserId: user.ID,
ApLink: data.Id,
}
err = dbgen.UserRemoteLinks.Create(user.RemoteInfo)
if err != nil {
return "", other.Error("activitypub", "failed to create remote data for user", err)
}
err = dbgen.User.RemoteInfo.Model(user).Replace(user.RemoteInfo)
if err != nil {
return "", other.Error("activitypub", "failed to connect remote data to user", err)
}
user.RemoteInfoId.Int64 = int64(user.RemoteInfo.ID)
user.RemoteInfoId.Valid = true
}
if data.DisplayName != nil {
user.DisplayName = *data.DisplayName
}
if data.Outbox != nil {
user.RemoteInfo.OutboxLink.String = *data.Outbox
user.RemoteInfo.OutboxLink.Valid = true
}
user.RemoteInfo.InboxLink = data.Inbox
if data.PublicKey != nil {
pemBlock, _ := pem.Decode([]byte(data.PublicKey.Pem))
if pemBlock.Type != "PUBLIC KEY" && pemBlock.Type != "RSA PUBLIC KEY" {
return "", fmt.Errorf("activitypub: invalid public key block type: %v", pemBlock.Type)
}
user.PublicKeyRsa = pemBlock.Bytes
}
// Assume published day of user won't change
if data.Description != nil {
user.Description = *data.Description
}
if data.PublicUrl != nil {
user.RemoteInfo.ViewLink.String = *data.PublicUrl
user.RemoteInfo.ViewLink.Valid = true
}
if data.Discoverable != nil {
user.Indexable = *data.Discoverable
} else {
// Assume false per default
user.Indexable = false
}
if data.Location != nil {
user.Location.String = *data.Location
user.Location.Valid = true
}
if data.Birthday != nil {
user.Birthday.String = *data.Birthday
user.Birthday.Valid = true
}
if data.RestrictedFollow != nil {
user.RestrictedFollow = *data.RestrictedFollow
} else {
// Assume not restricted if not included in received data
user.RestrictedFollow = false
}
if data.IsCat {
if !sliceutils.ContainsFunc(user.BeingTypes, func(t models.UserToBeing) bool {
return t.Being == string(models.BEING_CAT)
}) {
log.Debug().Msg("user doesn't contain cat yet")
bt := models.UserToBeing{UserId: user.ID, Being: string(models.BEING_CAT)}
if err = dbgen.UserToBeing.Create(&bt); err != nil {
log.Warn().Err(err).Msg("Failed to append cat being type to imported user")
}
if err = dbgen.User.BeingTypes.Model(user).Append(&bt); err != nil {
log.Warn().
Err(err).
Msg("Failed to append cat being type to imported user relations")
}
user.BeingTypes = append(user.BeingTypes, bt)
}
} else {
if sliceutils.ContainsFunc(user.BeingTypes, func(t models.UserToBeing) bool {
return t.Being == string(models.BEING_CAT)
}) {
_, err = dbgen.UserToBeing.Where(dbgen.UserToBeing.UserId.Eq(user.ID)).Where(dbgen.UserToBeing.Being.Eq(models.BEING_CAT)).Delete()
if err != nil {
log.Warn().Err(err).Msg("Failed to remove cat being type from user")
}
}
}
// Don't handle SpeakAsCat yet, as not included in stored data yet
// Handle media last as more complicated
// Icon *inboundImportUserMedia `json:"icon,omitempty"`
// Banner *inboundImportUserMedia `json:"image,omitempty"`
id := user.ID
user.ID = ""
if _, err = dbgen.User.Where(dbgen.User.ID.Eq(id)).UpdateColumns(user); err != nil {
return "", other.Error("activitypub", "failed to update imported user", err)
}
rlid := user.RemoteInfo.ID
user.RemoteInfo.ID = 0
if _, err = dbgen.UserRemoteLinks.Where(dbgen.UserRemoteLinks.ID.Eq(rlid)).UpdateColumns(user.RemoteInfo); err != nil {
return "", other.Error("activitypub", "failed to update imported user's remote links", err)
}
log.Info().Str("user", targetName).Msg("Import completed")
return "", nil
return id, nil
}
func ImportRemoteServer(host string) (uint, error) {

View file

@ -32,13 +32,13 @@ func newUserRemoteLinks(db *gorm.DB, opts ...gen.DOOption) userRemoteLinks {
_userRemoteLinks.DeletedAt = field.NewField(tableName, "deleted_at")
_userRemoteLinks.UserId = field.NewString(tableName, "user_id")
_userRemoteLinks.ApLink = field.NewString(tableName, "ap_link")
_userRemoteLinks.ViewLink = field.NewString(tableName, "view_link")
_userRemoteLinks.FollowersLink = field.NewString(tableName, "followers_link")
_userRemoteLinks.FollowingLink = field.NewString(tableName, "following_link")
_userRemoteLinks.ViewLink = field.NewField(tableName, "view_link")
_userRemoteLinks.FollowersLink = field.NewField(tableName, "followers_link")
_userRemoteLinks.FollowingLink = field.NewField(tableName, "following_link")
_userRemoteLinks.InboxLink = field.NewString(tableName, "inbox_link")
_userRemoteLinks.OutboxLink = field.NewString(tableName, "outbox_link")
_userRemoteLinks.FeaturedLink = field.NewString(tableName, "featured_link")
_userRemoteLinks.FeaturedTagsLink = field.NewString(tableName, "featured_tags_link")
_userRemoteLinks.OutboxLink = field.NewField(tableName, "outbox_link")
_userRemoteLinks.FeaturedLink = field.NewField(tableName, "featured_link")
_userRemoteLinks.FeaturedTagsLink = field.NewField(tableName, "featured_tags_link")
_userRemoteLinks.User = userRemoteLinksBelongsToUser{
db: db.Session(&gorm.Session{}),
@ -227,13 +227,13 @@ type userRemoteLinks struct {
DeletedAt field.Field
UserId field.String
ApLink field.String
ViewLink field.String
FollowersLink field.String
FollowingLink field.String
ViewLink field.Field
FollowersLink field.Field
FollowingLink field.Field
InboxLink field.String
OutboxLink field.String
FeaturedLink field.String
FeaturedTagsLink field.String
OutboxLink field.Field
FeaturedLink field.Field
FeaturedTagsLink field.Field
User userRemoteLinksBelongsToUser
fieldMap map[string]field.Expr
@ -257,13 +257,13 @@ func (u *userRemoteLinks) updateTableName(table string) *userRemoteLinks {
u.DeletedAt = field.NewField(table, "deleted_at")
u.UserId = field.NewString(table, "user_id")
u.ApLink = field.NewString(table, "ap_link")
u.ViewLink = field.NewString(table, "view_link")
u.FollowersLink = field.NewString(table, "followers_link")
u.FollowingLink = field.NewString(table, "following_link")
u.ViewLink = field.NewField(table, "view_link")
u.FollowersLink = field.NewField(table, "followers_link")
u.FollowingLink = field.NewField(table, "following_link")
u.InboxLink = field.NewString(table, "inbox_link")
u.OutboxLink = field.NewString(table, "outbox_link")
u.FeaturedLink = field.NewString(table, "featured_link")
u.FeaturedTagsLink = field.NewString(table, "featured_tags_link")
u.OutboxLink = field.NewField(table, "outbox_link")
u.FeaturedLink = field.NewField(table, "featured_link")
u.FeaturedTagsLink = field.NewField(table, "featured_tags_link")
u.fillFieldMap()

View file

@ -28,7 +28,7 @@ func newUserToBeing(db *gorm.DB, opts ...gen.DOOption) userToBeing {
_userToBeing.ALL = field.NewAsterisk(tableName)
_userToBeing.ID = field.NewUint64(tableName, "id")
_userToBeing.UserId = field.NewString(tableName, "user_id")
_userToBeing.Being = field.NewField(tableName, "being")
_userToBeing.Being = field.NewString(tableName, "being")
_userToBeing.User = userToBeingBelongsToUser{
db: db.Session(&gorm.Session{}),
@ -213,7 +213,7 @@ type userToBeing struct {
ALL field.Asterisk
ID field.Uint64
UserId field.String
Being field.Field
Being field.String
User userToBeingBelongsToUser
fieldMap map[string]field.Expr
@ -233,7 +233,7 @@ func (u *userToBeing) updateTableName(table string) *userToBeing {
u.ALL = field.NewAsterisk(table)
u.ID = field.NewUint64(table, "id")
u.UserId = field.NewString(table, "user_id")
u.Being = field.NewField(table, "being")
u.Being = field.NewString(table, "being")
u.fillFieldMap()

View file

@ -43,13 +43,14 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) user {
_user.PublicKeyRsa = field.NewBytes(tableName, "public_key_rsa")
_user.PublicKeyEd = field.NewBytes(tableName, "public_key_ed")
_user.RestrictedFollow = field.NewBool(tableName, "restricted_follow")
_user.Location = field.NewField(tableName, "location")
_user.Birthday = field.NewField(tableName, "birthday")
_user.Location = field.NewField(tableName, "location")
_user.Verified = field.NewBool(tableName, "verified")
_user.PasskeyId = field.NewBytes(tableName, "passkey_id")
_user.FinishedRegistration = field.NewBool(tableName, "finished_registration")
_user.PrivateKeyRsa = field.NewBytes(tableName, "private_key_rsa")
_user.PrivateKeyEd = field.NewBytes(tableName, "private_key_ed")
_user.RemoteInfoId = field.NewField(tableName, "remote_info_id")
_user.RemoteInfo = userHasOneRemoteInfo{
db: db.Session(&gorm.Session{}),
@ -383,13 +384,14 @@ type user struct {
PublicKeyRsa field.Bytes
PublicKeyEd field.Bytes
RestrictedFollow field.Bool
Location field.Field
Birthday field.Field
Location field.Field
Verified field.Bool
PasskeyId field.Bytes
FinishedRegistration field.Bool
PrivateKeyRsa field.Bytes
PrivateKeyEd field.Bytes
RemoteInfoId field.Field
RemoteInfo userHasOneRemoteInfo
InfoFields userHasManyInfoFields
@ -445,13 +447,14 @@ func (u *user) updateTableName(table string) *user {
u.PublicKeyRsa = field.NewBytes(table, "public_key_rsa")
u.PublicKeyEd = field.NewBytes(table, "public_key_ed")
u.RestrictedFollow = field.NewBool(table, "restricted_follow")
u.Location = field.NewField(table, "location")
u.Birthday = field.NewField(table, "birthday")
u.Location = field.NewField(table, "location")
u.Verified = field.NewBool(table, "verified")
u.PasskeyId = field.NewBytes(table, "passkey_id")
u.FinishedRegistration = field.NewBool(table, "finished_registration")
u.PrivateKeyRsa = field.NewBytes(table, "private_key_rsa")
u.PrivateKeyEd = field.NewBytes(table, "private_key_ed")
u.RemoteInfoId = field.NewField(table, "remote_info_id")
u.fillFieldMap()
@ -468,7 +471,7 @@ func (u *user) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
func (u *user) fillFieldMap() {
u.fieldMap = make(map[string]field.Expr, 35)
u.fieldMap = make(map[string]field.Expr, 36)
u.fieldMap["id"] = u.ID
u.fieldMap["username"] = u.Username
u.fieldMap["created_at"] = u.CreatedAt
@ -485,13 +488,14 @@ func (u *user) fillFieldMap() {
u.fieldMap["public_key_rsa"] = u.PublicKeyRsa
u.fieldMap["public_key_ed"] = u.PublicKeyEd
u.fieldMap["restricted_follow"] = u.RestrictedFollow
u.fieldMap["location"] = u.Location
u.fieldMap["birthday"] = u.Birthday
u.fieldMap["location"] = u.Location
u.fieldMap["verified"] = u.Verified
u.fieldMap["passkey_id"] = u.PasskeyId
u.fieldMap["finished_registration"] = u.FinishedRegistration
u.fieldMap["private_key_rsa"] = u.PrivateKeyRsa
u.fieldMap["private_key_ed"] = u.PrivateKeyEd
u.fieldMap["remote_info_id"] = u.RemoteInfoId
}

View file

@ -55,12 +55,14 @@ type User struct {
// If true, the owner must approve of a follow request first
RestrictedFollow bool
// Technically should be a timestamp, but can't trust other implementations
// to enforce this in a consistent format
Birthday sql.NullString
Location sql.NullString
Birthday sql.NullTime
// 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
// No impact on remote accounts
Verified bool
// 64 byte unique id for passkeys, because UUIDs are 128 bytes and passkey spec says 64 bytes max
// In theory, could also slash Id in half, but that would be a lot more calculations than the
@ -78,6 +80,7 @@ type User struct {
Pronouns []UserToPronoun
Roles []UserToRole
RemoteInfo *UserRemoteLinks
RemoteInfoId sql.NullInt64
AuthMethods []UserAuthMethod
}

View file

@ -21,7 +21,7 @@ const (
var AllBeings = []BeingType{BEING_HUMAN, BEING_CAT, BEING_FOX, BEING_DOG, BEING_ROBOT, BEING_DOLL}
func (ct *BeingType) Scan(value any) error {
*ct = BeingType(value.([]byte))
*ct = BeingType(value.(string))
return nil
}

View file

@ -8,5 +8,5 @@ type UserToBeing struct {
ID uint64 `gorm:"primarykey"`
User User
UserId string
Being BeingType `gorm:"type:being_type"`
Being string
}

View file

@ -1,6 +1,10 @@
package models
import "gorm.io/gorm"
import (
"database/sql"
"gorm.io/gorm"
)
// UserRemoteLinks contains cached links for remote users
type UserRemoteLinks struct {
@ -13,11 +17,11 @@ type UserRemoteLinks struct {
// 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
ViewLink sql.NullString
FollowersLink sql.NullString
FollowingLink sql.NullString
InboxLink string
OutboxLink *string
FeaturedLink *string
FeaturedTagsLink *string
OutboxLink sql.NullString
FeaturedLink sql.NullString
FeaturedTagsLink sql.NullString
}

View file

@ -1,7 +1,7 @@
[general]
protocol = "https"
domain = "serveo.net"
subdomain = "cc45720a387f04ba6a748a2627327a77"
domain = "lhr.life"
subdomain = "47565bb39100de"
private_port = 8080
public_port = 443

View file

@ -158,12 +158,13 @@ func createLocalUser(w http.ResponseWriter, r *http.Request) {
PasskeyId: pkeyId,
}
if data.Birthday != nil {
user.Birthday = sql.NullTime{Valid: true, Time: *data.Birthday}
user.Birthday = sql.NullString{Valid: true, String: data.Birthday.Format("2006-Jan-02")}
// user.Birthday = sql.NullTime{Valid: true, Time: *data.Birthday}
}
if data.Location != nil {
user.Location = sql.NullString{Valid: true, String: *data.Location}
}
if err = u.Create(&user); err != nil {
if err = query.Create(&user); err != nil {
log.Error().Err(err).Msg("failed to create new local user")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
}

View file

@ -8,7 +8,6 @@ import (
"time"
webutils "git.mstar.dev/mstar/goutils/http"
"git.mstar.dev/mstar/goutils/other"
"git.mstar.dev/mstar/goutils/sliceutils"
"github.com/rs/zerolog/hlog"
@ -119,7 +118,7 @@ func users(w http.ResponseWriter, r *http.Request) {
}
}
if sliceutils.ContainsFunc(user.BeingTypes, func(t models.UserToBeing) bool {
return t.Being == models.BEING_CAT
return t.Being == string(models.BEING_CAT)
}) {
data.IsCat = true
// data.SpeakAsCat = true // TODO: Move to check of separate field in db model
@ -128,10 +127,16 @@ func users(w http.ResponseWriter, r *http.Request) {
data.Location = &user.Location.String
}
if user.Birthday.Valid {
data.Birthday = other.IntoPointer(user.Birthday.Time.Format("2006-Jan-02")) //YYYY-Month-DD
data.Birthday = &user.Birthday.String
// data.Birthday = other.IntoPointer(user.Birthday.Time.Format("2006-Jan-02")) //YYYY-Month-DD
}
encoded, err := json.Marshal(data)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal response")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/activity+json")
fmt.Fprint(w, string(encoded))
}

View file

@ -29,7 +29,7 @@ type User struct {
PublicKey []byte `json:"public_key"`
RestrictedFollow bool `json:"restricted_follow"`
Location *string `json:"location"`
Birthday *time.Time `json:"birthday"`
Birthday *string `json:"birthday"`
// ---- Section Debug data ----
// All these entries should only be available
@ -113,7 +113,7 @@ func (u *User) FromModel(m *models.User) {
u.Location = &m.Location.String
}
if m.Birthday.Valid {
u.Birthday = &m.Birthday.Time
u.Birthday = &m.Birthday.String
}
u.Verified = &m.Verified
u.FinishedRegistration = &m.FinishedRegistration