···11import { useRoute } from "@react-navigation/native";
22-import {
33- LivestreamProvider,
44- PlayerProvider,
55- zero,
66-} from "@streamplace/components";
22+import { LivestreamProvider, PlayerProvider } from "@streamplace/components";
73import BentoGrid from "components/live-dashboard/bento-grid";
84import Loading from "components/loading/loading";
95import { VideoElementProvider } from "contexts/VideoElementContext";
···117import { useCallback, useEffect, useState } from "react";
128import { useStore } from "store";
139import { useIsReady, useUserProfile } from "store/hooks";
1414-1515-const { flex, bg } = zero;
16101711export default function LiveDashboard() {
1812 const isReady = useIsReady();
+3-2
js/app/src/screens/mobile-stream.tsx
···22 KeepAwake,
33 LivestreamProvider,
44 PlayerProvider,
55+ Text,
56 useLivestreamStore,
67} from "@streamplace/components";
78import { Player } from "components/mobile/player";
89import { PlayerProps } from "components/player/props";
910import { FullscreenProvider } from "contexts/FullscreenContext";
1011import useTitle from "hooks/useTitle";
1111-import { Platform, Text, View } from "react-native";
1212+import { Platform, View } from "react-native";
1213import { queryToProps } from "./util";
13141415const isWeb = Platform.OS === "web";
···5859}
59606061export default function MobileStream({ route }) {
6161- const { user, protocol, url } = route.params;
6262+ const { user, protocol, url } = route?.params ?? {};
6263 let extraProps: Partial<PlayerProps> = {};
6364 if (isWeb) {
6465 extraProps = queryToProps(new URLSearchParams(window.location.search));
+65
js/components/locales/en-US/settings.ftl
···166166go-to-dashboard = Go to Dashboard
167167need-setup-live-dashboard = Need to set up streaming first? Visit the live dashboard
168168no-languages-found = No languages found
169169+170170+## Branding Administration
171171+branding-admin = Branding Administration
172172+branding-admin-description = Customize your Streamplace instance. Note that settings may take a few hours to propagate.
173173+branding-login-required = Please log in to manage branding
174174+branding-configuration = Configuration
175175+branding-text-settings = Text Settings
176176+branding-colors = Colors
177177+branding-legal-links = Legal Links
178178+branding-images = Images
179179+180180+## Branding Fields
181181+branding-broadcaster-did = Broadcaster DID
182182+branding-broadcaster-did-description = Leave empty to use server default
183183+branding-site-title = Site Title
184184+branding-site-title-placeholder = Enter new site title
185185+branding-site-description = Site Description
186186+branding-site-description-placeholder = Enter site description
187187+branding-default-streamer = Default Streamer
188188+branding-default-streamer-none = None
189189+branding-default-streamer-placeholder = did:plc:...
190190+branding-clear-default-streamer = Clear Default Streamer
191191+branding-primary-color = Primary Color
192192+branding-primary-color-placeholder = #6366f1
193193+branding-accent-color = Accent Color
194194+branding-accent-color-placeholder = #8b5cf6
195195+branding-main-logo = Main Logo
196196+branding-main-logo-description = SVG, PNG, or JPEG (max 500KB)
197197+branding-favicon = Favicon
198198+branding-favicon-description = SVG, PNG, or ICO (max 100KB)
199199+branding-sidebar-bg = Sidebar Background Image
200200+branding-sidebar-bg-description = SVG, PNG, or JPEG (max 500kb) - appears aligned to bottom of sidebar, full width. Upload an image with opacity for best results, as there is not currently a separate opacity option.
201201+branding-current = Current: { $value }
202202+branding-dimensions = { $height } x { $width }
203203+204204+## Branding Actions
205205+branding-upload-logo = Upload Logo
206206+branding-delete-logo = Delete Logo
207207+branding-upload-favicon = Upload Favicon
208208+branding-delete-favicon = Delete Favicon
209209+branding-upload-background = Upload Background
210210+branding-delete-background = Delete Background
211211+branding-web-only = Image uploads are only available on web.
212212+213213+## Branding Legal Links
214214+refresh-branding = Refresh branding assets
215215+branding-add-legal-link = Add Legal Link
216216+branding-edit-legal-link = Edit Legal Link
217217+branding-legal-link-text-placeholder = Link text (e.g., Privacy Policy)
218218+branding-legal-link-url-placeholder = URL (e.g., https://example.com/privacy)
219219+add = Add
220220+edit = Edit
221221+222222+## Branding Toast Messages
223223+branding-not-authenticated = Please log in first
224224+branding-empty-value = Please enter a value
225225+branding-update-success = { $key } updated successfully
226226+branding-upload-success = { $key } uploaded successfully
227227+branding-delete-success = { $key } deleted successfully
228228+branding-upload-failed = Failed to upload
229229+branding-delete-failed = Failed to delete
230230+branding-not-available = File uploads are only available on web
231231+232232+## Navigation Categories (About Page)
233233+node-legal-documents = Broadcaster-specific Documents
···11// barrel file :)
22export * from "./useAvatars";
33export * from "./useCameraToggle";
44+export * from "./useDocumentTitle";
45export * from "./useKeyboard";
56export * from "./useKeyboardSlide";
67export * from "./useLivestreamInfo";
+45
js/components/src/hooks/useDocumentTitle.tsx
···11+import { useEffect } from "react";
22+import { Platform } from "react-native";
33+import {
44+ useFavicon,
55+ useSiteDescription,
66+ useSiteTitle,
77+} from "../streamplace-store";
88+99+/**
1010+ * Hook to set the document title, description, and favicon on web based on branding.
1111+ * No-op on native platforms.
1212+ */
1313+export function useDocumentTitle() {
1414+ const siteTitle = useSiteTitle();
1515+ const siteDescription = useSiteDescription();
1616+ const favicon = useFavicon();
1717+1818+ useEffect(() => {
1919+ if (Platform.OS === "web" && typeof document !== "undefined") {
2020+ // set title
2121+ document.title = siteTitle;
2222+2323+ // set or update meta description
2424+ let metaDescription = document.querySelector('meta[name="description"]');
2525+ if (!metaDescription) {
2626+ metaDescription = document.createElement("meta");
2727+ metaDescription.setAttribute("name", "description");
2828+ document.head.appendChild(metaDescription);
2929+ }
3030+ metaDescription.setAttribute("content", siteDescription);
3131+3232+ // set or update favicon
3333+ if (favicon) {
3434+ let link: HTMLLinkElement | null =
3535+ document.querySelector('link[rel="icon"]');
3636+ if (!link) {
3737+ link = document.createElement("link");
3838+ link.rel = "icon";
3939+ document.head.appendChild(link);
4040+ }
4141+ link.href = favicon;
4242+ }
4343+ }
4444+ }, [siteTitle, siteDescription, favicon]);
4545+}
···1212 type ThemeIcons,
1313} from "./theme";
14141515+// Branded theme provider
1616+export { BrandedThemeProvider } from "./branded-theme-provider";
1717+1518// Design tokens
1619export {
1720 animations,
+23-4
js/components/src/streamplace-provider/index.tsx
···11import { SessionManager } from "@atproto/api/dist/session-manager";
22import { useEffect, useRef } from "react";
33-import { useGetChatProfile } from "../streamplace-store";
33+import { useDocumentTitle } from "../hooks";
44+import {
55+ useBrandingAutoFetch,
66+ useFetchBroadcasterDID,
77+ useGetChatProfile,
88+} from "../streamplace-store";
49import { makeStreamplaceStore } from "../streamplace-store/streamplace-store";
510import { StreamplaceContext } from "./context";
611import Poller from "./poller";
···27322833 return (
2934 <StreamplaceContext.Provider value={{ store: store }}>
3030- <ChatProfileCreator oauthSession={oauthSession}>
3131- <Poller>{children}</Poller>
3232- </ChatProfileCreator>
3535+ <BrandingFetcher>
3636+ <ChatProfileCreator oauthSession={oauthSession}>
3737+ <Poller>{children}</Poller>
3838+ </ChatProfileCreator>
3939+ </BrandingFetcher>
3340 </StreamplaceContext.Provider>
3441 );
4242+}
4343+4444+export function BrandingFetcher({ children }: { children: React.ReactNode }) {
4545+ const fetchBroadcasterDID = useFetchBroadcasterDID();
4646+ useBrandingAutoFetch();
4747+ useDocumentTitle();
4848+4949+ useEffect(() => {
5050+ fetchBroadcasterDID();
5151+ }, [fetchBroadcasterDID]);
5252+5353+ return <>{children}</>;
3554}
36553756export function ChatProfileCreator({
+216
js/components/src/streamplace-store/branding.tsx
···11+import { useCallback, useEffect } from "react";
22+import storage from "../storage";
33+import {
44+ getStreamplaceStoreFromContext,
55+ useStreamplaceStore,
66+} from "./streamplace-store";
77+import { usePossiblyUnauthedPDSAgent } from "./xrpc";
88+99+export interface BrandingAsset {
1010+ key: string;
1111+ mimeType: string;
1212+ url?: string; // URL for images
1313+ data?: string; // inline data for text, or base64 for images
1414+ width?: number; // image width in pixels
1515+ height?: number; // image height in pixels
1616+}
1717+1818+// helper to convert blob to base64
1919+const blobToBase64 = (blob: Blob): Promise<string> => {
2020+ return new Promise((resolve, reject) => {
2121+ const reader = new FileReader();
2222+ reader.onloadend = () => resolve(reader.result as string);
2323+ reader.onerror = reject;
2424+ reader.readAsDataURL(blob);
2525+ });
2626+};
2727+2828+// hook to fetch broadcaster DID (unauthenticated)
2929+export function useFetchBroadcasterDID() {
3030+ const streamplaceAgent = usePossiblyUnauthedPDSAgent();
3131+ const store = getStreamplaceStoreFromContext();
3232+3333+ return useCallback(async () => {
3434+ try {
3535+ if (!streamplaceAgent) {
3636+ throw new Error("Streamplace agent not available");
3737+ }
3838+ const result =
3939+ await streamplaceAgent.place.stream.broadcast.getBroadcaster();
4040+ store.setState({ broadcasterDID: result.data.broadcaster });
4141+ if (result.data.server) {
4242+ store.setState({ serverDID: result.data.server });
4343+ }
4444+ if (result.data.admins) {
4545+ store.setState({ adminDIDs: result.data.admins });
4646+ }
4747+ } catch (err) {
4848+ console.error("Failed to fetch broadcaster DID:", err);
4949+ }
5050+ }, [streamplaceAgent, store]);
5151+}
5252+5353+// hook to fetch branding data from the server
5454+export function useFetchBranding() {
5555+ const streamplaceAgent = usePossiblyUnauthedPDSAgent();
5656+ const broadcasterDID = useStreamplaceStore((state) => state.broadcasterDID);
5757+ const url = useStreamplaceStore((state) => state.url);
5858+ const store = getStreamplaceStoreFromContext();
5959+6060+ return useCallback(
6161+ async ({ force = true } = {}) => {
6262+ if (!broadcasterDID) return;
6363+6464+ try {
6565+ store.setState({ brandingLoading: true });
6666+6767+ // check localStorage first
6868+ const cacheKey = `branding:${broadcasterDID}`;
6969+ const cached = await storage.getItem(cacheKey);
7070+ if (!force && cached) {
7171+ try {
7272+ const parsed = JSON.parse(cached);
7373+ // check if cache is less than 1 hour old
7474+ if (Date.now() - parsed.timestamp < 60 * 60 * 1000) {
7575+ store.setState({
7676+ branding: parsed.data,
7777+ brandingLoading: false,
7878+ brandingError: null,
7979+ });
8080+ return;
8181+ }
8282+ } catch (e) {
8383+ // invalid cache, continue to fetch
8484+ console.warn("Invalid branding cache, refetching", e);
8585+ }
8686+ }
8787+8888+ // fetch branding metadata from server
8989+ if (!streamplaceAgent) {
9090+ throw new Error("Streamplace agent not available");
9191+ }
9292+ const res = await streamplaceAgent.place.stream.branding.getBranding({
9393+ broadcaster: broadcasterDID,
9494+ });
9595+ const assets = res.data.assets;
9696+9797+ // convert assets array to keyed object and fetch blob data
9898+ const brandingMap: Record<string, BrandingAsset> = {};
9999+100100+ for (const asset of assets) {
101101+ brandingMap[asset.key] = { ...asset };
102102+103103+ // if data is already inline (text assets), use it directly
104104+ if (asset.data) {
105105+ brandingMap[asset.key].data = asset.data;
106106+ } else if (asset.url) {
107107+ // for images, construct full URL and fetch blob
108108+ const fullUrl = `${url}${asset.url}`;
109109+ const blobRes = await fetch(fullUrl);
110110+ const blob = await blobRes.blob();
111111+ brandingMap[asset.key].data = await blobToBase64(blob);
112112+ }
113113+ }
114114+115115+ // cache in localStorage
116116+ storage.setItem(
117117+ cacheKey,
118118+ JSON.stringify({
119119+ timestamp: Date.now(),
120120+ data: brandingMap,
121121+ }),
122122+ );
123123+124124+ store.setState({
125125+ branding: brandingMap,
126126+ brandingLoading: false,
127127+ brandingError: null,
128128+ });
129129+ } catch (err: any) {
130130+ console.error("Failed to fetch branding:", err);
131131+ store.setState({
132132+ brandingLoading: false,
133133+ brandingError: err.message || "Failed to fetch branding",
134134+ });
135135+ }
136136+ },
137137+ [broadcasterDID, streamplaceAgent, url, store],
138138+ );
139139+}
140140+141141+// hook to get a specific branding asset by key
142142+export function useBrandingAsset(key: string): BrandingAsset | undefined {
143143+ return useStreamplaceStore((state) => state.branding?.[key]);
144144+}
145145+146146+// convenience hook for main logo
147147+export function useMainLogo(): string | undefined {
148148+ const asset = useBrandingAsset("mainLogo");
149149+ return asset?.data;
150150+}
151151+152152+// convenience hook for favicon
153153+export function useFavicon(): string | undefined {
154154+ const asset = useBrandingAsset("favicon");
155155+ return asset?.data;
156156+}
157157+158158+// convenience hook for site title
159159+export function useSiteTitle(): string {
160160+ const asset = useBrandingAsset("siteTitle");
161161+ return asset?.data || "My Streamplace Station";
162162+}
163163+164164+// convenience hook for site description
165165+export function useSiteDescription(): string {
166166+ const asset = useBrandingAsset("siteDescription");
167167+ return asset?.data || "Live streaming platform";
168168+}
169169+170170+// convenience hook for primary color
171171+export function usePrimaryColor(): string {
172172+ const asset = useBrandingAsset("primaryColor");
173173+ return asset?.data || "#6366f1";
174174+}
175175+176176+// convenience hook for accent color
177177+export function useAccentColor(): string {
178178+ const asset = useBrandingAsset("accentColor");
179179+ return asset?.data || "#8b5cf6";
180180+}
181181+182182+// convenience hook for default streamer
183183+export function useDefaultStreamer(): string | undefined {
184184+ const asset = useBrandingAsset("defaultStreamer");
185185+ return asset?.data || undefined;
186186+}
187187+188188+// convenience hook for sidebar background image
189189+export function useSidebarBackgroundImage(): BrandingAsset | undefined {
190190+ return useBrandingAsset("sidebarBackgroundImage");
191191+}
192192+193193+// convenience hook for legal links
194194+export function useLegalLinks(): { text: string; url: string }[] {
195195+ const asset = useBrandingAsset("legalLinks");
196196+ if (!asset?.data) {
197197+ return [];
198198+ }
199199+ try {
200200+ return JSON.parse(asset.data);
201201+ } catch {
202202+ return [];
203203+ }
204204+}
205205+206206+// hook to auto-fetch branding when broadcaster changes
207207+export function useBrandingAutoFetch() {
208208+ const fetchBranding = useFetchBranding();
209209+ const broadcasterDID = useStreamplaceStore((state) => state.broadcasterDID);
210210+211211+ useEffect(() => {
212212+ if (broadcasterDID) {
213213+ fetchBranding();
214214+ }
215215+ }, [broadcasterDID, fetchBranding]);
216216+}
+1
js/components/src/streamplace-store/index.tsx
···11export * from "./block";
22+export * from "./branding";
23export * from "./moderation";
34export * from "./moderator-management";
45export * from "./stream";
···3434SP_HTTPS_ADDR=:443
3535SP_SECURE=true
36363737+# Set this variable to your did:plc or did:web to have admin access to the node
3838+SP_ADMIN_DIDS=did:web:example.com,did:plc:rbvrr34edl5ddpuwcubjiost
3939+3740# If you're running Streamplace behind an HTTPS proxy, you'll want
3841# SP_SECURE=false
3942# SP_BEHIND_HTTPS_PROXY=true
···137137 WebsocketURL string
138138 BehindHTTPSProxy bool
139139 SegmentDebugDir string
140140+ AdminDIDs []string
140141 Syndicate []string
141142}
142143···235236 cli.StringSliceFlag(fs, &cli.Replicators, "replicators", []string{ReplicatorWebsocket}, "list of replication protocols to use (http, iroh)")
236237 fs.StringVar(&cli.WebsocketURL, "websocket-url", "", "override the websocket (ws:// or wss://) url to use for replication (normally not necessary, used for testing)")
237238 fs.BoolVar(&cli.BehindHTTPSProxy, "behind-https-proxy", false, "set to true if this node is behind an https proxy and we should report https URLs even though the node isn't serving HTTPS")
239239+ cli.StringSliceFlag(fs, &cli.AdminDIDs, "admin-dids", []string{}, "comma-separated list of DIDs that are authorized to modify branding and other admin operations")
238240 cli.StringSliceFlag(fs, &cli.Syndicate, "syndicate", []string{}, "list of DIDs that we should rebroadcast ('*' for everybody)")
239241240242 fs.Bool("external-signing", true, "DEPRECATED, does nothing.")
+266
pkg/spxrpc/place_stream_branding.go
···11+package spxrpc
22+33+import (
44+ "bytes"
55+ "context"
66+ _ "embed"
77+ "encoding/base64"
88+ "fmt"
99+ "io"
1010+ "net/http"
1111+1212+ "github.com/labstack/echo/v4"
1313+ "github.com/streamplace/oatproxy/pkg/oatproxy"
1414+ "gorm.io/gorm"
1515+ "stream.place/streamplace/js/app"
1616+ "stream.place/streamplace/pkg/log"
1717+ placestreamtypes "stream.place/streamplace/pkg/streamplace"
1818+)
1919+2020+var defaultBrandingAssets = map[string]struct {
2121+ data []byte
2222+ mime string
2323+}{
2424+ // "mainLogo": {data: defaultLogoSVG, mime: "image/svg+xml"},
2525+ // "favicon": {data: defaultFaviconSVG, mime: "image/svg+xml"},
2626+ "siteTitle": {data: []byte(""), mime: "text/plain"},
2727+ "siteDescription": {data: []byte(""), mime: "text/plain"},
2828+ "primaryColor": {data: []byte("#6366f1"), mime: "text/plain"},
2929+ "accentColor": {data: []byte("#8b5cf6"), mime: "text/plain"},
3030+ "defaultStreamer": {data: []byte(""), mime: "text/plain"},
3131+}
3232+3333+func (s *Server) getBroadcasterID(ctx context.Context, broadcasterDID string) string {
3434+ // if broadcaster param provided, use it; otherwise use server's default
3535+ if broadcasterDID != "" {
3636+ return broadcasterDID
3737+ }
3838+ return s.cli.BroadcasterHost
3939+}
4040+4141+func (s *Server) getBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) {
4242+ // cache miss - fetch from db
4343+ blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key)
4444+ if err == gorm.ErrRecordNotFound {
4545+ // not in db, use default
4646+ if def, ok := defaultBrandingAssets[key]; ok {
4747+ return def.data, def.mime, nil, nil, nil
4848+ }
4949+ return nil, "", nil, nil, fmt.Errorf("unknown branding key: %s", key)
5050+ }
5151+ if err != nil {
5252+ return nil, "", nil, nil, fmt.Errorf("error fetching branding blob: %w", err)
5353+ }
5454+ return blob.Data, blob.MimeType, blob.Width, blob.Height, nil
5555+}
5656+5757+func (s *Server) handlePlaceStreamBrandingGetBlob(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) {
5858+ return s.HandlePlaceStreamBrandingGetBlobDirect(ctx, broadcasterDID, key)
5959+}
6060+6161+// HandlePlaceStreamBrandingGetBlobDirect is the exported version for direct calls
6262+func (s *Server) HandlePlaceStreamBrandingGetBlobDirect(ctx context.Context, broadcasterDID string, key string) (io.Reader, error) {
6363+ broadcasterID := s.getBroadcasterID(ctx, broadcasterDID)
6464+ data, _, _, _, err := s.getBrandingBlob(ctx, broadcasterID, key)
6565+ if err != nil {
6666+ return nil, err
6767+ }
6868+ return bytes.NewReader(data), nil
6969+}
7070+7171+func (s *Server) handlePlaceStreamBrandingGetBranding(ctx context.Context, broadcasterDID string) (*placestreamtypes.BrandingGetBranding_Output, error) {
7272+ return s.HandlePlaceStreamBrandingGetBrandingDirect(ctx, broadcasterDID)
7373+}
7474+7575+// HandlePlaceStreamBrandingGetBrandingDirect is the exported version for direct calls
7676+func (s *Server) HandlePlaceStreamBrandingGetBrandingDirect(ctx context.Context, broadcasterDID string) (*placestreamtypes.BrandingGetBranding_Output, error) {
7777+ broadcasterID := s.getBroadcasterID(ctx, broadcasterDID)
7878+7979+ // get all keys from database
8080+ dbKeys, err := s.statefulDB.ListBrandingKeys(broadcasterID)
8181+ if err != nil {
8282+ return nil, fmt.Errorf("error listing branding keys: %w", err)
8383+ }
8484+8585+ // build key set including defaults
8686+ allKeys := make(map[string]bool)
8787+ for _, key := range dbKeys {
8888+ allKeys[key] = true
8989+ }
9090+ for key := range defaultBrandingAssets {
9191+ allKeys[key] = true
9292+ }
9393+9494+ // build output
9595+ assets := make([]*placestreamtypes.BrandingGetBranding_BrandingAsset, 0, len(allKeys))
9696+ for key := range allKeys {
9797+ data, mimeType, width, height, err := s.getBrandingBlob(ctx, broadcasterID, key)
9898+ if err != nil {
9999+ continue // skip if error
100100+ }
101101+102102+ asset := &placestreamtypes.BrandingGetBranding_BrandingAsset{
103103+ Key: key,
104104+ MimeType: mimeType,
105105+ }
106106+107107+ // add dimensions if available
108108+ if width != nil {
109109+ w := int64(*width)
110110+ asset.Width = &w
111111+ }
112112+ if height != nil {
113113+ h := int64(*height)
114114+ asset.Height = &h
115115+ }
116116+117117+ // for text assets, include data inline; for images, provide URL
118118+ if mimeType == "text/plain" {
119119+ str := string(data)
120120+ asset.Data = &str
121121+ } else {
122122+ url := fmt.Sprintf("/xrpc/place.stream.branding.getBlob?key=%s&broadcaster=%s", key, broadcasterID)
123123+ asset.Url = &url
124124+ }
125125+126126+ assets = append(assets, asset)
127127+ }
128128+129129+ return &placestreamtypes.BrandingGetBranding_Output{
130130+ Assets: assets,
131131+ }, nil
132132+}
133133+134134+func (s *Server) isAdminDID(did string) bool {
135135+ for _, adminDID := range s.cli.AdminDIDs {
136136+ if adminDID == did {
137137+ return true
138138+ }
139139+ }
140140+ return false
141141+}
142142+143143+func (s *Server) handlePlaceStreamBrandingUpdateBlob(ctx context.Context, input *placestreamtypes.BrandingUpdateBlob_Input) (*placestreamtypes.BrandingUpdateBlob_Output, error) {
144144+ // check authentication
145145+ session, _ := oatproxy.GetOAuthSession(ctx)
146146+ if session == nil {
147147+ return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found")
148148+ }
149149+150150+ // check admin authorization
151151+ if !s.isAdminDID(session.DID) {
152152+ log.Warn(ctx, "unauthorized branding update attempt", "did", session.DID)
153153+ return nil, echo.NewHTTPError(http.StatusUnauthorized, "not authorized to modify branding")
154154+ }
155155+156156+ var broadcasterDID string
157157+ if input.Broadcaster != nil {
158158+ broadcasterDID = *input.Broadcaster
159159+ }
160160+ broadcasterID := s.getBroadcasterID(ctx, broadcasterDID)
161161+162162+ // decode base64 data
163163+ data, err := base64.StdEncoding.DecodeString(input.Data)
164164+ if err != nil {
165165+ return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid base64 data")
166166+ }
167167+168168+ // validate size based on key type
169169+ maxSize := 500 * 1024 // 500KB default for logos
170170+ if input.Key == "favicon" {
171171+ maxSize = 100 * 1024 // 100KB for favicons
172172+ } else if input.Key == "siteTitle" || input.Key == "siteDescription" || input.Key == "primaryColor" || input.Key == "accentColor" || input.Key == "defaultStreamer" {
173173+ maxSize = 1024 // 1KB for text values
174174+ }
175175+ // sidebarBackgroundImage uses default 500KB limit
176176+ if len(data) > maxSize {
177177+ return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("blob too large (max %d bytes)", maxSize))
178178+ }
179179+180180+ // store in database
181181+ var width, height *int
182182+ if input.Width != nil {
183183+ w := int(*input.Width)
184184+ width = &w
185185+ }
186186+ if input.Height != nil {
187187+ h := int(*input.Height)
188188+ height = &h
189189+ }
190190+191191+ err = s.statefulDB.PutBrandingBlob(broadcasterID, input.Key, input.MimeType, data, width, height)
192192+ if err != nil {
193193+ log.Error(ctx, "failed to store branding blob", "err", err)
194194+ return nil, echo.NewHTTPError(http.StatusInternalServerError, "unable to store branding blob")
195195+ }
196196+197197+ return &placestreamtypes.BrandingUpdateBlob_Output{
198198+ Success: true,
199199+ }, nil
200200+}
201201+202202+func (s *Server) handlePlaceStreamBrandingDeleteBlob(ctx context.Context, input *placestreamtypes.BrandingDeleteBlob_Input) (*placestreamtypes.BrandingDeleteBlob_Output, error) {
203203+ // check authentication
204204+ session, _ := oatproxy.GetOAuthSession(ctx)
205205+ if session == nil {
206206+ return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found")
207207+ }
208208+209209+ // check admin authorization
210210+ if !s.isAdminDID(session.DID) {
211211+ log.Warn(ctx, "unauthorized branding delete attempt", "did", session.DID)
212212+ return nil, echo.NewHTTPError(http.StatusUnauthorized, "not authorized to modify branding")
213213+ }
214214+215215+ var broadcasterDID string
216216+ if input.Broadcaster != nil {
217217+ broadcasterDID = *input.Broadcaster
218218+ }
219219+ broadcasterID := s.getBroadcasterID(ctx, broadcasterDID)
220220+221221+ err := s.statefulDB.DeleteBrandingBlob(broadcasterID, input.Key)
222222+ if err != nil {
223223+ if err == gorm.ErrRecordNotFound {
224224+ return nil, echo.NewHTTPError(http.StatusNotFound, "branding asset not found")
225225+ }
226226+ log.Error(ctx, "failed to delete branding blob", "err", err)
227227+ return nil, echo.NewHTTPError(http.StatusInternalServerError, "unable to delete branding blob")
228228+ }
229229+230230+ return &placestreamtypes.BrandingDeleteBlob_Output{
231231+ Success: true,
232232+ }, nil
233233+}
234234+235235+// HandleFaviconICO serves the favicon at /favicon.ico
236236+func (s *Server) HandleFaviconICO(c echo.Context) error {
237237+ ctx := c.Request().Context()
238238+239239+ broadcasterID := s.cli.BroadcasterHost
240240+ log.Log(ctx, "fetching favicon", "broadcasterID", broadcasterID)
241241+ data, mimeType, _, _, err := s.getBrandingBlob(ctx, "did:web:"+broadcasterID, "favicon")
242242+243243+ if err != nil || data == nil {
244244+ log.Log(ctx, "using fallback favicon", "err", err, "data_nil", data == nil)
245245+ distFiles, fsErr := app.Files()
246246+ if fsErr != nil {
247247+ return echo.NewHTTPError(http.StatusInternalServerError, "failed to fetch favicon")
248248+ }
249249+250250+ faviconFile, fsErr := distFiles.Open("favicon.ico")
251251+ if fsErr != nil {
252252+ return echo.NewHTTPError(http.StatusNotFound, "favicon not found")
253253+ }
254254+ defer faviconFile.Close()
255255+256256+ data, fsErr = io.ReadAll(faviconFile)
257257+ if fsErr != nil {
258258+ return echo.NewHTTPError(http.StatusInternalServerError, "failed to read favicon")
259259+ }
260260+261261+ // detect mime type based on file extension (ico)
262262+ mimeType = "image/x-icon"
263263+ }
264264+265265+ return c.Blob(http.StatusOK, mimeType, data)
266266+}
···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+// Lexicon schema: place.stream.branding.deleteBlob
44+55+package streamplace
66+77+import (
88+ "context"
99+1010+ lexutil "github.com/bluesky-social/indigo/lex/util"
1111+)
1212+1313+// BrandingDeleteBlob_Input is the input argument to a place.stream.branding.deleteBlob call.
1414+type BrandingDeleteBlob_Input struct {
1515+ // broadcaster: DID of the broadcaster. If not provided, uses the server's default broadcaster.
1616+ Broadcaster *string `json:"broadcaster,omitempty" cborgen:"broadcaster,omitempty"`
1717+ // key: Branding asset key (mainLogo, favicon, siteTitle, etc.)
1818+ Key string `json:"key" cborgen:"key"`
1919+}
2020+2121+// BrandingDeleteBlob_Output is the output of a place.stream.branding.deleteBlob call.
2222+type BrandingDeleteBlob_Output struct {
2323+ Success bool `json:"success" cborgen:"success"`
2424+}
2525+2626+// BrandingDeleteBlob calls the XRPC method "place.stream.branding.deleteBlob".
2727+func BrandingDeleteBlob(ctx context.Context, c lexutil.LexClient, input *BrandingDeleteBlob_Input) (*BrandingDeleteBlob_Output, error) {
2828+ var out BrandingDeleteBlob_Output
2929+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.branding.deleteBlob", nil, input, &out); err != nil {
3030+ return nil, err
3131+ }
3232+3333+ return &out, nil
3434+}
+31
pkg/streamplace/brandinggetBlob.go
···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+// Lexicon schema: place.stream.branding.getBlob
44+55+package streamplace
66+77+import (
88+ "bytes"
99+ "context"
1010+1111+ lexutil "github.com/bluesky-social/indigo/lex/util"
1212+)
1313+1414+// BrandingGetBlob calls the XRPC method "place.stream.branding.getBlob".
1515+//
1616+// broadcaster: DID of the broadcaster. If not provided, uses the server's default broadcaster.
1717+// key: Branding asset key (mainLogo, favicon, siteTitle, etc.)
1818+func BrandingGetBlob(ctx context.Context, c lexutil.LexClient, broadcaster string, key string) ([]byte, error) {
1919+ buf := new(bytes.Buffer)
2020+2121+ params := map[string]interface{}{}
2222+ if broadcaster != "" {
2323+ params["broadcaster"] = broadcaster
2424+ }
2525+ params["key"] = key
2626+ if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.branding.getBlob", params, nil, buf); err != nil {
2727+ return nil, err
2828+ }
2929+3030+ return buf.Bytes(), nil
3131+}
+50
pkg/streamplace/brandinggetBranding.go
···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+// Lexicon schema: place.stream.branding.getBranding
44+55+package streamplace
66+77+import (
88+ "context"
99+1010+ lexutil "github.com/bluesky-social/indigo/lex/util"
1111+)
1212+1313+// BrandingGetBranding_BrandingAsset is a "brandingAsset" in the place.stream.branding.getBranding schema.
1414+type BrandingGetBranding_BrandingAsset struct {
1515+ // data: Inline data for text assets
1616+ Data *string `json:"data,omitempty" cborgen:"data,omitempty"`
1717+ // height: Image height in pixels (optional, for images only)
1818+ Height *int64 `json:"height,omitempty" cborgen:"height,omitempty"`
1919+ // key: Asset key identifier
2020+ Key string `json:"key" cborgen:"key"`
2121+ // mimeType: MIME type of the asset
2222+ MimeType string `json:"mimeType" cborgen:"mimeType"`
2323+ // url: URL to fetch the asset blob (for images)
2424+ Url *string `json:"url,omitempty" cborgen:"url,omitempty"`
2525+ // width: Image width in pixels (optional, for images only)
2626+ Width *int64 `json:"width,omitempty" cborgen:"width,omitempty"`
2727+}
2828+2929+// BrandingGetBranding_Output is the output of a place.stream.branding.getBranding call.
3030+type BrandingGetBranding_Output struct {
3131+ // assets: List of available branding assets
3232+ Assets []*BrandingGetBranding_BrandingAsset `json:"assets" cborgen:"assets"`
3333+}
3434+3535+// BrandingGetBranding calls the XRPC method "place.stream.branding.getBranding".
3636+//
3737+// broadcaster: DID of the broadcaster. If not provided, uses the server's default broadcaster.
3838+func BrandingGetBranding(ctx context.Context, c lexutil.LexClient, broadcaster string) (*BrandingGetBranding_Output, error) {
3939+ var out BrandingGetBranding_Output
4040+4141+ params := map[string]interface{}{}
4242+ if broadcaster != "" {
4343+ params["broadcaster"] = broadcaster
4444+ }
4545+ if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.branding.getBranding", params, nil, &out); err != nil {
4646+ return nil, err
4747+ }
4848+4949+ return &out, nil
5050+}
+42
pkg/streamplace/brandingupdateBlob.go
···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+// Lexicon schema: place.stream.branding.updateBlob
44+55+package streamplace
66+77+import (
88+ "context"
99+1010+ lexutil "github.com/bluesky-social/indigo/lex/util"
1111+)
1212+1313+// BrandingUpdateBlob_Input is the input argument to a place.stream.branding.updateBlob call.
1414+type BrandingUpdateBlob_Input struct {
1515+ // broadcaster: DID of the broadcaster. If not provided, uses the server's default broadcaster.
1616+ Broadcaster *string `json:"broadcaster,omitempty" cborgen:"broadcaster,omitempty"`
1717+ // data: Base64-encoded blob data
1818+ Data string `json:"data" cborgen:"data"`
1919+ // height: Image height in pixels (optional, for images only)
2020+ Height *int64 `json:"height,omitempty" cborgen:"height,omitempty"`
2121+ // key: Branding asset key (mainLogo, favicon, siteTitle, etc.)
2222+ Key string `json:"key" cborgen:"key"`
2323+ // mimeType: MIME type of the blob (e.g., image/png, text/plain)
2424+ MimeType string `json:"mimeType" cborgen:"mimeType"`
2525+ // width: Image width in pixels (optional, for images only)
2626+ Width *int64 `json:"width,omitempty" cborgen:"width,omitempty"`
2727+}
2828+2929+// BrandingUpdateBlob_Output is the output of a place.stream.branding.updateBlob call.
3030+type BrandingUpdateBlob_Output struct {
3131+ Success bool `json:"success" cborgen:"success"`
3232+}
3333+3434+// BrandingUpdateBlob calls the XRPC method "place.stream.branding.updateBlob".
3535+func BrandingUpdateBlob(ctx context.Context, c lexutil.LexClient, input *BrandingUpdateBlob_Input) (*BrandingUpdateBlob_Output, error) {
3636+ var out BrandingUpdateBlob_Output
3737+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.branding.updateBlob", nil, input, &out); err != nil {
3838+ return nil, err
3939+ }
4040+4141+ return &out, nil
4242+}
+2
pkg/streamplace/broadcastgetBroadcaster.go
···12121313// BroadcastGetBroadcaster_Output is the output of a place.stream.broadcast.getBroadcaster call.
1414type BroadcastGetBroadcaster_Output struct {
1515+ // admins: Array of DIDs authorized as admins
1616+ Admins []string `json:"admins,omitempty" cborgen:"admins,omitempty"`
1517 // broadcaster: DID of the Streamplace broadcaster to which this server belongs
1618 Broadcaster string `json:"broadcaster" cborgen:"broadcaster"`
1719 // server: DID of this particular Streamplace server