Live video on the AT Protocol
fork

Configure Feed

Select the types of activity you want to include in your feed.

move branding to xrpc

+793 -80
+1
js/app/components/index.tsx
··· 1 1 export { Countdown } from "./countdown"; 2 2 export { default as Provider } from "./provider/provider"; 3 + export { BrandingAdmin } from "./settings/branding-admin"; 3 4 export { Settings } from "./settings/settings";
+450
js/app/components/settings/branding-admin.tsx
··· 1 + import { 2 + Button, 3 + Input, 4 + Text, 5 + useToast, 6 + View, 7 + zero, 8 + } from "@streamplace/components"; 9 + import { 10 + useBrandingAsset, 11 + useFetchBranding, 12 + } from "@streamplace/components/src/streamplace-store/branding"; 13 + import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; 14 + import { useEffect, useState } from "react"; 15 + import { ActivityIndicator, Image, Platform, ScrollView } from "react-native"; 16 + 17 + export function BrandingAdmin() { 18 + const agent = usePDSAgent(); 19 + const fetchBranding = useFetchBranding(); 20 + const toast = useToast(); 21 + 22 + // state for form inputs 23 + const [siteTitle, setSiteTitle] = useState(""); 24 + const [siteDescription, setSiteDescription] = useState(""); 25 + const [primaryColor, setPrimaryColor] = useState(""); 26 + const [accentColor, setAccentColor] = useState(""); 27 + const [defaultStreamer, setDefaultStreamer] = useState(""); 28 + const [broadcasterDID, setBroadcasterDID] = useState(""); 29 + const [uploading, setUploading] = useState(false); 30 + 31 + // get current values 32 + const currentTitle = useBrandingAsset("siteTitle"); 33 + const currentDescription = useBrandingAsset("siteDescription"); 34 + const currentPrimaryColor = useBrandingAsset("primaryColor"); 35 + const currentAccentColor = useBrandingAsset("accentColor"); 36 + const currentDefaultStreamer = useBrandingAsset("defaultStreamer"); 37 + const currentLogo = useBrandingAsset("mainLogo"); 38 + const currentFavicon = useBrandingAsset("favicon"); 39 + 40 + // load current branding on mount 41 + useEffect(() => { 42 + fetchBranding(); 43 + }, []); 44 + 45 + const uploadText = async (key: string, value: string) => { 46 + if (!agent) { 47 + toast.show("Not authenticated", "Please log in first", { 48 + variant: "error", 49 + }); 50 + return; 51 + } 52 + 53 + if (!value.trim()) { 54 + toast.show("Empty value", "Please enter a value", { variant: "error" }); 55 + return; 56 + } 57 + 58 + setUploading(true); 59 + try { 60 + const textBytes = new TextEncoder().encode(value.trim()); 61 + const base64Data = btoa(String.fromCharCode(...textBytes)); 62 + 63 + await agent.place.stream.branding.updateBlob({ 64 + key, 65 + broadcaster: broadcasterDID || undefined, 66 + data: base64Data, 67 + mimeType: "text/plain", 68 + }); 69 + 70 + toast.show("Success", `${key} updated successfully`, { 71 + variant: "success", 72 + }); 73 + 74 + // clear input based on key 75 + switch (key) { 76 + case "siteTitle": 77 + setSiteTitle(""); 78 + break; 79 + case "siteDescription": 80 + setSiteDescription(""); 81 + break; 82 + case "primaryColor": 83 + setPrimaryColor(""); 84 + break; 85 + case "accentColor": 86 + setAccentColor(""); 87 + break; 88 + case "defaultStreamer": 89 + setDefaultStreamer(""); 90 + break; 91 + } 92 + 93 + // reload branding 94 + setTimeout(() => fetchBranding(), 500); 95 + } catch (err: any) { 96 + toast.show("Upload failed", err.message || "Failed to upload", { 97 + variant: "error", 98 + }); 99 + } finally { 100 + setUploading(false); 101 + } 102 + }; 103 + 104 + const uploadFile = async (key: string, file: File) => { 105 + if (!agent) { 106 + toast.show("Not authenticated", "Please log in first", { 107 + variant: "error", 108 + }); 109 + return; 110 + } 111 + 112 + setUploading(true); 113 + try { 114 + const arrayBuffer = await file.arrayBuffer(); 115 + const uint8Array = new Uint8Array(arrayBuffer); 116 + const base64Data = btoa(String.fromCharCode(...uint8Array)); 117 + 118 + await agent.place.stream.branding.updateBlob({ 119 + key, 120 + broadcaster: broadcasterDID || undefined, 121 + data: base64Data, 122 + mimeType: file.type, 123 + }); 124 + 125 + toast.show("Success", `${key} uploaded successfully`, { 126 + variant: "success", 127 + }); 128 + 129 + // reload branding 130 + setTimeout(() => fetchBranding(), 500); 131 + } catch (err: any) { 132 + toast.show("Upload failed", err.message || "Failed to upload", { 133 + variant: "error", 134 + }); 135 + } finally { 136 + setUploading(false); 137 + } 138 + }; 139 + 140 + const handleFileSelect = (key: string, accept: string) => { 141 + if (Platform.OS !== "web") { 142 + toast.show("Not available", "File uploads are only available on web", { 143 + variant: "error", 144 + }); 145 + return; 146 + } 147 + 148 + // TypeScript doesn't know about document in react-native-web context 149 + // @ts-ignore - document exists on web 150 + const input = document.createElement("input"); 151 + input.type = "file"; 152 + input.accept = accept; 153 + input.onchange = (e) => { 154 + const file = (e.target as HTMLInputElement).files?.[0]; 155 + if (file) { 156 + uploadFile(key, file); 157 + } 158 + }; 159 + input.click(); 160 + }; 161 + 162 + const deleteBlob = async (key: string) => { 163 + if (!agent) { 164 + toast.show("Not authenticated", "Please log in first", { 165 + variant: "error", 166 + }); 167 + return; 168 + } 169 + 170 + setUploading(true); 171 + try { 172 + await agent.place.stream.branding.deleteBlob({ 173 + key, 174 + broadcaster: broadcasterDID || undefined, 175 + }); 176 + 177 + toast.show("Success", `${key} deleted successfully`, { 178 + variant: "success", 179 + }); 180 + 181 + // reload branding 182 + setTimeout(() => fetchBranding(), 500); 183 + } catch (err: any) { 184 + toast.show("Delete failed", err.message || "Failed to delete", { 185 + variant: "error", 186 + }); 187 + } finally { 188 + setUploading(false); 189 + } 190 + }; 191 + 192 + if (!agent) { 193 + return ( 194 + <View style={[zero.layout.flex.align.center, zero.px[16], zero.py[24]]}> 195 + <Text>Please log in to manage branding</Text> 196 + </View> 197 + ); 198 + } 199 + 200 + return ( 201 + <ScrollView> 202 + <View style={[zero.layout.flex.align.center, zero.px[16], zero.py[24]]}> 203 + <View 204 + style={[ 205 + zero.gap.all[12], 206 + { paddingVertical: 24, maxWidth: 600, width: "100%" }, 207 + ]} 208 + > 209 + <View> 210 + <Text size="2xl" weight="bold"> 211 + Branding Administration 212 + </Text> 213 + <Text color="muted">Customize your Streamplace instance</Text> 214 + </View> 215 + 216 + {uploading && ( 217 + <View style={[zero.layout.flex.align.center, zero.py[16]]}> 218 + <ActivityIndicator /> 219 + </View> 220 + )} 221 + 222 + {/* Broadcaster DID */} 223 + <View style={[zero.gap.all[8]]}> 224 + <Text size="lg" weight="semibold"> 225 + Broadcaster DID 226 + </Text> 227 + <Text size="sm" color="muted"> 228 + Leave empty to use server default 229 + </Text> 230 + <Input 231 + placeholder="did:plc:..." 232 + value={broadcasterDID} 233 + onChangeText={setBroadcasterDID} 234 + /> 235 + </View> 236 + 237 + {/* Site Title */} 238 + <View style={[zero.gap.all[8]]}> 239 + <Text size="lg" weight="semibold"> 240 + Site Title 241 + </Text> 242 + <Text size="sm" color="muted"> 243 + Current: {currentTitle?.data || "Streamplace"} 244 + </Text> 245 + <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 246 + <View style={{ flex: 1 }}> 247 + <Input 248 + placeholder="Enter new site title" 249 + value={siteTitle} 250 + onChangeText={setSiteTitle} 251 + /> 252 + </View> 253 + <Button 254 + onPress={() => uploadText("siteTitle", siteTitle)} 255 + disabled={uploading || !siteTitle.trim()} 256 + > 257 + Update 258 + </Button> 259 + </View> 260 + </View> 261 + 262 + {/* Site Description */} 263 + <View style={[zero.gap.all[8]]}> 264 + <Text size="lg" weight="semibold"> 265 + Site Description 266 + </Text> 267 + <Text size="sm" color="muted"> 268 + Current: {currentDescription?.data || "Live streaming platform"} 269 + </Text> 270 + <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 271 + <View style={{ flex: 1 }}> 272 + <Input 273 + placeholder="Enter site description" 274 + value={siteDescription} 275 + onChangeText={setSiteDescription} 276 + /> 277 + </View> 278 + <Button 279 + onPress={() => uploadText("siteDescription", siteDescription)} 280 + disabled={uploading || !siteDescription.trim()} 281 + > 282 + Update 283 + </Button> 284 + </View> 285 + </View> 286 + 287 + {/* Primary Color */} 288 + <View style={[zero.gap.all[8]]}> 289 + <Text size="lg" weight="semibold"> 290 + Primary Color 291 + </Text> 292 + <Text size="sm" color="muted"> 293 + Current: {currentPrimaryColor?.data || "#6366f1"} 294 + </Text> 295 + <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 296 + <View style={{ flex: 1 }}> 297 + <Input 298 + placeholder="#6366f1" 299 + value={primaryColor} 300 + onChangeText={setPrimaryColor} 301 + /> 302 + </View> 303 + <Button 304 + onPress={() => uploadText("primaryColor", primaryColor)} 305 + disabled={uploading || !primaryColor.trim()} 306 + > 307 + Update 308 + </Button> 309 + </View> 310 + </View> 311 + 312 + {/* Accent Color */} 313 + <View style={[zero.gap.all[8]]}> 314 + <Text size="lg" weight="semibold"> 315 + Accent Color 316 + </Text> 317 + <Text size="sm" color="muted"> 318 + Current: {currentAccentColor?.data || "#8b5cf6"} 319 + </Text> 320 + <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 321 + <View style={{ flex: 1 }}> 322 + <Input 323 + placeholder="#8b5cf6" 324 + value={accentColor} 325 + onChangeText={setAccentColor} 326 + /> 327 + </View> 328 + <Button 329 + onPress={() => uploadText("accentColor", accentColor)} 330 + disabled={uploading || !accentColor.trim()} 331 + > 332 + Update 333 + </Button> 334 + </View> 335 + </View> 336 + 337 + {/* Default Streamer */} 338 + <View style={[zero.gap.all[8]]}> 339 + <Text size="lg" weight="semibold"> 340 + Default Streamer 341 + </Text> 342 + <Text size="sm" color="muted"> 343 + Current: {currentDefaultStreamer?.data || "None"} 344 + </Text> 345 + <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 346 + <View style={{ flex: 1 }}> 347 + <Input 348 + placeholder="did:plc:..." 349 + value={defaultStreamer} 350 + onChangeText={setDefaultStreamer} 351 + /> 352 + </View> 353 + <Button 354 + onPress={() => uploadText("defaultStreamer", defaultStreamer)} 355 + disabled={uploading || !defaultStreamer.trim()} 356 + > 357 + Update 358 + </Button> 359 + </View> 360 + <Button 361 + variant="destructive" 362 + onPress={() => deleteBlob("defaultStreamer")} 363 + disabled={uploading} 364 + > 365 + Clear Default Streamer 366 + </Button> 367 + </View> 368 + 369 + {/* Main Logo */} 370 + <View style={[zero.gap.all[8]]}> 371 + <Text size="lg" weight="semibold"> 372 + Main Logo 373 + </Text> 374 + <Text size="sm" color="muted"> 375 + SVG, PNG, or JPEG (max 500KB) - Web only 376 + </Text> 377 + {currentLogo?.data && ( 378 + <Image 379 + source={{ uri: currentLogo.data }} 380 + style={{ width: 200, height: 100, resizeMode: "contain" }} 381 + /> 382 + )} 383 + <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 384 + <Button 385 + onPress={() => 386 + handleFileSelect( 387 + "mainLogo", 388 + "image/svg+xml,image/png,image/jpeg", 389 + ) 390 + } 391 + disabled={uploading || Platform.OS !== "web"} 392 + > 393 + Upload Logo 394 + </Button> 395 + <Button 396 + variant="destructive" 397 + onPress={() => deleteBlob("mainLogo")} 398 + disabled={uploading} 399 + > 400 + Delete Logo 401 + </Button> 402 + </View> 403 + </View> 404 + 405 + {/* Favicon */} 406 + <View style={[zero.gap.all[8]]}> 407 + <Text size="lg" weight="semibold"> 408 + Favicon 409 + </Text> 410 + <Text size="sm" color="muted"> 411 + SVG, PNG, or ICO (max 100KB) - Web only 412 + </Text> 413 + {currentFavicon?.data && ( 414 + <Image 415 + source={{ uri: currentFavicon.data }} 416 + style={{ width: 64, height: 64, resizeMode: "contain" }} 417 + /> 418 + )} 419 + <View style={[zero.layout.flex.direction.row, zero.gap.all[8]]}> 420 + <Button 421 + onPress={() => 422 + handleFileSelect( 423 + "favicon", 424 + "image/svg+xml,image/png,image/x-icon", 425 + ) 426 + } 427 + disabled={uploading || Platform.OS !== "web"} 428 + > 429 + Upload Favicon 430 + </Button> 431 + <Button 432 + variant="destructive" 433 + onPress={() => deleteBlob("favicon")} 434 + disabled={uploading} 435 + > 436 + Delete Favicon 437 + </Button> 438 + </View> 439 + </View> 440 + 441 + <Text size="sm" color="muted" style={{ marginTop: 16 }}> 442 + Note: You must be an authorized admin DID to make changes. 443 + {Platform.OS !== "web" && 444 + " Image uploads are only available on web."} 445 + </Text> 446 + </View> 447 + </View> 448 + </ScrollView> 449 + ); 450 + }
+19 -2
js/app/src/router.tsx
··· 22 22 useTheme, 23 23 useToast, 24 24 } from "@streamplace/components"; 25 - import { Provider, Settings } from "components"; 25 + import { BrandingAdmin, Provider, Settings } from "components"; 26 26 import AQLink from "components/aqlink"; 27 27 import Login from "components/login/login"; 28 28 import LoginModal from "components/login/login-modal"; ··· 136 136 Multi: { config: string }; 137 137 Support: undefined; 138 138 Settings: NavigatorScreenParams<SettingsStackParamList>; 139 + BrandingAdmin: undefined; 139 140 KeyManagement: undefined; 140 141 GoLive: undefined; 141 142 LiveDashboard: undefined; ··· 186 187 DeveloperSettings: "settings/developer", 187 188 }, 188 189 }, 190 + BrandingAdmin: "admin", 189 191 KeyManagement: "key-management", 190 192 GoLive: "golive", 191 193 LiveDashboard: "live", ··· 593 595 }, 594 596 }} 595 597 /> 596 - 598 + <Drawer.Screen 599 + name="BrandingAdmin" 600 + component={BrandingAdmin} 601 + options={{ 602 + drawerLabel: () => <Text variant="h5">Branding Admin</Text>, 603 + drawerItemStyle: { display: "none" }, 604 + }} 605 + /> 606 + <Drawer.Screen 607 + name="KeyManagement" 608 + component={KeyManager} 609 + options={{ 610 + drawerLabel: () => <Text variant="h5">Key Manager</Text>, 611 + drawerItemStyle: { display: "none" }, 612 + }} 613 + /> 597 614 <Drawer.Screen 598 615 name="Support" 599 616 component={SupportScreen}
+50
lexicons/place/stream/branding/deleteBlob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.branding.deleteBlob", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a branding asset blob. Requires admin authorization.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["key"], 13 + "properties": { 14 + "key": { 15 + "type": "string", 16 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 17 + }, 18 + "broadcaster": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster." 22 + } 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["success"], 31 + "properties": { 32 + "success": { 33 + "type": "boolean" 34 + } 35 + } 36 + } 37 + }, 38 + "errors": [ 39 + { 40 + "name": "Unauthorized", 41 + "description": "The authenticated DID is not authorized to modify branding" 42 + }, 43 + { 44 + "name": "BrandingNotFound", 45 + "description": "The requested branding asset does not exist" 46 + } 47 + ] 48 + } 49 + } 50 + }
+58
lexicons/place/stream/branding/updateBlob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.branding.updateBlob", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Update or create a branding asset blob. Requires admin authorization.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["key", "data", "mimeType"], 13 + "properties": { 14 + "key": { 15 + "type": "string", 16 + "description": "Branding asset key (mainLogo, favicon, siteTitle, etc.)" 17 + }, 18 + "broadcaster": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of the broadcaster. If not provided, uses the server's default broadcaster." 22 + }, 23 + "data": { 24 + "type": "string", 25 + "description": "Base64-encoded blob data" 26 + }, 27 + "mimeType": { 28 + "type": "string", 29 + "description": "MIME type of the blob (e.g., image/png, text/plain)" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": ["success"], 39 + "properties": { 40 + "success": { 41 + "type": "boolean" 42 + } 43 + } 44 + } 45 + }, 46 + "errors": [ 47 + { 48 + "name": "Unauthorized", 49 + "description": "The authenticated DID is not authorized to modify branding" 50 + }, 51 + { 52 + "name": "BlobTooLarge", 53 + "description": "The blob exceeds the maximum size limit" 54 + } 55 + ] 56 + } 57 + } 58 + }
-78
pkg/api/api_internal.go
··· 487 487 } 488 488 }) 489 489 490 - router.PUT("/branding/:key", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 491 - key := p.ByName("key") 492 - if key == "" { 493 - errors.WriteHTTPBadRequest(w, "key required", nil) 494 - return 495 - } 496 - 497 - // get broadcaster from query param or use default 498 - broadcasterID := r.URL.Query().Get("broadcaster") 499 - if broadcasterID == "" { 500 - broadcasterID = a.CLI.BroadcasterHost 501 - } 502 - 503 - // read body 504 - data, err := io.ReadAll(r.Body) 505 - if err != nil { 506 - errors.WriteHTTPBadRequest(w, "unable to read request body", err) 507 - return 508 - } 509 - 510 - // validate size based on key type 511 - maxSize := 500 * 1024 // 500KB default for logos 512 - if key == "favicon" { 513 - maxSize = 100 * 1024 // 100KB for favicons 514 - } else if key == "siteTitle" || key == "siteDescription" || key == "primaryColor" || key == "accentColor" || key == "defaultStreamKey" || key == "defaultStreamer" { 515 - maxSize = 1024 // 1KB for text values 516 - } 517 - if len(data) > maxSize { 518 - errors.WriteHTTPBadRequest(w, fmt.Sprintf("blob too large (max %d bytes)", maxSize), nil) 519 - return 520 - } 521 - 522 - // determine mime type from content-type header 523 - mimeType := r.Header.Get("Content-Type") 524 - if mimeType == "" { 525 - mimeType = "application/octet-stream" 526 - } 527 - 528 - // store in database 529 - err = a.StatefulDB.PutBrandingBlob(broadcasterID, key, mimeType, data) 530 - if err != nil { 531 - errors.WriteHTTPInternalServerError(w, "unable to store branding blob", err) 532 - return 533 - } 534 - 535 - // invalidate cache 536 - cacheKey := fmt.Sprintf("%s:%s", broadcasterID, key) 537 - a.XRPCServer.BrandingCache.Delete(cacheKey) 538 - 539 - w.WriteHeader(http.StatusNoContent) 540 - }) 541 - 542 - router.DELETE("/branding/:key", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 543 - key := p.ByName("key") 544 - if key == "" { 545 - errors.WriteHTTPBadRequest(w, "key required", nil) 546 - return 547 - } 548 - 549 - // get broadcaster from query param or use default 550 - broadcasterID := r.URL.Query().Get("broadcaster") 551 - if broadcasterID == "" { 552 - broadcasterID = a.CLI.BroadcasterHost 553 - } 554 - 555 - err := a.StatefulDB.DeleteBrandingBlob(broadcasterID, key) 556 - if err != nil { 557 - errors.WriteHTTPInternalServerError(w, "unable to delete branding blob", err) 558 - return 559 - } 560 - 561 - // invalidate cache 562 - cacheKey := fmt.Sprintf("%s:%s", broadcasterID, key) 563 - a.XRPCServer.BrandingCache.Delete(cacheKey) 564 - 565 - w.WriteHeader(http.StatusNoContent) 566 - }) 567 - 568 490 router.POST("/notification-blast", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 569 491 var payload notificationpkg.NotificationBlast 570 492 if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+2
pkg/config/config.go
··· 137 137 WebsocketURL string 138 138 BehindHTTPSProxy bool 139 139 SegmentDebugDir string 140 + AdminDIDs []string 140 141 Syndicate []string 141 142 } 142 143 ··· 235 236 cli.StringSliceFlag(fs, &cli.Replicators, "replicators", []string{ReplicatorWebsocket}, "list of replication protocols to use (http, iroh)") 236 237 fs.StringVar(&cli.WebsocketURL, "websocket-url", "", "override the websocket (ws:// or wss://) url to use for replication (normally not necessary, used for testing)") 237 238 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") 239 + cli.StringSliceFlag(fs, &cli.AdminDIDs, "admin-dids", []string{}, "comma-separated list of DIDs that are authorized to modify branding and other admin operations") 238 240 cli.StringSliceFlag(fs, &cli.Syndicate, "syndicate", []string{}, "list of DIDs that we should rebroadcast ('*' for everybody)") 239 241 240 242 fs.Bool("external-signing", true, "DEPRECATED, does nothing.")
+103
pkg/spxrpc/place_stream_branding.go
··· 4 4 "bytes" 5 5 "context" 6 6 _ "embed" 7 + "encoding/base64" 7 8 "fmt" 8 9 "io" 10 + "net/http" 9 11 12 + "github.com/labstack/echo/v4" 10 13 "github.com/patrickmn/go-cache" 14 + "github.com/streamplace/oatproxy/pkg/oatproxy" 11 15 "gorm.io/gorm" 16 + "stream.place/streamplace/pkg/log" 12 17 placestreamtypes "stream.place/streamplace/pkg/streamplace" 13 18 ) 14 19 ··· 143 148 Assets: assets, 144 149 }, nil 145 150 } 151 + 152 + func (s *Server) isAdminDID(did string) bool { 153 + for _, adminDID := range s.cli.AdminDIDs { 154 + if adminDID == did { 155 + return true 156 + } 157 + } 158 + return false 159 + } 160 + 161 + func (s *Server) handlePlaceStreamBrandingUpdateBlob(ctx context.Context, input *placestreamtypes.BrandingUpdateBlob_Input) (*placestreamtypes.BrandingUpdateBlob_Output, error) { 162 + // check authentication 163 + session, _ := oatproxy.GetOAuthSession(ctx) 164 + if session == nil { 165 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 166 + } 167 + 168 + // check admin authorization 169 + if !s.isAdminDID(session.DID) { 170 + log.Warn(ctx, "unauthorized branding update attempt", "did", session.DID) 171 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "not authorized to modify branding") 172 + } 173 + 174 + var broadcasterDID string 175 + if input.Broadcaster != nil { 176 + broadcasterDID = *input.Broadcaster 177 + } 178 + broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 179 + 180 + // decode base64 data 181 + data, err := base64.StdEncoding.DecodeString(input.Data) 182 + if err != nil { 183 + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid base64 data") 184 + } 185 + 186 + // validate size based on key type 187 + maxSize := 500 * 1024 // 500KB default for logos 188 + if input.Key == "favicon" { 189 + maxSize = 100 * 1024 // 100KB for favicons 190 + } else if input.Key == "siteTitle" || input.Key == "siteDescription" || input.Key == "primaryColor" || input.Key == "accentColor" || input.Key == "defaultStreamKey" || input.Key == "defaultStreamer" { 191 + maxSize = 1024 // 1KB for text values 192 + } 193 + if len(data) > maxSize { 194 + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("blob too large (max %d bytes)", maxSize)) 195 + } 196 + 197 + // store in database 198 + err = s.statefulDB.PutBrandingBlob(broadcasterID, input.Key, input.MimeType, data) 199 + if err != nil { 200 + log.Error(ctx, "failed to store branding blob", "err", err) 201 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "unable to store branding blob") 202 + } 203 + 204 + // invalidate cache 205 + cacheKey := fmt.Sprintf("%s:%s", broadcasterID, input.Key) 206 + s.BrandingCache.Delete(cacheKey) 207 + 208 + return &placestreamtypes.BrandingUpdateBlob_Output{ 209 + Success: true, 210 + }, nil 211 + } 212 + 213 + func (s *Server) handlePlaceStreamBrandingDeleteBlob(ctx context.Context, input *placestreamtypes.BrandingDeleteBlob_Input) (*placestreamtypes.BrandingDeleteBlob_Output, error) { 214 + // check authentication 215 + session, _ := oatproxy.GetOAuthSession(ctx) 216 + if session == nil { 217 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") 218 + } 219 + 220 + // check admin authorization 221 + if !s.isAdminDID(session.DID) { 222 + log.Warn(ctx, "unauthorized branding delete attempt", "did", session.DID) 223 + return nil, echo.NewHTTPError(http.StatusUnauthorized, "not authorized to modify branding") 224 + } 225 + 226 + var broadcasterDID string 227 + if input.Broadcaster != nil { 228 + broadcasterDID = *input.Broadcaster 229 + } 230 + broadcasterID := s.getBroadcasterID(ctx, broadcasterDID) 231 + 232 + err := s.statefulDB.DeleteBrandingBlob(broadcasterID, input.Key) 233 + if err != nil { 234 + if err == gorm.ErrRecordNotFound { 235 + return nil, echo.NewHTTPError(http.StatusNotFound, "branding asset not found") 236 + } 237 + log.Error(ctx, "failed to delete branding blob", "err", err) 238 + return nil, echo.NewHTTPError(http.StatusInternalServerError, "unable to delete branding blob") 239 + } 240 + 241 + // invalidate cache 242 + cacheKey := fmt.Sprintf("%s:%s", broadcasterID, input.Key) 243 + s.BrandingCache.Delete(cacheKey) 244 + 245 + return &placestreamtypes.BrandingDeleteBlob_Output{ 246 + Success: true, 247 + }, nil 248 + }
+38
pkg/spxrpc/stubs.go
··· 262 262 } 263 263 264 264 func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { 265 + e.POST("/xrpc/place.stream.branding.deleteBlob", s.HandlePlaceStreamBrandingDeleteBlob) 265 266 e.GET("/xrpc/place.stream.branding.getBlob", s.HandlePlaceStreamBrandingGetBlob) 266 267 e.GET("/xrpc/place.stream.branding.getBranding", s.HandlePlaceStreamBrandingGetBranding) 268 + e.POST("/xrpc/place.stream.branding.updateBlob", s.HandlePlaceStreamBrandingUpdateBlob) 267 269 e.GET("/xrpc/place.stream.broadcast.getBroadcaster", s.HandlePlaceStreamBroadcastGetBroadcaster) 268 270 e.GET("/xrpc/place.stream.graph.getFollowingUser", s.HandlePlaceStreamGraphGetFollowingUser) 269 271 e.GET("/xrpc/place.stream.live.getLiveUsers", s.HandlePlaceStreamLiveGetLiveUsers) ··· 280 282 return nil 281 283 } 282 284 285 + func (s *Server) HandlePlaceStreamBrandingDeleteBlob(c echo.Context) error { 286 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingDeleteBlob") 287 + defer span.End() 288 + 289 + var body placestreamtypes.BrandingDeleteBlob_Input 290 + if err := c.Bind(&body); err != nil { 291 + return err 292 + } 293 + var out *placestreamtypes.BrandingDeleteBlob_Output 294 + var handleErr error 295 + // func (s *Server) handlePlaceStreamBrandingDeleteBlob(ctx context.Context,body *placestreamtypes.BrandingDeleteBlob_Input) (*placestreamtypes.BrandingDeleteBlob_Output, error) 296 + out, handleErr = s.handlePlaceStreamBrandingDeleteBlob(ctx, &body) 297 + if handleErr != nil { 298 + return handleErr 299 + } 300 + return c.JSON(200, out) 301 + } 302 + 283 303 func (s *Server) HandlePlaceStreamBrandingGetBlob(c echo.Context) error { 284 304 ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingGetBlob") 285 305 defer span.End() ··· 303 323 var handleErr error 304 324 // func (s *Server) handlePlaceStreamBrandingGetBranding(ctx context.Context,broadcaster string) (*placestreamtypes.BrandingGetBranding_Output, error) 305 325 out, handleErr = s.handlePlaceStreamBrandingGetBranding(ctx, broadcaster) 326 + if handleErr != nil { 327 + return handleErr 328 + } 329 + return c.JSON(200, out) 330 + } 331 + 332 + func (s *Server) HandlePlaceStreamBrandingUpdateBlob(c echo.Context) error { 333 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamBrandingUpdateBlob") 334 + defer span.End() 335 + 336 + var body placestreamtypes.BrandingUpdateBlob_Input 337 + if err := c.Bind(&body); err != nil { 338 + return err 339 + } 340 + var out *placestreamtypes.BrandingUpdateBlob_Output 341 + var handleErr error 342 + // func (s *Server) handlePlaceStreamBrandingUpdateBlob(ctx context.Context,body *placestreamtypes.BrandingUpdateBlob_Input) (*placestreamtypes.BrandingUpdateBlob_Output, error) 343 + out, handleErr = s.handlePlaceStreamBrandingUpdateBlob(ctx, &body) 306 344 if handleErr != nil { 307 345 return handleErr 308 346 }
+34
pkg/streamplace/brandingdeleteBlob.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package streamplace 4 + 5 + // schema: place.stream.branding.deleteBlob 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // BrandingDeleteBlob_Input is the input argument to a place.stream.branding.deleteBlob call. 14 + type BrandingDeleteBlob_Input struct { 15 + // broadcaster: DID of the broadcaster. If not provided, uses the server's default broadcaster. 16 + Broadcaster *string `json:"broadcaster,omitempty" cborgen:"broadcaster,omitempty"` 17 + // key: Branding asset key (mainLogo, favicon, siteTitle, etc.) 18 + Key string `json:"key" cborgen:"key"` 19 + } 20 + 21 + // BrandingDeleteBlob_Output is the output of a place.stream.branding.deleteBlob call. 22 + type BrandingDeleteBlob_Output struct { 23 + Success bool `json:"success" cborgen:"success"` 24 + } 25 + 26 + // BrandingDeleteBlob calls the XRPC method "place.stream.branding.deleteBlob". 27 + func BrandingDeleteBlob(ctx context.Context, c util.LexClient, input *BrandingDeleteBlob_Input) (*BrandingDeleteBlob_Output, error) { 28 + var out BrandingDeleteBlob_Output 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "place.stream.branding.deleteBlob", nil, input, &out); err != nil { 30 + return nil, err 31 + } 32 + 33 + return &out, nil 34 + }
+38
pkg/streamplace/brandingupdateBlob.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package streamplace 4 + 5 + // schema: place.stream.branding.updateBlob 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // BrandingUpdateBlob_Input is the input argument to a place.stream.branding.updateBlob call. 14 + type BrandingUpdateBlob_Input struct { 15 + // broadcaster: DID of the broadcaster. If not provided, uses the server's default broadcaster. 16 + Broadcaster *string `json:"broadcaster,omitempty" cborgen:"broadcaster,omitempty"` 17 + // data: Base64-encoded blob data 18 + Data string `json:"data" cborgen:"data"` 19 + // key: Branding asset key (mainLogo, favicon, siteTitle, etc.) 20 + Key string `json:"key" cborgen:"key"` 21 + // mimeType: MIME type of the blob (e.g., image/png, text/plain) 22 + MimeType string `json:"mimeType" cborgen:"mimeType"` 23 + } 24 + 25 + // BrandingUpdateBlob_Output is the output of a place.stream.branding.updateBlob call. 26 + type BrandingUpdateBlob_Output struct { 27 + Success bool `json:"success" cborgen:"success"` 28 + } 29 + 30 + // BrandingUpdateBlob calls the XRPC method "place.stream.branding.updateBlob". 31 + func BrandingUpdateBlob(ctx context.Context, c util.LexClient, input *BrandingUpdateBlob_Input) (*BrandingUpdateBlob_Output, error) { 32 + var out BrandingUpdateBlob_Output 33 + if err := c.LexDo(ctx, util.Procedure, "application/json", "place.stream.branding.updateBlob", nil, input, &out); err != nil { 34 + return nil, err 35 + } 36 + 37 + return &out, nil 38 + }