package activitypub import ( "encoding/json" "encoding/pem" "errors" "fmt" "io" "net/http" "strings" "time" "git.mstar.dev/mstar/goutils/other" "git.mstar.dev/mstar/goutils/sliceutils" "github.com/rs/zerolog/log" "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" webshared "git.mstar.dev/mstar/linstrom/web/shared" ) type inboundImportUserKey struct { Id string `json:"id"` Owner string `json:"owner"` Pem string `json:"publicKeyPem"` } type inboundImportUserMedia struct { Type string `json:"type"` Url string `json:"url"` MediaType string `json:"mediaType"` } type inboundImportUser struct { Id string `json:"id"` Type string `json:"type"` PreferredUsername string `json:"preferredUsername"` Inbox string `json:"inbox"` 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 *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 { return "", other.Error("activitypub", "webfinger request failed", err) } selfLinks := sliceutils.Filter(webfinger.Links, func(t LinkData) bool { return t.Relation == "self" }) if len(selfLinks) == 0 { return "", errors.New("no self link") } APLink := selfLinks[0] // Server actor key for signing linstromActor, err := dbgen.User.Where(dbgen.User.Username.Eq(shared.ServerActorName)).First() if err != nil { return "", other.Error("activitypub", "failed to get server actor", err) } var response *http.Response response, err = webshared.RequestSignedCavage("GET", *APLink.Href, nil, linstromActor) if err != nil { return "", other.Error("activitypub", "failed to complete cavage signed request", err) } defer response.Body.Close() body, _ := io.ReadAll(response.Body) log.Debug(). Int("status", response.StatusCode). Bytes("body", body). Any("headers", response.Header). Msg("Response information") if response.StatusCode != 200 { return "", fmt.Errorf("activitypub: invalid status code: %v", response.StatusCode) } 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). Preload(dbgen.User.Roles). FirstOrCreate() if err != nil { return "", other.Error("activitypub", "failed to find or create user in db", err) } user.Verified = true user.FinishedRegistration = true if !sliceutils.ContainsFunc(user.Roles, func(t models.UserToRole) bool { return t.Role.ID == models.DefaultUserRole.ID }) { roleMapping := models.UserToRole{ Role: models.DefaultUserRole, RoleId: models.DefaultUserRole.ID, UserId: user.ID, } if err = dbgen.UserToRole.Create(&roleMapping); err != nil { return "", other.Error( "activitypub", "failed to store default user role to imported account mapping", err, ) } if err = dbgen.User.Roles.Model(user).Append(&roleMapping); err != nil { return "", other.Error("activitypub", "failed to attach default role to user", err) } } 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(string(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 id, nil } func ImportRemoteServer(host string) (uint, error) { // Nodeinfo is not locked behind authentication, so don't do it req, err := webshared.NewRequest("GET", "https://"+host+"/.well-known/nodeinfo", nil) if err != nil { return 0, other.Error("activitypub", "failed to create overview request", err) } response, err := webshared.RequestClient.Do(req) if err != nil { return 0, other.Error("activitypub", "overview request failed", err) } var nodeInfoOverview webshared.NodeInfoOverview decoder := json.NewDecoder(response.Body) err = decoder.Decode(&nodeInfoOverview) if err != nil { return 0, other.Error("activitypub", "overview unmarshal failed", err) } relevantInfos := sliceutils.Filter(nodeInfoOverview.Links, func(t webshared.NodeInfoLink) bool { return strings.HasSuffix(t.Rel, "schema/2.0") || strings.HasSuffix(t.Rel, "schema/2.1") }) var data webshared.NodeInfo2 v21Slice := sliceutils.Filter(relevantInfos, func(t webshared.NodeInfoLink) bool { return strings.HasSuffix(t.Rel, "schema/2.1") }) if len(v21Slice) > 0 { req, err = webshared.NewRequest("GET", v21Slice[0].Href, nil) if err != nil { return 0, other.Error("activitypub", "info 2.1 request creation failed", err) } } else { req, err = webshared.NewRequest("GET", relevantInfos[0].Href, nil) if err != nil { return 0, other.Error("activitypub", "info 2.0 request creation failed", err) } } res, err := webshared.RequestClient.Do(req) if err != nil { return 0, other.Error("activitypub", "info request failed to complete", err) } decoder = json.NewDecoder(res.Body) err = decoder.Decode(&data) if err != nil { return 0, other.Error("activitypub", "failed to unmarshal info", err) } log.Debug().Any("nodeinfo", data).Msg("Server info received") rs := dbgen.RemoteServer // rsm := dbgen.RemoteServerMetadata serverModelType := webshared.MapNodeServerTypeToModelType(data.Software.Name) existingEntry, err := rs.Where(rs.Domain.Eq(host)).Preload(rs.Metadata).First() switch err { case gorm.ErrRecordNotFound: existingEntry = &models.RemoteServer{ ServerType: serverModelType, Version: data.Version, Domain: host, SpecificType: data.Software.Name, } if err = rs.Create(existingEntry); err != nil { return 0, other.Error("activitypub", "failed to create new server entry", err) } case nil: default: return 0, other.Error("activitypub", "db failure", err) } existingEntry.SpecificType = data.Software.Name existingEntry.IsSelf = false existingEntry.ServerType = serverModelType existingEntry.Version = data.Version if name, ok := data.Metadata["nodeName"].(string); ok { existingEntry.Name = name } else { existingEntry.Name = host } // Cast without check for existence is ok here // Default value for `Any` is `nil`, which fails to cast to a valid string if description, ok := data.Metadata["nodeDescription"].(string); ok { log.Debug().Msg("Description found") targets := sliceutils.Filter( existingEntry.Metadata, func(t models.RemoteServerMetadata) bool { return t.Key == "description" }, ) if len(targets) > 0 { targets[0].Value = description } else { err := rs.Metadata.Model(existingEntry).Append(&models.RemoteServerMetadata{ Value: description, Key: "description", }) if err != nil { log.Warn(). Err(err). Uint("server-id", existingEntry.ID). Str("key", "description"). Str("value", description). Msg("Failed to append new metadata to server") } } } if maintainer, ok := data.Metadata["maintainer"].(map[string]any); ok { log.Debug().Msg("Maintainer found") name, nameOk := maintainer["name"].(string) email, emailOk := maintainer["email"].(string) if nameOk && emailOk { targets := sliceutils.Filter( existingEntry.Metadata, func(t models.RemoteServerMetadata) bool { return t.Key == "maintainer" }, ) val := fmt.Sprintf("%s <%s>", name, email) if len(targets) > 0 { targets[0].Value = val } else { err = rs.Metadata.Model(existingEntry).Append(&models.RemoteServerMetadata{ Key: "maintainer", Value: val, }) if err != nil { log.Warn(). Err(err). Uint("server-id", existingEntry.ID). Str("key", "maintainer"). Str("value", val). Msg("Failed to append new metadata to server") } } } } if tosUrl, ok := data.Metadata["tosUrl"].(string); ok { log.Debug().Msg("Tos url found") targets := sliceutils.Filter( existingEntry.Metadata, func(t models.RemoteServerMetadata) bool { return t.Key == "tosUrl" }, ) if len(targets) > 0 { targets[0].Value = tosUrl } else { err := rs.Metadata.Model(existingEntry).Append(&models.RemoteServerMetadata{ Value: tosUrl, Key: "tosUrl", }) if err != nil { log.Warn(). Err(err). Uint("server-id", existingEntry.ID). Str("key", "tosUrl"). Str("value", tosUrl). Msg("Failed to append new metadata to server") } } } if nodeAdmins, ok := data.Metadata["nodeAdmins"].([]map[string]any); ok && len(nodeAdmins) > 0 { log.Debug().Msg("Node admins url found") targets := sliceutils.Filter( existingEntry.Metadata, func(t models.RemoteServerMetadata) bool { return t.Key == "nodeAdmins" }, ) valueBuilder := strings.Builder{} for _, v := range nodeAdmins { name, nameOk := v["name"].(string) email, emailOk := v["email"].(string) if nameOk && emailOk { valueBuilder.WriteString(name) valueBuilder.WriteString(" <") valueBuilder.WriteString(email) valueBuilder.WriteString(">;") } } if len(targets) > 0 { targets[0].Value = valueBuilder.String() } else { err := rs.Metadata.Model(existingEntry).Append(&models.RemoteServerMetadata{ Value: valueBuilder.String(), Key: "nodeAdmins", }) if err != nil { log.Warn(). Err(err). Uint("server-id", existingEntry.ID). Str("key", "nodeAdmins"). Str("value", valueBuilder.String()). Msg("Failed to append new metadata to server") } } } if privacyPolicyUrl, ok := data.Metadata["privacyPolicyUrl"].(string); ok { log.Debug().Msg("Privacy policy url found") targets := sliceutils.Filter( existingEntry.Metadata, func(t models.RemoteServerMetadata) bool { return t.Key == "privacyPolicyUrl" }, ) if len(targets) > 0 { targets[0].Value = privacyPolicyUrl } else { err := rs.Metadata.Model(existingEntry).Append(&models.RemoteServerMetadata{ Value: privacyPolicyUrl, Key: "privacyPolicyUrl", }) if err != nil { log.Warn(). Err(err). Uint("server-id", existingEntry.ID). Str("key", "privacyPolicyUrl"). Str("value", privacyPolicyUrl). Msg("Failed to append new metadata to server") } } } if inquiryUrl, ok := data.Metadata["inquiryUrl"].(string); ok { log.Debug().Msg("Inquiry found") targets := sliceutils.Filter( existingEntry.Metadata, func(t models.RemoteServerMetadata) bool { return t.Key == "inquiryUrl" }, ) if len(targets) > 0 { targets[0].Value = inquiryUrl } else { err := rs.Metadata.Model(existingEntry).Append(&models.RemoteServerMetadata{ Value: inquiryUrl, Key: "inquiryUrl", }) if err != nil { log.Warn(). Err(err). Uint("server-id", existingEntry.ID). Str("key", "inquiryUrl"). Str("value", inquiryUrl). Msg("Failed to append new metadata to server") } } } if impressumUrl, ok := data.Metadata["impressumUrl"].(string); ok { log.Debug().Msg("Impressum url found") targets := sliceutils.Filter( existingEntry.Metadata, func(t models.RemoteServerMetadata) bool { return t.Key == "impressumUrl" }, ) if len(targets) > 0 { targets[0].Value = impressumUrl } else { err := rs.Metadata.Model(existingEntry).Append(&models.RemoteServerMetadata{ Value: impressumUrl, Key: "impressumUrl", }) if err != nil { log.Warn(). Err(err). Uint("server-id", existingEntry.ID). Str("key", "impressumUrl"). Str("value", impressumUrl). Msg("Failed to append new metadata to server") } } } if donationUrl, ok := data.Metadata["donationUrl"].(string); ok { log.Debug().Msg("Donation url found") targets := sliceutils.Filter( existingEntry.Metadata, func(t models.RemoteServerMetadata) bool { return t.Key == "donationUrl" }, ) if len(targets) > 0 { targets[0].Value = donationUrl } else { err := rs.Metadata.Model(existingEntry).Append(&models.RemoteServerMetadata{ Value: donationUrl, Key: "donationUrl", }) if err != nil { log.Warn(). Err(err). Uint("server-id", existingEntry.ID). Str("key", "donationUrl"). Str("value", donationUrl). Msg("Failed to append new metadata to server") } } } if staffAccounts, ok := data.Metadata["nodeAdmins"].([]any); ok && len(staffAccounts) > 0 { log.Debug().Msg("Node admins url found") targets := sliceutils.Filter( existingEntry.Metadata, func(t models.RemoteServerMetadata) bool { return t.Key == "staffAccounts" }, ) valueBuilder := strings.Builder{} for _, v := range staffAccounts { if acc, ok := v.(string); ok { valueBuilder.WriteString(acc) valueBuilder.WriteString(";") } } if len(targets) > 0 { targets[0].Value = valueBuilder.String() } else { err := rs.Metadata.Model(existingEntry).Append(&models.RemoteServerMetadata{ Value: valueBuilder.String(), Key: "staffAccounts", }) if err != nil { log.Warn(). Err(err). Uint("server-id", existingEntry.ID). Str("key", "staffAccounts"). Str("value", valueBuilder.String()). Msg("Failed to append new metadata to server") } } } id := existingEntry.ID existingEntry.ID = 0 _, err = rs.Where(rs.ID.Eq(id)).UpdateColumns(existingEntry) if err != nil { return 0, other.Error("activitypub", "failed to store update in db", err) } return id, nil }