diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx index 450c9a3ac..5a861165a 100644 --- a/js/app/components/settings/branding-admin.tsx +++ b/js/app/components/settings/branding-admin.tsx @@ -296,6 +296,8 @@ export function BrandingAdmin() { case "bottomLinks": case "networkName": case "networkProfileUrl": + case "verifierDids": + case "chatVerifiedOnly": case "loginPlaceholder": setChromeInputs((prev) => ({ ...prev, [key]: "" })); break; @@ -783,6 +785,136 @@ export function BrandingAdmin() { + + + + + {t("branding-verifiers")} + + + {t("branding-verifiers-description")} + + + setChromeInputs((prev) => ({ + ...prev, + verifierDids: v, + })) + } + /> + + + + + + {t("branding-chat-verified-only-description")} + + uploadText("chatVerifiedOnly", v)} + /> + + {t("branding-verified-icon-description")} + + + {branding?.verifiedIcon?.data ? ( + + ) : null} + + {!!branding?.verifiedIcon?.data && ( + + )} + + + + + diff --git a/js/app/components/stream-card/stream-card-layout.tsx b/js/app/components/stream-card/stream-card-layout.tsx index 02db7dab8..8a1365eac 100644 --- a/js/app/components/stream-card/stream-card-layout.tsx +++ b/js/app/components/stream-card/stream-card-layout.tsx @@ -14,6 +14,7 @@ import { zero, } from "@streamplace/components"; import { RichTextMessage } from "@streamplace/components/src/components/chat/chat-message"; +import { VerifiedBadge } from "@streamplace/components/src/components/chat/verified-badge"; import { useAvatars } from "@streamplace/components/src/hooks/useAvatars"; import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; import { EmojiPicker } from "components/emoji-picker/emoji-picker"; @@ -480,6 +481,7 @@ function PinnedCard() { {name} + {handle && name !== author.handle ? ( {" " + handle} diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 28043f102..588c70c71 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -32,6 +32,7 @@ import { UserProfileCard, useProfileCardData, } from "./user-profile-card"; +import { VerifiedBadge } from "./verified-badge"; // Deterministic per-user color, muted so a busy chat doesn't turn into a // rainbow, and clamped for contrast against the dark chat surface: colors @@ -159,6 +160,8 @@ function useNameColor(): ( const MessageBodyWeb = ({ item }: { item: ChatMessageViewHydrated }) => { const nameColor = useNameColor(); + const dids = useMemo(() => [item.author.did], [item.author.did]); + const profile = useAvatars(dids)[item.author.did]; return ( @@ -184,6 +187,7 @@ const MessageBodyWeb = ({ item }: { item: ChatMessageViewHydrated }) => { > {formatHandleWithAt(item.author)} + @@ -202,6 +206,8 @@ const MessageBodyWeb = ({ item }: { item: ChatMessageViewHydrated }) => { // handle opens the same profile bottom sheet via two triggers on one menu. const MessageBodyNative = ({ item }: { item: ChatMessageViewHydrated }) => { const nameColor = useNameColor(); + const dids = useMemo(() => [item.author.did], [item.author.did]); + const profile = useAvatars(dids)[item.author.did]; const { theme } = useTheme(); const data = useProfileCardData(item.author, item.badges); return ( @@ -225,6 +231,7 @@ const MessageBodyNative = ({ item }: { item: ChatMessageViewHydrated }) => { }} > + )} @@ -323,6 +330,7 @@ const AvatarMessageBody = ({ item }: { item: ChatMessageViewHydrated }) => { + {!!item.badges?.length && ( + + + + ); +} + +const BLUESKY_BLUE = "#1185fe"; // token-ok: Bluesky's verification color + +function decodeDataUrlText(dataUrl: string): string | null { + const comma = dataUrl.indexOf(","); + if (comma < 0) return null; + try { + if (/;base64/i.test(dataUrl.slice(0, comma))) { + const bin = atob(dataUrl.slice(comma + 1)); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return new TextDecoder().decode(bytes); + } + return decodeURIComponent(dataUrl.slice(comma + 1)); + } catch { + return null; + } +} + +/** The node's own verified badge: the uploaded verifiedIcon, else the check + * in the brand's primary color. */ +function NodeVerifiedIcon({ size }: { size: number }) { + const { theme } = useTheme(); + const asset = useBrandingAsset("verifiedIcon"); + const data = asset?.data; + const svg = useMemo(() => { + if (!data || !data.startsWith("data:")) return null; + const mime = asset?.mimeType ?? ""; + if (!mime.includes("svg") && !data.startsWith("data:image/svg")) { + return null; + } + const xml = decodeDataUrlText(data); + return xml && xml.includes(" + ); + } + if (data) { + return ( + + ); + } + return ; +} + +/** + * Verified marks beside a chat name. Two sources, two looks: + * - the node's trusted verifiers (branding key verifierDids), which the + * node reports on the message author itself — the branded badge; + * - the public app view's verification (e.g. Bluesky's), read from the + * profile cache — the network's blue check. + * A user verified both ways gets the node's badge only. + */ +export function VerifiedBadge({ + author, + profile, + size = 16, + style, +}: { + author: { verification?: { verifiedStatus?: string } | null }; + profile?: AppBskyActorDefs.ProfileViewDetailed | null; + size?: number; + style?: any; +}) { + const nodeVerified = author.verification?.verifiedStatus === "valid"; + const networkVerified = profile?.verification?.verifiedStatus === "valid"; + if (!nodeVerified && !networkVerified) return null; + return ( + + {nodeVerified ? ( + + ) : ( + + )} + + ); +} diff --git a/js/i18n/locales/en-US/settings.ftl b/js/i18n/locales/en-US/settings.ftl index f4355215c..c634c28ce 100644 --- a/js/i18n/locales/en-US/settings.ftl +++ b/js/i18n/locales/en-US/settings.ftl @@ -295,6 +295,12 @@ branding-network-name-description = What the app calls the social network viewer branding-network-profile-url = Profile link branding-network-profile-url-description = Where a chat user's profile link goes, from the profile card and the message menu. Use {"{handle}"} and {"{did}"} as placeholders. Default is the Bluesky profile page. branding-network-icon-description = Icon shown on those profile links. SVG preferred, max 64KB; unset shows the Bluesky butterfly. +branding-verifiers = Verified users +branding-verifiers-description = DIDs of the verifiers this node trusts, as a JSON list. Anyone they have issued an app.bsky.graph.verification record for shows a verified badge in chat. Bluesky's own verification shows its blue check regardless. +branding-chat-verified-only-description = Restrict chat to users verified by those verifiers. Others see a notice instead of the composer and their messages are not shown. +branding-chat-verified-only-off = Anyone can chat +branding-chat-verified-only-on = Verified only +branding-verified-icon-description = Badge shown beside verified users. SVG preferred, max 64KB, currentColor takes the primary color; unset shows a check in the primary color. branding-login-placeholder = Login handle example branding-login-placeholder-description = The example handle shown in the login form's empty handle field, e.g. you.example.com. branding-default-streamer = Default Streamer diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json index 4d60c9ad7..5d5cd7a06 100644 --- a/js/i18n/public/locales/en-US/settings.json +++ b/js/i18n/public/locales/en-US/settings.json @@ -235,6 +235,12 @@ "branding-network-profile-url": "Profile link", "branding-network-profile-url-description": "Where a chat user's profile link goes, from the profile card and the message menu. Use {} and {} as placeholders. Default is the Bluesky profile page.", "branding-network-icon-description": "Icon shown on those profile links. SVG preferred, max 64KB; unset shows the Bluesky butterfly.", + "branding-verifiers": "Verified users", + "branding-verifiers-description": "DIDs of the verifiers this node trusts, as a JSON list. Anyone they have issued an app.bsky.graph.verification record for shows a verified badge in chat. Bluesky's own verification shows its blue check regardless.", + "branding-chat-verified-only-description": "Restrict chat to users verified by those verifiers. Others see a notice instead of the composer and their messages are not shown.", + "branding-chat-verified-only-off": "Anyone can chat", + "branding-chat-verified-only-on": "Verified only", + "branding-verified-icon-description": "Badge shown beside verified users. SVG preferred, max 64KB, currentColor takes the primary color; unset shows a check in the primary color.", "branding-login-placeholder": "Login handle example", "branding-login-placeholder-description": "The example handle shown in the login form's empty handle field, e.g. you.example.com.", "branding-default-streamer": "Default Streamer", diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json index 4d60c9ad7..5d5cd7a06 100644 --- a/js/web/public/locales/en-US/settings.json +++ b/js/web/public/locales/en-US/settings.json @@ -235,6 +235,12 @@ "branding-network-profile-url": "Profile link", "branding-network-profile-url-description": "Where a chat user's profile link goes, from the profile card and the message menu. Use {} and {} as placeholders. Default is the Bluesky profile page.", "branding-network-icon-description": "Icon shown on those profile links. SVG preferred, max 64KB; unset shows the Bluesky butterfly.", + "branding-verifiers": "Verified users", + "branding-verifiers-description": "DIDs of the verifiers this node trusts, as a JSON list. Anyone they have issued an app.bsky.graph.verification record for shows a verified badge in chat. Bluesky's own verification shows its blue check regardless.", + "branding-chat-verified-only-description": "Restrict chat to users verified by those verifiers. Others see a notice instead of the composer and their messages are not shown.", + "branding-chat-verified-only-off": "Anyone can chat", + "branding-chat-verified-only-on": "Verified only", + "branding-verified-icon-description": "Badge shown beside verified users. SVG preferred, max 64KB, currentColor takes the primary color; unset shows a check in the primary color.", "branding-login-placeholder": "Login handle example", "branding-login-placeholder-description": "The example handle shown in the login form's empty handle field, e.g. you.example.com.", "branding-default-streamer": "Default Streamer", diff --git a/lexicons.json b/lexicons.json index d9b399e4d..addd216bf 100644 --- a/lexicons.json +++ b/lexicons.json @@ -8,6 +8,7 @@ "app.bsky.feed.post", "app.bsky.graph.block", "app.bsky.graph.follow", + "app.bsky.graph.verification", "app.bsky.richtext.facet", "com.atproto.identity.refreshIdentity", "com.atproto.identity.resolveHandle", @@ -110,6 +111,10 @@ "uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.follow", "cid": "bafyreiav7u7jchqtsxr2yy3gsazfqqziw5tcrfniuprrmoariruj3cft5u" }, + "app.bsky.graph.verification": { + "uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.verification", + "cid": "bafyreidn7abfzt5p4gcujys2w4seigbhcfnscos6fmh4hnce2gdormbwpa" + }, "app.bsky.labeler.defs": { "uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.labeler.defs", "cid": "bafyreicxx5i36v5dbqk5vvfzhnta5gajrvc544mnepux4wksrkid7mw3q4" @@ -227,4 +232,4 @@ "cid": "bafyreic3l2rmh2ugirt3jz372wcvy333m7t2ynlyzj2k54oshijs6lxdfu" } } -} \ No newline at end of file +} diff --git a/lexicons/app/bsky/graph/verification.json b/lexicons/app/bsky/graph/verification.json new file mode 100644 index 000000000..3abd4de8e --- /dev/null +++ b/lexicons/app/bsky/graph/verification.json @@ -0,0 +1,42 @@ +{ + "id": "app.bsky.graph.verification", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "subject", + "handle", + "displayName", + "createdAt" + ], + "properties": { + "handle": { + "type": "string", + "format": "handle", + "description": "Handle of the subject the verification applies to at the moment of verifying, which might not be the same at the time of viewing. The verification is only valid if the current handle matches the one at the time of verifying." + }, + "subject": { + "type": "string", + "format": "did", + "description": "DID of the subject the verification applies to." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Date of when the verification was created." + }, + "displayName": { + "type": "string", + "description": "Display name of the subject the verification applies to at the moment of verifying, which might not be the same at the time of viewing. The verification is only valid if the current displayName matches the one at the time of verifying." + } + } + }, + "description": "Record declaring a verification relationship between two accounts. Verifications are only considered valid by an app if issued by an account the app considers trusted." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} \ No newline at end of file diff --git a/pkg/appbsky/graphverification.go b/pkg/appbsky/graphverification.go new file mode 100644 index 000000000..c0c2bfb38 --- /dev/null +++ b/pkg/appbsky/graphverification.go @@ -0,0 +1,57 @@ +// Code generated by glex; DO NOT EDIT. + +// Lexicon schema: app.bsky.graph.verification + +package appbsky + +import ( + "encoding/json" + "io" + + glex "github.com/streamplace/glex/runtime" + cbg "github.com/whyrusleeping/cbor-gen" +) + +func init() { + glex.RegisterType("app.bsky.graph.verification", &GraphVerification{}) +} + +// Record declaring a verification relationship between two accounts. Verifications are only considered valid by an app if issued by an account the app considers trusted. +type GraphVerification struct { + LexiconTypeID string `json:"$type,omitempty"` + // createdAt: Date of when the verification was created. + CreatedAt string `json:"createdAt"` + // displayName: Display name of the subject the verification applies to at the moment of verifying, which might not be the same at the time of viewing. The verification is only valid if the current displayName matches the one at the time of verifying. + DisplayName string `json:"displayName"` + // handle: Handle of the subject the verification applies to at the moment of verifying, which might not be the same at the time of viewing. The verification is only valid if the current handle matches the one at the time of verifying. + Handle string `json:"handle"` + // subject: DID of the subject the verification applies to. + Subject string `json:"subject"` +} + +// RecordTypeID implements glex.Record. +func (t *GraphVerification) RecordTypeID() string { return "app.bsky.graph.verification" } + +// MarshalJSON stamps the $type field, like MarshalCBOR does. The value +// receiver operates on a copy, so the record is never mutated and both +// GraphVerification and *GraphVerification marshal with $type. +func (t GraphVerification) MarshalJSON() ([]byte, error) { + t.LexiconTypeID = "app.bsky.graph.verification" + type alias GraphVerification + return json.Marshal((alias)(t)) +} + +func (t *GraphVerification) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + // stamp $type on a copy so marshal never mutates the record + cp := *t + cp.LexiconTypeID = "app.bsky.graph.verification" + return glex.MarshalCBOR(w, &cp) +} + +func (t *GraphVerification) UnmarshalCBOR(r io.Reader) error { + return glex.UnmarshalCBOR(r, t) +} diff --git a/pkg/atproto/firehose.go b/pkg/atproto/firehose.go index 08b29dfcf..c4faf2c87 100644 --- a/pkg/atproto/firehose.go +++ b/pkg/atproto/firehose.go @@ -537,6 +537,7 @@ var CollectionFilter = []string{ constants.APP_BSKY_GRAPH_BLOCK, constants.APP_BSKY_ACTOR_PROFILE, constants.PLACE_STREAM_LIVE_RECOMMENDATIONS, + constants.APP_BSKY_GRAPH_VERIFICATION, } // indexedCollection reports whether we index anything in this collection: the @@ -724,6 +725,12 @@ func (atsync *ATProtoSynchronizer) handleIndexedOps(ctx context.Context, evt *in atsync.Bus.Publish(evt.Repo, key) } + if collection.String() == constants.APP_BSKY_GRAPH_VERIFICATION { + if err := atsync.Model.DeleteVerification(ctx, uri); err != nil { + log.Error(ctx, "failed to delete verification", "err", err) + } + } + if collection.String() == constants.PLACE_STREAM_CHAT_MESSAGE { msg, err := atsync.Model.GetChatMessage(uri) if err != nil { diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index d3673a66a..cd7ca4211 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -103,6 +103,29 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } go atsync.Bus.Publish(userDID, streamplaceBlock) + case *appbsky.GraphVerification: + // Indexed from any repo, known or not: verifiers are few, and which + // ones this node trusts is decided by branding after the fact. + v := &model.Verification{ + URI: aturi.String(), + CID: cid, + IssuerDID: userDID, + SubjectDID: rec.Subject, + Handle: rec.Handle, + DisplayName: rec.DisplayName, + } + if created, err := aqtime.FromString(rec.CreatedAt); err == nil { + v.CreatedAt = created.Time() + } + err := atsync.Model.CreateVerification(ctx, v) + if errors.Is(err, model.ErrAlreadyIndexed) { + return nil + } + if err != nil { + return fmt.Errorf("failed to create verification: %w", err) + } + return nil + case *appbsky.ActorProfile: if r == nil { // someone we don't know about @@ -223,6 +246,12 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD if scm.Author.Handle == "" || scm.Author.Handle == "handle.invalid" { scm.Author.Handle = atsync.ResolveAuthorHandle(ctx, scm.Author.Did) } + atsync.DecorateVerification(ctx, scm) + if !atsync.ChatAllowed(ctx, scm.Author.Did) { + // Chat is locked to verified users: the message is indexed (the + // lock may lift) but not shown. + return nil + } go atsync.Bus.Publish(rec.Streamer, scm) diff --git a/pkg/atproto/verification.go b/pkg/atproto/verification.go new file mode 100644 index 000000000..75827a1e7 --- /dev/null +++ b/pkg/atproto/verification.go @@ -0,0 +1,206 @@ +package atproto + +import ( + "context" + "encoding/json" + "strings" + "sync" + "time" + + "github.com/bluesky-social/indigo/xrpc" + "stream.place/streamplace/pkg/appbsky" + "stream.place/streamplace/pkg/aqtime" + "stream.place/streamplace/pkg/branding" + "stream.place/streamplace/pkg/constants" + "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/model" + "stream.place/streamplace/pkg/placestream" +) + +// Verification on this node: the operator names trusted verifiers in the +// branding key verifierDids; any app.bsky.graph.verification record one of +// them holds for a user makes that user "verified" here. The records are +// indexed from the firehose (see sync.go) and, because a verifier's history +// predates this node, also pulled from the verifier's repo on a schedule. + +const verifierCacheTTL = 30 * time.Second + +var ( + verifierMu sync.Mutex + verifierCached []string + verifierAt time.Time + verifiedOnlyC bool +) + +// Verifiers returns the trusted verifier DIDs, cached briefly. +func (atsync *ATProtoSynchronizer) Verifiers(ctx context.Context) []string { + verifierMu.Lock() + defer verifierMu.Unlock() + if time.Since(verifierAt) < verifierCacheTTL { + return verifierCached + } + verifierCached = nil + verifiedOnlyC = false + if atsync.CLI != nil && atsync.StatefulDB != nil { + raw := branding.Text(atsync.StatefulDB, atsync.CLI.BroadcasterHost, "verifierDids") + if raw != "" { + var dids []string + if err := json.Unmarshal([]byte(raw), &dids); err == nil { + for _, d := range dids { + d = strings.TrimSpace(d) + if strings.HasPrefix(d, "did:") { + verifierCached = append(verifierCached, d) + } + } + } + } + verifiedOnlyC = branding.Text(atsync.StatefulDB, atsync.CLI.BroadcasterHost, "chatVerifiedOnly") == "on" + } + verifierAt = time.Now() + return verifierCached +} + +// ChatVerifiedOnly reports whether chat is restricted to verified users. +func (atsync *ATProtoSynchronizer) ChatVerifiedOnly(ctx context.Context) bool { + atsync.Verifiers(ctx) + verifierMu.Lock() + defer verifierMu.Unlock() + return verifiedOnlyC && len(verifierCached) > 0 +} + +// verificationsOf returns the trusted verifications for did, or nil. +func (atsync *ATProtoSynchronizer) verificationsOf(ctx context.Context, did string) []model.Verification { + verifiers := atsync.Verifiers(ctx) + if len(verifiers) == 0 || did == "" { + return nil + } + found, err := atsync.Model.VerificationsFor(ctx, []string{did}, verifiers) + if err != nil { + log.Error(ctx, "failed to look up verifications", "did", did, "err", err) + return nil + } + return found[did] +} + +// IsVerified reports whether a trusted verifier vouches for did. +func (atsync *ATProtoSynchronizer) IsVerified(ctx context.Context, did string) bool { + return len(atsync.verificationsOf(ctx, did)) > 0 +} + +// DecorateVerification fills the author's verification state on a chat +// message view from this node's trusted verifiers, in the shape the app view +// uses, so the app renders the same badge either way. +func (atsync *ATProtoSynchronizer) DecorateVerification(ctx context.Context, message *placestream.ChatDefs_MessageView) { + if message == nil { + return + } + vs := atsync.verificationsOf(ctx, message.Author.Did) + if len(vs) == 0 { + return + } + state := &appbsky.ActorDefs_VerificationState{ + VerifiedStatus: "valid", + TrustedVerifierStatus: "none", + } + for _, v := range vs { + state.Verifications = append(state.Verifications, appbsky.ActorDefs_VerificationView{ + CreatedAt: aqtime.FromTime(v.CreatedAt).String(), + IsValid: true, + Issuer: v.IssuerDID, + Uri: v.URI, + }) + } + message.Author.Verification = state +} + +// ChatAllowed reports whether a message from did may be shown, given the +// chat lock: everyone when the lock is off, verified users when it is on. +func (atsync *ATProtoSynchronizer) ChatAllowed(ctx context.Context, did string) bool { + if !atsync.ChatVerifiedOnly(ctx) { + return true + } + return atsync.IsVerified(ctx, did) +} + +// SeedVerificationsForever pulls every verification record from each trusted +// verifier's repo at boot and hourly after, so verifications issued before +// this node existed (or while it was down) count too. The firehose keeps +// the table current in between. +func (atsync *ATProtoSynchronizer) SeedVerificationsForever(ctx context.Context) { + for { + for _, did := range atsync.Verifiers(ctx) { + if err := atsync.seedVerifier(ctx, did); err != nil { + log.Warn(ctx, "failed to seed verifications", "verifier", did, "err", err) + } + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Hour): + } + } +} + +type listRecordsOut struct { + Cursor string `json:"cursor"` + Records []struct { + Uri string `json:"uri"` + Cid string `json:"cid"` + Value json.RawMessage `json:"value"` + } `json:"records"` +} + +func (atsync *ATProtoSynchronizer) seedVerifier(ctx context.Context, did string) error { + ident, err := atsync.resolveIdent(ctx, did, true) + if err != nil { + return err + } + xrpcc := xrpc.Client{Host: ident.PDSEndpoint(), Client: SyncHTTPClient} + if xrpcc.Host == "" { + return nil + } + cursor := "" + total := 0 + for { + params := map[string]any{ + "repo": did, + "collection": constants.APP_BSKY_GRAPH_VERIFICATION, + "limit": 100, + } + if cursor != "" { + params["cursor"] = cursor + } + var out listRecordsOut + if err := xrpcc.Do(ctx, xrpc.Query, "", "com.atproto.repo.listRecords", params, nil, &out); err != nil { + return err + } + for _, r := range out.Records { + var rec appbsky.GraphVerification + if err := json.Unmarshal(r.Value, &rec); err != nil || rec.Subject == "" { + continue + } + v := &model.Verification{ + URI: r.Uri, + CID: r.Cid, + IssuerDID: did, + SubjectDID: rec.Subject, + Handle: rec.Handle, + DisplayName: rec.DisplayName, + } + if created, err := aqtime.FromString(rec.CreatedAt); err == nil { + v.CreatedAt = created.Time() + } + if err := atsync.Model.CreateVerification(ctx, v); err != nil && err != model.ErrAlreadyIndexed { + log.Warn(ctx, "failed to index seeded verification", "uri", r.Uri, "err", err) + continue + } + total++ + } + if out.Cursor == "" || len(out.Records) == 0 { + break + } + cursor = out.Cursor + } + log.Log(ctx, "seeded verifications", "verifier", did, "records", total) + return nil +} diff --git a/pkg/branding/text.go b/pkg/branding/text.go new file mode 100644 index 000000000..0e4dfd3a3 --- /dev/null +++ b/pkg/branding/text.go @@ -0,0 +1,27 @@ +package branding + +import ( + "strings" + + "stream.place/streamplace/pkg/statedb" +) + +// Text reads a text-valued branding key for the node whose broadcaster host +// is host, under the did:web key the admin UI writes and, failing that, the +// bare host older nodes used. Unset or unreadable keys read as "". +func Text(state *statedb.StatefulDB, host, key string) string { + if state == nil || host == "" { + return "" + } + for _, id := range []string{"did:web:" + host, host} { + blob, err := state.GetBrandingBlob(id, key) + if err != nil || blob == nil { + continue + } + if blob.MimeType != TextMime { + return "" + } + return strings.TrimSpace(string(blob.Data)) + } + return "" +} diff --git a/pkg/branding/vocab.go b/pkg/branding/vocab.go index f097f4978..be105e550 100644 --- a/pkg/branding/vocab.go +++ b/pkg/branding/vocab.go @@ -76,6 +76,8 @@ var Specs = []Spec{ {Key: "appLayout", Kind: KindEnum, Enum: []string{"classic", "social"}, MaxSize: textMax, Doc: "App shell: classic Streamplace chrome, or social (no top bar, avatar atop the nav rail, fixed-width feed column, timeline-style nav)."}, {Key: "streamLayout", Kind: KindEnum, Enum: []string{"classic", "card"}, MaxSize: textMax, Doc: "Stream page shape: classic full-width player, or card (post in a feed column with live chat beside it)."}, {Key: "typeface", Kind: KindEnum, Enum: []string{"geist", "inter"}, MaxSize: textMax, Doc: "Sans-serif typeface."}, + {Key: "verifierDids", Kind: KindJSON, MaxSize: jsonMax, Doc: "DIDs whose app.bsky.graph.verification records mark a chat user as verified: [\"did:plc:...\"]."}, + {Key: "chatVerifiedOnly", Kind: KindEnum, Enum: []string{"on", "off"}, MaxSize: textMax, Doc: "Only users verified by one of verifierDids may chat (their messages are the only ones shown)."}, {Key: "chatNameColors", Kind: KindEnum, Enum: []string{"on", "off"}, MaxSize: textMax, Doc: "Whether chat shows display names in each user's chosen color (on) or the default text color (off)."}, {Key: "chatLayout", Kind: KindEnum, Enum: []string{"compact", "avatar"}, MaxSize: textMax, Doc: "Chat message shape: compact one-line rows, or avatar rows with name, handle and time above the text."}, {Key: "navLinks", Kind: KindJSON, MaxSize: jsonMax, Doc: "Navigation links replacing the sidebar sections: [{label, url, icon}]."}, @@ -89,6 +91,7 @@ var Specs = []Spec{ {Key: "favicon", Kind: KindImage, MaxSize: 100 * 1024, Doc: "Browser tab icon."}, {Key: "sidebarBackgroundImage", Kind: KindImage, MaxSize: imageMax, Doc: "Decorative image at the bottom of the sidebar."}, {Key: "linkBanner", Kind: KindImage, MaxSize: 2 * 1024 * 1024, Doc: "OpenGraph image for the front page's link card (1200x630)."}, + {Key: "verifiedIcon", Kind: KindImage, MaxSize: iconMax, Doc: "Badge shown beside verified chat users (SVG preferred); default a check in the primary color."}, {Key: "networkIcon", Kind: KindImage, MaxSize: iconMax, Doc: "Icon on chat profile links to the network (SVG preferred); default the Bluesky butterfly."}, {Key: "socialIcon1", Kind: KindImage, MaxSize: iconMax, Doc: "Custom icon for socialLinks (SVG preferred; use currentColor to take the sidebar's icon color)."}, {Key: "socialIcon2", Kind: KindImage, MaxSize: iconMax, Doc: "Custom icon for socialLinks (SVG preferred; use currentColor to take the sidebar's icon color)."}, diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index 8d947bdd8..40b207934 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -359,6 +359,9 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu // it, and at full speed it is what buries a boot. It trickles in at // --deepen-rate windows a minute for as long as this node runs. go atsync.DeepenForever(ctx) + // Trusted verifiers' records predate this node; pull them at boot and + // hourly, the firehose keeps them current in between. + go atsync.SeedVerificationsForever(ctx) var replicator replication.Replicator = nil if slices.Contains(cli.Replicators, config.ReplicatorIroh) { diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 4717925d1..cf7d4d119 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -13,6 +13,7 @@ var STREAMPLACE_SIGNING_KEY = "signingKey" // var APP_BSKY_GRAPH_FOLLOW = "app.bsky.graph.follow" //nolint:all var APP_BSKY_FEED_POST = "app.bsky.feed.post" //nolint:all var APP_BSKY_GRAPH_BLOCK = "app.bsky.graph.block" //nolint:all +var APP_BSKY_GRAPH_VERIFICATION = "app.bsky.graph.verification" //nolint:all var APP_BSKY_ACTOR_PROFILE = "app.bsky.actor.profile" //nolint:all var PLACE_STREAM_CHAT_GATE = "place.stream.chat.gate" //nolint:all var PLACE_STREAM_CHAT_PINNED_RECORD = "place.stream.chat.pinnedRecord" //nolint:all diff --git a/pkg/model/model.go b/pkg/model/model.go index 3e5b0045e..d8c5525d9 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -79,6 +79,9 @@ type Model interface { DenyTeleport(ctx context.Context, uri string) error CreateBlock(ctx context.Context, block *Block) error + CreateVerification(ctx context.Context, v *Verification) error + DeleteVerification(ctx context.Context, uri string) error + VerificationsFor(ctx context.Context, subjectDIDs []string, issuerDIDs []string) (map[string][]Verification, error) GetBlock(ctx context.Context, rkey string) (*Block, error) GetUserBlock(ctx context.Context, userDID, subjectDID string) (*Block, error) DeleteBlock(ctx context.Context, rkey string) error @@ -323,6 +326,7 @@ func MakeDBConns(dbURL string, conns int) (Model, error) { VodComment{}, Like{}, VodGate{}, + Verification{}, } { err = db.AutoMigrate(model) if err != nil { diff --git a/pkg/model/verification.go b/pkg/model/verification.go new file mode 100644 index 000000000..8b8f2b0ae --- /dev/null +++ b/pkg/model/verification.go @@ -0,0 +1,65 @@ +package model + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +// Verification is one app.bsky.graph.verification record: a verifier (the +// repo it lives in) vouching for a subject. Indexed from every repo on the +// firehose, not only tracked ones, because verifiers are few and which of +// them a node trusts is a branding decision (verifierDids) made after the +// fact. +type Verification struct { + URI string `json:"uri" gorm:"primaryKey;column:uri"` + CID string `json:"cid" gorm:"column:cid"` + IssuerDID string `json:"issuerDID" gorm:"column:issuer_did;index:idx_verification_subject_issuer,priority:2"` + SubjectDID string `json:"subjectDID" gorm:"column:subject_did;index:idx_verification_subject_issuer,priority:1"` + Handle string `json:"handle" gorm:"column:handle"` + DisplayName string `json:"displayName" gorm:"column:display_name"` + CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` + IndexedAt time.Time `json:"indexedAt" gorm:"column:indexed_at"` +} + +func (v *Verification) recordCID() string { return v.CID } +func (v *Verification) recordURI() string { return v.URI } + +// CreateVerification indexes one verification record, idempotently. +func (m *DBModel) CreateVerification(ctx context.Context, v *Verification) error { + if v.IndexedAt.IsZero() { + v.IndexedAt = time.Now() + } + return createOrVerify(ctx, m, v, map[string]any{"uri": v.URI}) +} + +// DeleteVerification removes a verification by record URI. +func (m *DBModel) DeleteVerification(ctx context.Context, uri string) error { + return m.DB.WithContext(ctx).Where("uri = ?", uri).Delete(&Verification{}).Error +} + +// VerificationsFor returns the verifications of the given subjects issued by +// any of the given issuers, keyed by subject DID. +func (m *DBModel) VerificationsFor(ctx context.Context, subjectDIDs []string, issuerDIDs []string) (map[string][]Verification, error) { + out := map[string][]Verification{} + if len(subjectDIDs) == 0 || len(issuerDIDs) == 0 { + return out, nil + } + var rows []Verification + err := m.DB.WithContext(ctx). + Where("subject_did IN ? AND issuer_did IN ?", subjectDIDs, issuerDIDs). + Order("created_at ASC"). + Find(&rows).Error + if err != nil && !isNotFound(err) { + return nil, err + } + for _, r := range rows { + out[r.SubjectDID] = append(out[r.SubjectDID], r) + } + return out, nil +} + +func isNotFound(err error) bool { + return err == gorm.ErrRecordNotFound +}