From defabe4b7856eff270848999da9a5fcf67e6ffd1 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Thu, 9 Jul 2026 15:54:14 -0700 Subject: [PATCH] auth: let users decline Bluesky permissions at login Adds a checkbox to the handle-entry screen (with a preview of the red Bluesky LIVE avatar ring, since that's the headline thing being declined) that requests an OAuth scope without the repo?collection=app.bsky.* write grants. The read-only rpc:app.bsky.* scopes are kept for now; removing them entirely (in favor of caching profiles/avatars ourselves) is a follow-up. Server side, sessions now track their granted scope via oatproxy (bumped to the fable/dynamic-scopes branch, which accepts subset scopes at PAR time, reports honest scope in tokens, and adds the RFC 7662 /oauth/introspect endpoint it always advertised): - statedb.GetSessionByDIDWithScope scans a user's valid sessions for one holding a grant, newest first; sessions from before scope tracking count as full grants, so no data migration. - The director's live-status writes (the red ring) pick a session with the app.bsky.actor.status grant and quietly skip when the user declined it everywhere; startLivestream skips the go-live post the same way. The frontend learns its own scope from /oauth/introspect after login and locks the "Create Bluesky post" checkbox when posting wasn't granted. Co-Authored-By: Claude Fable 5 --- go.mod | 4 +- go.sum | 8 +- .../live-dashboard/livestream-panel.tsx | 39 +++++++-- js/app/components/login/login-form.tsx | 77 ++++++++++++++++- js/app/features/bluesky/scopes.ts | 33 +++++++ js/app/store/slices/blueskySlice.ts | 45 +++++++++- pkg/atproto/lexicon_permission_sets.go | 12 ++- pkg/director/stream_session.go | 25 +++++- pkg/spxrpc/place_stream_live.go | 8 +- pkg/statedb/oauth_session.go | 24 ++++++ pkg/statedb/oauth_session_test.go | 85 +++++++++++++++++++ 11 files changed, 337 insertions(+), 23 deletions(-) create mode 100644 js/app/features/bluesky/scopes.ts create mode 100644 pkg/statedb/oauth_session_test.go diff --git a/go.mod b/go.mod index a83ed7e7c..fcf3c41a1 100644 --- a/go.mod +++ b/go.mod @@ -66,9 +66,9 @@ require ( github.com/slok/go-http-metrics v0.13.0 github.com/starttoaster/prometheus-exporter-scraper v0.0.1 github.com/streamplace/atmoq-go v0.0.2 - github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac + github.com/streamplace/atproto-oauth-golang v0.0.0-20260413212710-98956064d06c github.com/streamplace/muxl/go v0.3.4 - github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44 + github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b github.com/stretchr/testify v1.11.1 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 github.com/tus/tusd/v2 v2.8.0 diff --git a/go.sum b/go.sum index 755f71d70..dd9a34e41 100644 --- a/go.sum +++ b/go.sum @@ -1370,16 +1370,16 @@ github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8B github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= github.com/streamplace/atmoq-go v0.0.2 h1:lLfJ9R88wzkugqMMrRIcy2Sz4s3fp6TLesf3iwG4/Vo= github.com/streamplace/atmoq-go v0.0.2/go.mod h1:r4KV7lW5KWfAJa2OgNXA4fgDHHcF3pNa9vGEUj9z4e0= -github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac h1:heVM4CGox3kfJclSmagsI3hvKk7W52EegnFqqs9CSYk= -github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac/go.mod h1:9LlKkqciiO5lRfbX0n4Wn5KNY9nvFb4R3by8FdW2TWc= +github.com/streamplace/atproto-oauth-golang v0.0.0-20260413212710-98956064d06c h1:IzEPU2O4iL58Nb7aw+7lB9ttnesEwOVVE5oV9NEXemM= +github.com/streamplace/atproto-oauth-golang v0.0.0-20260413212710-98956064d06c/go.mod h1:9LlKkqciiO5lRfbX0n4Wn5KNY9nvFb4R3by8FdW2TWc= github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAyNnkwfuZubgfeZy8rkWmA0cMtH5Z0HqNc= github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 h1:e8M3qPLr37NxEjlr18TaAwGP+OVyherVjgUG5VVmgWI= github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0= github.com/streamplace/muxl/go v0.3.4 h1:M/G8CRKjAmfsyWtolLziUtNrNtS3lJFq/H6IhnY1yBo= github.com/streamplace/muxl/go v0.3.4/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8= -github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44 h1:b38ToXNQCvKqBlx0SeQYUOhx6uqqnrF5AL6B0Z3zzdk= -github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44/go.mod h1:j1+zdhe1IC0+PTE2rIXk2LqYn4Lz2x9SJaV3eHkQyfs= +github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b h1:eWbwCtBbMyrDTHLYIold07OR2hmvzXsbAUxi57ElMLk= +github.com/streamplace/oatproxy v0.0.0-20260710202406-60d97b9d780b/go.mod h1:wpY+T/wE00jrUhgh2dKXbbE91D36u86KGlENK/hWFkE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index 55b060118..ef34417e5 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -26,7 +26,7 @@ import { zero, } from "@streamplace/components"; import { Image } from "expo-image"; -import { ChevronsUpDown, ImagePlus, X } from "lucide-react-native"; +import { ChevronsUpDown, ImagePlus, Lock, X } from "lucide-react-native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Platform, @@ -35,8 +35,13 @@ import { TouchableOpacity, View, } from "react-native"; +import { useStore } from "store"; import { useUserProfile } from "store/hooks"; import type { PlaceStreamLivestream } from "streamplace"; +import { + SCOPE_BSKY_POST_CREATE, + scopeGrants, +} from "../../features/bluesky/scopes"; import { useCaptureVideoFrame } from "../../hooks/useCaptureVideoFrame"; import { useLiveUser } from "../../hooks/useLiveUser"; import ActivityPicker from "../activity-picker"; @@ -322,6 +327,8 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { const [tags, setTags] = useState([]); const [tagInput, setTagInput] = useState(""); + const sessionScope = useStore((s) => s.sessionScope); + const canPostToBluesky = scopeGrants(sessionScope, SCOPE_BSKY_POST_CREATE); const [createPost, setCreatePost] = useState(true); const [idleTimeout, setIdleTimeout] = useState(true); const [sendPushNotification, setSendPushNotification] = useState(true); @@ -404,7 +411,7 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { await createStreamRecord({ title: title.trim(), customThumbnail: thumbnailToUse as Blob | undefined, - submitPost: createPost, + submitPost: createPost && canPostToBluesky, notificationSettings: { pushNotification: sendPushNotification, }, @@ -787,14 +794,30 @@ function LivestreamPanel({ scrollable = true }: { scrollable?: boolean }) { ]} > - setCreatePost(checked)} - label="Create Bluesky post" - /> + + setCreatePost(checked)} + disabled={!canPostToBluesky} + label="Create Bluesky post" + /> + {!canPostToBluesky && ( + + )} + { @@ -283,6 +285,79 @@ export default function LoginForm({ + + {/* preview of the Bluesky LIVE ring the user gets while streaming */} + + + {avatarUri ? ( + + ) : ( + + + + )} + + + + LIVE + + + + + + !s.startsWith(BSKY_REPO_SCOPE_PREFIX)) + .join(" "); +} + +// Whether the current session's granted scope includes `wanted`. A null +// sessionScope means we couldn't determine it (older server or the +// introspection call failed); those sessions predate declinable permissions +// and were granted everything, so treat unknown as granted. +export function scopeGrants( + sessionScope: string | null, + wanted: string, +): boolean { + if (!sessionScope) { + return true; + } + return sessionScope.split(" ").includes(wanted); +} diff --git a/js/app/store/slices/blueskySlice.ts b/js/app/store/slices/blueskySlice.ts index 1590b2476..a42092b0a 100644 --- a/js/app/store/slices/blueskySlice.ts +++ b/js/app/store/slices/blueskySlice.ts @@ -24,6 +24,7 @@ import { privateKeyToAccount } from "viem/accounts"; import { StateCreator } from "zustand"; import createOAuthClient from "../../features/bluesky/oauthClient"; import { OAuthClient } from "../../features/bluesky/oauthClientImport"; +import { withoutBlueskyScopes } from "../../features/bluesky/scopes"; import { DID_KEY, STORED_KEY_KEY, StreamKey } from "./baseSlice"; type NewLivestream = { @@ -36,6 +37,9 @@ export interface BlueskySlice { authStatus: "start" | "loggedIn" | "loggedOut"; oauthState: null | string; oauthSession?: null | OAuthSession; + // granted OAuth scope of the current session (from /oauth/introspect); + // null means unknown, which is treated as a full grant + sessionScope: null | string; pdsAgent: null | StreamplaceAgent; anonPDSAgent: null | StreamplaceAgent; profiles: { [key: string]: ProfileViewDetailed }; @@ -77,7 +81,9 @@ export interface BlueskySlice { login: ( handle: string, openLoginLink: (url: string) => Promise, + options?: { blueskyPermissions?: boolean }, ) => Promise; + refreshSessionScope: () => Promise; logout: () => Promise; getProfile: (actor: string) => Promise; getProfiles: (actors: string[]) => Promise; @@ -175,6 +181,7 @@ export const createBlueskySlice: StateCreator< authStatus: "start", oauthState: null, oauthSession: undefined, + sessionScope: null, pdsAgent: null, anonPDSAgent: null, profiles: {}, @@ -289,6 +296,7 @@ export const createBlueskySlice: StateCreator< pdsAgent: new StreamplaceAgent(session), anonPDSAgent, }); + void (get() as BlueskySlice).refreshSessionScope(); } else { set({ oauthSession: session, @@ -317,9 +325,35 @@ export const createBlueskySlice: StateCreator< }); }, + refreshSessionScope: async () => { + const session = (get() as BlueskySlice).oauthSession; + if (!session) { + set({ sessionScope: null }); + return; + } + try { + const res = await session.fetchHandler("/oauth/introspect", { + method: "POST", + }); + if (!res.ok) { + throw new Error(`introspection failed with status ${res.status}`); + } + const data = await res.json(); + set({ + sessionScope: + data?.active && typeof data.scope === "string" ? data.scope : null, + }); + } catch (error) { + // older servers don't serve /oauth/introspect; treat scope as unknown + console.error("failed to introspect oauth session", error); + set({ sessionScope: null }); + } + }, + login: async ( handle: string, openLoginLink: (url: string) => Promise, + options?: { blueskyPermissions?: boolean }, ) => { console.log("Logging in"); set({ @@ -336,7 +370,14 @@ export const createBlueskySlice: StateCreator< throw new Error("No client"); } console.log("Authorizing"); - const u = await updatedState.client.authorize(handle, {}); + const authorizeOptions: { scope?: string } = {}; + if (options?.blueskyPermissions === false) { + const fullScope = updatedState.client.clientMetadata.scope; + if (fullScope) { + authorizeOptions.scope = withoutBlueskyScopes(fullScope); + } + } + const u = await updatedState.client.authorize(handle, authorizeOptions); if ( typeof document !== "undefined" && document.location.href.startsWith("http://127.0.0.1") @@ -381,6 +422,7 @@ export const createBlueskySlice: StateCreator< set({ oauthSession: null, pdsAgent: null, + sessionScope: null, authStatus: "loggedOut", }); }, @@ -462,6 +504,7 @@ export const createBlueskySlice: StateCreator< pdsAgent: new StreamplaceAgent(ret.session), authStatus: "loggedIn", }); + void (get() as BlueskySlice).refreshSessionScope(); } catch (e) { let message = e.message; while (e.cause) { diff --git a/pkg/atproto/lexicon_permission_sets.go b/pkg/atproto/lexicon_permission_sets.go index 679b4cc38..26c761ee0 100644 --- a/pkg/atproto/lexicon_permission_sets.go +++ b/pkg/atproto/lexicon_permission_sets.go @@ -8,6 +8,14 @@ import ( "github.com/bluesky-social/indigo/atproto/lexicon" ) +// Scope values for acting on the user's Bluesky account. Users may decline +// these at login, so anything that needs one must check the session's +// granted scope and degrade gracefully. +const ( + ScopeBskyPostCreate = "repo?collection=app.bsky.feed.post&action=create" + ScopeBskyActorStatus = "repo?collection=app.bsky.actor.status" +) + func generatePermissionSets(ctx context.Context, lexs []*lexicon.SchemaFile) ([]*lexicon.SchemaFile, error) { recordLexicons := []*lexicon.SchemaFile{} for _, lex := range lexs { @@ -27,8 +35,8 @@ func generatePermissionSets(ctx context.Context, lexs []*lexicon.SchemaFile) ([] allCollectionStrings := []string{ "atproto", "blob:*/*", - "repo?collection=app.bsky.feed.post&action=create", - "repo?collection=app.bsky.actor.status", + ScopeBskyPostCreate, + ScopeBskyActorStatus, "repo?collection=app.bsky.graph.block", "repo?collection=app.bsky.graph.follow", "repo?collection=app.bsky.actor.profile", diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go index 8f39be586..3a8354502 100644 --- a/pkg/director/stream_session.go +++ b/pkg/director/stream_session.go @@ -3,9 +3,11 @@ package director import ( "bytes" "context" + "errors" "fmt" "io" "net/url" + "strings" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -427,7 +429,12 @@ func (ss *StreamSession) statusUpdateLoop(ctx context.Context, repoDID string) e func (ss *StreamSession) doUpdateStatus(ctx context.Context, repoDID string) error { ctx = log.WithLogValues(ctx, "func", "doUpdateStatus") - client, err := ss.GetClientByDID(repoDID) + client, err := ss.GetClientByDID(repoDID, atproto.ScopeBskyActorStatus) + if errors.Is(err, statedb.ErrNoSessionWithScope) { + log.Debug(ctx, "user declined Bluesky permissions, skipping live status update", "repoDID", repoDID) + ss.lastStatus = time.Now() + return nil + } if err != nil { return fmt.Errorf("could not get xrpc client: %w", err) } @@ -649,7 +656,7 @@ func (ss *StreamSession) DeleteStatus(repoDID string) error { inp.SwapRecord = ss.lastStatusCID out := comatproto.RepoDeleteRecord_Output{} - client, err := ss.GetClientByDID(repoDID) + client, err := ss.GetClientByDID(repoDID, atproto.ScopeBskyActorStatus) if err != nil { return fmt.Errorf("could not get xrpc client: %w", err) } @@ -884,7 +891,11 @@ type XRPCClient interface { Do(ctx context.Context, method string, contentType string, path string, queryParams map[string]any, body any, out any) error } -func (ss *StreamSession) GetClientByDID(did string) (XRPCClient, error) { +// GetClientByDID returns an XRPC client acting as the given user. If +// requiredScope values are passed, the user's sessions are scanned for one +// that was granted all of them; statedb.ErrNoSessionWithScope means the user +// declined those permissions everywhere they're logged in. +func (ss *StreamSession) GetClientByDID(did string, requiredScope ...string) (XRPCClient, error) { password, ok := ss.cli.DevAccountCreds[did] if ok { repo, err := ss.mod.GetRepoByHandleOrDID(did) @@ -919,7 +930,13 @@ func (ss *StreamSession) GetClientByDID(did string) (XRPCClient, error) { }, }, nil } - session, err := ss.statefulDB.GetSessionByDID(ss.repoDID) + var session *oatproxy.OAuthSession + var err error + if len(requiredScope) > 0 { + session, err = ss.statefulDB.GetSessionByDIDWithScope(ss.repoDID, strings.Join(requiredScope, " ")) + } else { + session, err = ss.statefulDB.GetSessionByDID(ss.repoDID) + } if err != nil { return nil, fmt.Errorf("could not get OAuth session for repoDID: %w", err) } diff --git a/pkg/spxrpc/place_stream_live.go b/pkg/spxrpc/place_stream_live.go index a3df1847d..3e9ea1304 100644 --- a/pkg/spxrpc/place_stream_live.go +++ b/pkg/spxrpc/place_stream_live.go @@ -21,6 +21,7 @@ import ( "github.com/gorilla/websocket" "github.com/labstack/echo/v4" "github.com/streamplace/oatproxy/pkg/oatproxy" + "stream.place/streamplace/pkg/atproto" "stream.place/streamplace/pkg/log" "stream.place/streamplace/pkg/spid" "stream.place/streamplace/pkg/spmetrics" @@ -590,7 +591,12 @@ func (s *Server) handlePlaceStreamLiveStartLivestream(ctx context.Context, body canonicalUrl = *livestream.CanonicalUrl } - if body.CreateBlueskyPost == nil || *body.CreateBlueskyPost { + createPost := body.CreateBlueskyPost == nil || *body.CreateBlueskyPost + if createPost && !session.HasScope(atproto.ScopeBskyPostCreate) { + log.Debug(ctx, "session was not granted Bluesky post permissions, skipping go-live post", "did", session.DID) + createPost = false + } + if createPost { prefix := "🔴 LIVE " suffix := " " + livestream.Title postText := prefix + canonicalUrl + suffix diff --git a/pkg/statedb/oauth_session.go b/pkg/statedb/oauth_session.go index d66f26d59..217501246 100644 --- a/pkg/statedb/oauth_session.go +++ b/pkg/statedb/oauth_session.go @@ -62,3 +62,27 @@ func (state *StatefulDB) GetSessionByDID(did string) (*oatproxy.OAuthSession, er } return &session, nil } + +// ErrNoSessionWithScope means the user has valid sessions, but none of them +// was granted the required scope — i.e. they declined those permissions on +// every device they're logged in on. +var ErrNoSessionWithScope = errors.New("no session with required scope") + +// GetSessionByDIDWithScope returns the most recently used valid session for +// the DID that was granted every scope value in scope (space-separated). +// Sessions from before scope tracking count as full grants. +func (state *StatefulDB) GetSessionByDIDWithScope(did string, scope string) (*oatproxy.OAuthSession, error) { + var sessions []oatproxy.OAuthSession + if err := state.DB.Where("repo_did = ? AND revoked_at IS NULL", did).Order("updated_at DESC").Find(&sessions).Error; err != nil { + return nil, err + } + if len(sessions) == 0 { + return nil, gorm.ErrRecordNotFound + } + for i := range sessions { + if sessions[i].HasScope(scope) { + return &sessions[i], nil + } + } + return nil, fmt.Errorf("%w: did=%s scope=%s", ErrNoSessionWithScope, did, scope) +} diff --git a/pkg/statedb/oauth_session_test.go b/pkg/statedb/oauth_session_test.go new file mode 100644 index 000000000..5708ad1d2 --- /dev/null +++ b/pkg/statedb/oauth_session_test.go @@ -0,0 +1,85 @@ +package statedb + +import ( + "testing" + "time" + + "github.com/streamplace/oatproxy/pkg/oatproxy" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// literals instead of the pkg/atproto constants: that package imports +// statedb, so the test can't import it back +const ( + scopeBskyActorStatus = "repo?collection=app.bsky.actor.status" + scopeBskyPostCreate = "repo?collection=app.bsky.feed.post&action=create" +) + +func TestGetSessionByDIDWithScope(t *testing.T) { + WithAllDatabases(t, func(state *StatefulDB) { + did := "did:plc:scopetest" + noBskyScope := "atproto blob:*/* include:place.stream.authFull" + fullScope := noBskyScope + " " + scopeBskyPostCreate + " " + scopeBskyActorStatus + + mkSession := func(jkt, scope string, updatedAt time.Time, revoked bool) { + session := &oatproxy.OAuthSession{ + DID: did, + DownstreamDPoPJKT: jkt, + DownstreamScope: scope, + UpstreamScope: scope, + } + if revoked { + now := time.Now() + session.RevokedAt = &now + } + require.NoError(t, state.CreateOAuthSession(jkt, session)) + require.NoError(t, state.DB.Model(&oatproxy.OAuthSession{}). + Where("downstream_dpop_jkt = ?", jkt). + Update("updated_at", updatedAt).Error) + } + + // no sessions at all + _, err := state.GetSessionByDIDWithScope(did, scopeBskyActorStatus) + require.ErrorIs(t, err, gorm.ErrRecordNotFound) + + // newest session declined the Bluesky scopes + mkSession("jkt-declined", noBskyScope, time.Now(), false) + _, err = state.GetSessionByDIDWithScope(did, scopeBskyActorStatus) + require.ErrorIs(t, err, ErrNoSessionWithScope) + + // ...but it still satisfies the streamplace scope + got, err := state.GetSessionByDIDWithScope(did, "include:place.stream.authFull") + require.NoError(t, err) + require.Equal(t, "jkt-declined", got.DownstreamDPoPJKT) + + // an older full-scope session on another device gets picked for + // Bluesky writes even though the declined one is newer + mkSession("jkt-full", fullScope, time.Now().Add(-time.Hour), false) + got, err = state.GetSessionByDIDWithScope(did, scopeBskyActorStatus) + require.NoError(t, err) + require.Equal(t, "jkt-full", got.DownstreamDPoPJKT) + + // plain GetSessionByDID still returns the newest session + got, err = state.GetSessionByDID(did) + require.NoError(t, err) + require.Equal(t, "jkt-declined", got.DownstreamDPoPJKT) + + // revoked sessions don't count + mkSession("jkt-revoked", fullScope, time.Now().Add(time.Hour), true) + got, err = state.GetSessionByDIDWithScope(did, scopeBskyActorStatus) + require.NoError(t, err) + require.Equal(t, "jkt-full", got.DownstreamDPoPJKT) + + // legacy sessions with no recorded scope count as full grants + legacyDID := "did:plc:legacy" + legacy := &oatproxy.OAuthSession{ + DID: legacyDID, + DownstreamDPoPJKT: "jkt-legacy", + } + require.NoError(t, state.CreateOAuthSession("jkt-legacy", legacy)) + got, err = state.GetSessionByDIDWithScope(legacyDID, scopeBskyActorStatus) + require.NoError(t, err) + require.Equal(t, "jkt-legacy", got.DownstreamDPoPJKT) + }) +} -- 2.51.2