Live video on the AT Protocol

Nuke old branding admin stuff and cache remnants

+20 -596
-6
js/components/src/streamplace-store/branding.tsx
··· 173 173 return asset?.data || "#8b5cf6"; 174 174 } 175 175 176 - // convenience hook for default stream key 177 - export function useDefaultStreamKey(): string | undefined { 178 - const asset = useBrandingAsset("defaultStreamKey"); 179 - return asset?.data || undefined; 180 - } 181 - 182 176 // convenience hook for default streamer 183 177 export function useDefaultStreamer(): string | undefined { 184 178 const asset = useBrandingAsset("defaultStreamer");
-38
pkg/api/api_internal.go
··· 449 449 } 450 450 }) 451 451 452 - router.GET("/branding-admin", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 453 - http.ServeFile(w, r, "pkg/api/branding-admin.html") 454 - }) 455 - 456 - router.GET("/xrpc/place.stream.branding.getBranding", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 457 - // call XRPC handler directly instead of proxying 458 - broadcasterDID := r.URL.Query().Get("broadcaster") 459 - output, err := a.XRPCServer.HandlePlaceStreamBrandingGetBrandingDirect(ctx, broadcasterDID) 460 - if err != nil { 461 - errors.WriteHTTPInternalServerError(w, "failed to fetch branding", err) 462 - return 463 - } 464 - 465 - w.Header().Set("Content-Type", "application/json") 466 - bs, err := json.Marshal(output) 467 - if err != nil { 468 - errors.WriteHTTPInternalServerError(w, "unable to marshal json", err) 469 - return 470 - } 471 - if _, err := w.Write(bs); err != nil { 472 - log.Error(ctx, "error writing response", "error", err) 473 - } 474 - }) 475 - 476 - router.GET("/xrpc/place.stream.branding.getBlob", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 477 - // call XRPC handler directly instead of proxying 478 - key := r.URL.Query().Get("key") 479 - broadcasterDID := r.URL.Query().Get("broadcaster") 480 - reader, err := a.XRPCServer.HandlePlaceStreamBrandingGetBlobDirect(ctx, broadcasterDID, key) 481 - if err != nil { 482 - errors.WriteHTTPInternalServerError(w, "failed to fetch blob", err) 483 - return 484 - } 485 - if _, err := io.Copy(w, reader); err != nil { 486 - log.Error(ctx, "error writing response", "error", err) 487 - } 488 - }) 489 - 490 452 router.POST("/notification-blast", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 491 453 var payload notificationpkg.NotificationBlast 492 454 if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
-520
pkg/api/branding-admin.html
··· 1 - <!doctype html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>Branding Admin</title> 7 - <style> 8 - * { 9 - box-sizing: border-box; 10 - margin: 0; 11 - padding: 0; 12 - } 13 - 14 - body { 15 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 16 - "Helvetica Neue", Arial, sans-serif; 17 - background: #0a0a0a; 18 - color: #e4e4e7; 19 - padding: 2rem; 20 - line-height: 1.6; 21 - } 22 - 23 - .container { 24 - max-width: 800px; 25 - margin: 0 auto; 26 - } 27 - 28 - h1 { 29 - font-size: 2rem; 30 - margin-bottom: 0.5rem; 31 - color: #fafafa; 32 - } 33 - 34 - .subtitle { 35 - color: #a1a1aa; 36 - margin-bottom: 2rem; 37 - } 38 - 39 - .section { 40 - background: #18181b; 41 - border: 1px solid #27272a; 42 - border-radius: 8px; 43 - padding: 1.5rem; 44 - margin-bottom: 1.5rem; 45 - } 46 - 47 - .section-title { 48 - font-size: 1.25rem; 49 - font-weight: 600; 50 - margin-bottom: 0.5rem; 51 - color: #fafafa; 52 - } 53 - 54 - .section-subtitle { 55 - font-size: 0.875rem; 56 - color: #a1a1aa; 57 - margin-bottom: 1rem; 58 - } 59 - 60 - .input-group { 61 - display: flex; 62 - gap: 0.5rem; 63 - margin-bottom: 1rem; 64 - } 65 - 66 - input[type="text"], 67 - input[type="file"] { 68 - flex: 1; 69 - padding: 0.625rem; 70 - background: #27272a; 71 - border: 1px solid #3f3f46; 72 - border-radius: 6px; 73 - color: #e4e4e7; 74 - font-size: 0.875rem; 75 - } 76 - 77 - input[type="text"]:focus, 78 - input[type="file"]:focus { 79 - outline: none; 80 - border-color: #6366f1; 81 - } 82 - 83 - button { 84 - padding: 0.625rem 1.25rem; 85 - background: #6366f1; 86 - color: white; 87 - border: none; 88 - border-radius: 6px; 89 - font-size: 0.875rem; 90 - font-weight: 500; 91 - cursor: pointer; 92 - transition: background 0.2s; 93 - } 94 - 95 - button:hover { 96 - background: #4f46e5; 97 - } 98 - 99 - button:disabled { 100 - background: #3f3f46; 101 - cursor: not-allowed; 102 - } 103 - 104 - button.secondary { 105 - background: #27272a; 106 - border: 1px solid #3f3f46; 107 - } 108 - 109 - button.secondary:hover { 110 - background: #3f3f46; 111 - } 112 - 113 - button.danger { 114 - background: #dc2626; 115 - } 116 - 117 - button.danger:hover { 118 - background: #b91c1c; 119 - } 120 - 121 - .toast { 122 - position: fixed; 123 - top: 2rem; 124 - right: 2rem; 125 - padding: 1rem 1.5rem; 126 - background: #18181b; 127 - border: 1px solid #27272a; 128 - border-radius: 8px; 129 - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); 130 - animation: slideIn 0.3s ease-out; 131 - z-index: 1000; 132 - } 133 - 134 - .toast.success { 135 - border-color: #16a34a; 136 - } 137 - 138 - .toast.error { 139 - border-color: #dc2626; 140 - } 141 - 142 - @keyframes slideIn { 143 - from { 144 - transform: translateX(100%); 145 - opacity: 0; 146 - } 147 - to { 148 - transform: translateX(0); 149 - opacity: 1; 150 - } 151 - } 152 - 153 - .info { 154 - font-size: 0.875rem; 155 - color: #71717a; 156 - margin-top: 1.5rem; 157 - } 158 - 159 - .button-group { 160 - display: flex; 161 - gap: 0.5rem; 162 - } 163 - </style> 164 - </head> 165 - <body> 166 - <div class="container"> 167 - <h1>Branding Administration</h1> 168 - <p class="subtitle">Customize your Streamplace instance</p> 169 - 170 - <!-- Broadcaster DID --> 171 - <div class="section"> 172 - <div class="section-title">Broadcaster DID</div> 173 - <div class="section-subtitle">Leave empty to use server default</div> 174 - <div class="input-group"> 175 - <input 176 - type="text" 177 - id="broadcasterDIDInput" 178 - placeholder="did:plc:..." 179 - value="" 180 - /> 181 - </div> 182 - </div> 183 - 184 - <!-- Site Title --> 185 - <div class="section"> 186 - <div class="section-title">Site Title</div> 187 - <div class="section-subtitle" id="currentTitle"> 188 - Current: Loading... 189 - </div> 190 - <div class="input-group"> 191 - <input 192 - type="text" 193 - id="siteTitleInput" 194 - placeholder="Enter new site title" 195 - /> 196 - <button 197 - onclick="uploadText('siteTitle', document.getElementById('siteTitleInput').value)" 198 - > 199 - Update 200 - </button> 201 - </div> 202 - </div> 203 - 204 - <!-- Site Description --> 205 - <div class="section"> 206 - <div class="section-title">Site Description</div> 207 - <div class="section-subtitle" id="currentDescription"> 208 - Current: Loading... 209 - </div> 210 - <div class="input-group"> 211 - <input 212 - type="text" 213 - id="siteDescriptionInput" 214 - placeholder="Enter site description" 215 - /> 216 - <button 217 - onclick="uploadText('siteDescription', document.getElementById('siteDescriptionInput').value)" 218 - > 219 - Update 220 - </button> 221 - </div> 222 - </div> 223 - 224 - <!-- Primary Color --> 225 - <div class="section"> 226 - <div class="section-title">Primary Color</div> 227 - <div class="section-subtitle" id="currentPrimaryColor"> 228 - Current: Loading... 229 - </div> 230 - <div class="input-group"> 231 - <input type="text" id="primaryColorInput" placeholder="#6366f1" /> 232 - <button 233 - onclick="uploadText('primaryColor', document.getElementById('primaryColorInput').value)" 234 - > 235 - Update 236 - </button> 237 - </div> 238 - </div> 239 - 240 - <!-- Accent Color --> 241 - <div class="section"> 242 - <div class="section-title">Accent Color</div> 243 - <div class="section-subtitle" id="currentAccentColor"> 244 - Current: Loading... 245 - </div> 246 - <div class="input-group"> 247 - <input type="text" id="accentColorInput" placeholder="#8b5cf6" /> 248 - <button 249 - onclick="uploadText('accentColor', document.getElementById('accentColorInput').value)" 250 - > 251 - Update 252 - </button> 253 - </div> 254 - </div> 255 - 256 - <!-- Default Streamer --> 257 - <div class="section"> 258 - <div class="section-title">Default Streamer</div> 259 - <div class="section-subtitle" id="currentDefaultStreamer"> 260 - Current: None 261 - </div> 262 - <div class="input-group"> 263 - <input 264 - type="text" 265 - id="defaultStreamerInput" 266 - placeholder="did:plc:..." 267 - /> 268 - <button 269 - onclick="uploadText('defaultStreamer', document.getElementById('defaultStreamerInput').value)" 270 - > 271 - Update 272 - </button> 273 - </div> 274 - <div class="button-group" style="margin-top: 0.5rem"> 275 - <button class="danger" onclick="deleteBlob('defaultStreamer')"> 276 - Clear Default Streamer 277 - </button> 278 - </div> 279 - </div> 280 - 281 - <!-- Main Logo --> 282 - <div class="section"> 283 - <div class="section-title">Main Logo</div> 284 - <div class="section-subtitle">SVG, PNG, or JPEG (max 500KB)</div> 285 - <div id="currentLogoPreview" style="margin-bottom: 1rem"></div> 286 - <div class="input-group"> 287 - <input 288 - type="file" 289 - id="logoInput" 290 - accept="image/svg+xml,image/png,image/jpeg" 291 - /> 292 - <button 293 - onclick="uploadFile('mainLogo', document.getElementById('logoInput'))" 294 - > 295 - Upload 296 - </button> 297 - </div> 298 - <div class="button-group" style="margin-top: 0.5rem"> 299 - <button class="danger" onclick="deleteBlob('mainLogo')"> 300 - Delete Logo 301 - </button> 302 - </div> 303 - </div> 304 - 305 - <!-- Favicon --> 306 - <div class="section"> 307 - <div class="section-title">Favicon</div> 308 - <div class="section-subtitle">SVG, PNG, or ICO (max 100KB)</div> 309 - <div id="currentFaviconPreview" style="margin-bottom: 1rem"></div> 310 - <div class="input-group"> 311 - <input 312 - type="file" 313 - id="faviconInput" 314 - accept="image/svg+xml,image/png,image/x-icon" 315 - /> 316 - <button 317 - onclick="uploadFile('favicon', document.getElementById('faviconInput'))" 318 - > 319 - Upload 320 - </button> 321 - </div> 322 - <div class="button-group" style="margin-top: 0.5rem"> 323 - <button class="danger" onclick="deleteBlob('favicon')"> 324 - Delete Favicon 325 - </button> 326 - </div> 327 - </div> 328 - 329 - <div class="info" id="info">Loading branding information...</div> 330 - </div> 331 - 332 - <script> 333 - let currentBranding = {}; 334 - 335 - function getBroadcasterDID() { 336 - return document.getElementById("broadcasterDIDInput").value.trim(); 337 - } 338 - 339 - function getBroadcasterParam() { 340 - const did = getBroadcasterDID(); 341 - return did ? `?broadcaster=${encodeURIComponent(did)}` : ""; 342 - } 343 - 344 - async function loadBranding() { 345 - try { 346 - // fetch branding metadata 347 - const response = await fetch( 348 - `/xrpc/place.stream.branding.getBranding${getBroadcasterParam()}`, 349 - ); 350 - if (!response.ok) { 351 - throw new Error("Failed to load branding"); 352 - } 353 - 354 - const data = await response.json(); 355 - currentBranding = {}; 356 - 357 - // process assets - use inline data for text, URLs for images 358 - for (const asset of data.assets) { 359 - if (asset.data) { 360 - // text asset with inline data 361 - currentBranding[asset.key] = asset.data; 362 - } else if (asset.url) { 363 - // image asset with URL 364 - currentBranding[asset.key] = asset.url; 365 - } 366 - } 367 - 368 - // update UI with current values 369 - document.getElementById("currentTitle").textContent = 370 - "Current: " + (currentBranding["siteTitle"] || "Streamplace"); 371 - document.getElementById("currentDescription").textContent = 372 - "Current: " + 373 - (currentBranding["siteDescription"] || "Live streaming platform"); 374 - document.getElementById("currentPrimaryColor").textContent = 375 - "Current: " + (currentBranding["primaryColor"] || "#6366f1"); 376 - document.getElementById("currentAccentColor").textContent = 377 - "Current: " + (currentBranding["accentColor"] || "#8b5cf6"); 378 - document.getElementById("currentDefaultStreamer").textContent = 379 - "Current: " + (currentBranding["defaultStreamer"] || "None"); 380 - 381 - // render logo preview 382 - const logoPreview = document.getElementById("currentLogoPreview"); 383 - if (currentBranding["mainLogo"]) { 384 - logoPreview.innerHTML = `<img src="${currentBranding["mainLogo"]}" style="max-width: 200px; max-height: 100px; background: #27272a; padding: 1rem; border-radius: 6px;" alt="Main Logo">`; 385 - } else { 386 - logoPreview.innerHTML = 387 - '<div style="color: #71717a; font-size: 0.875rem;">No custom logo</div>'; 388 - } 389 - 390 - // render favicon preview 391 - const faviconPreview = document.getElementById( 392 - "currentFaviconPreview", 393 - ); 394 - if (currentBranding["favicon"]) { 395 - faviconPreview.innerHTML = `<img src="${currentBranding["favicon"]}" style="max-width: 64px; max-height: 64px; background: #27272a; padding: 0.5rem; border-radius: 6px;" alt="Favicon">`; 396 - } else { 397 - faviconPreview.innerHTML = 398 - '<div style="color: #71717a; font-size: 0.875rem;">No custom favicon</div>'; 399 - } 400 - 401 - document.getElementById("info").textContent = 402 - "Branding loaded successfully"; 403 - } catch (err) { 404 - showToast("Failed to load branding: " + err.message, "error"); 405 - document.getElementById("info").textContent = 406 - "Error loading branding: " + err.message; 407 - } 408 - } 409 - 410 - async function uploadText(key, value) { 411 - if (!value.trim()) { 412 - showToast("Please enter a value", "error"); 413 - return; 414 - } 415 - 416 - try { 417 - const response = await fetch( 418 - `/branding/${key}${getBroadcasterParam()}`, 419 - { 420 - method: "PUT", 421 - headers: { 422 - "Content-Type": "text/plain", 423 - }, 424 - body: value.trim(), 425 - }, 426 - ); 427 - 428 - if (!response.ok) { 429 - throw new Error("Upload failed: " + response.statusText); 430 - } 431 - 432 - showToast(`${key} updated successfully`, "success"); 433 - 434 - // clear input 435 - document.getElementById(key + "Input").value = ""; 436 - 437 - // reload branding 438 - setTimeout(() => loadBranding(), 500); 439 - } catch (err) { 440 - showToast("Failed to upload: " + err.message, "error"); 441 - } 442 - } 443 - 444 - async function uploadFile(key, inputElement) { 445 - const file = inputElement.files[0]; 446 - if (!file) { 447 - showToast("Please select a file", "error"); 448 - return; 449 - } 450 - 451 - try { 452 - const response = await fetch( 453 - `/branding/${key}${getBroadcasterParam()}`, 454 - { 455 - method: "PUT", 456 - headers: { 457 - "Content-Type": file.type, 458 - }, 459 - body: file, 460 - }, 461 - ); 462 - 463 - if (!response.ok) { 464 - throw new Error("Upload failed: " + response.statusText); 465 - } 466 - 467 - showToast(`${key} uploaded successfully`, "success"); 468 - 469 - // clear input 470 - inputElement.value = ""; 471 - 472 - // reload branding 473 - setTimeout(() => loadBranding(), 500); 474 - } catch (err) { 475 - showToast("Failed to upload: " + err.message, "error"); 476 - } 477 - } 478 - 479 - async function deleteBlob(key) { 480 - if (!confirm(`Are you sure you want to delete ${key}?`)) { 481 - return; 482 - } 483 - 484 - try { 485 - const response = await fetch( 486 - `/branding/${key}${getBroadcasterParam()}`, 487 - { 488 - method: "DELETE", 489 - }, 490 - ); 491 - 492 - if (!response.ok) { 493 - throw new Error("Delete failed: " + response.statusText); 494 - } 495 - 496 - showToast(`${key} deleted successfully`, "success"); 497 - 498 - // reload branding 499 - setTimeout(() => loadBranding(), 500); 500 - } catch (err) { 501 - showToast("Failed to delete: " + err.message, "error"); 502 - } 503 - } 504 - 505 - function showToast(message, type = "success") { 506 - const toast = document.createElement("div"); 507 - toast.className = `toast ${type}`; 508 - toast.textContent = message; 509 - document.body.appendChild(toast); 510 - 511 - setTimeout(() => { 512 - toast.remove(); 513 - }, 3000); 514 - } 515 - 516 - // load branding on page load 517 - loadBranding(); 518 - </script> 519 - </body> 520 - </html>
+6 -15
pkg/spxrpc/place_stream_branding.go
··· 23 23 }{ 24 24 // "mainLogo": {data: defaultLogoSVG, mime: "image/svg+xml"}, 25 25 // "favicon": {data: defaultFaviconSVG, mime: "image/svg+xml"}, 26 - "siteTitle": {data: []byte("Streamplace"), mime: "text/plain"}, 27 - "siteDescription": {data: []byte("Live streaming platform"), mime: "text/plain"}, 28 - "primaryColor": {data: []byte("#6366f1"), mime: "text/plain"}, 29 - "accentColor": {data: []byte("#8b5cf6"), mime: "text/plain"}, 30 - "defaultStreamKey": {data: []byte(""), mime: "text/plain"}, 31 - "defaultStreamer": {data: []byte(""), mime: "text/plain"}, 26 + "siteTitle": {data: []byte("Streamplace"), mime: "text/plain"}, 27 + "siteDescription": {data: []byte("Live streaming platform"), mime: "text/plain"}, 28 + "primaryColor": {data: []byte("#6366f1"), mime: "text/plain"}, 29 + "accentColor": {data: []byte("#8b5cf6"), mime: "text/plain"}, 30 + "defaultStreamer": {data: []byte(""), mime: "text/plain"}, 32 31 } 33 32 34 33 func (s *Server) getBroadcasterID(ctx context.Context, broadcasterDID string) string { ··· 170 169 maxSize := 500 * 1024 // 500KB default for logos 171 170 if input.Key == "favicon" { 172 171 maxSize = 100 * 1024 // 100KB for favicons 173 - } else if input.Key == "siteTitle" || input.Key == "siteDescription" || input.Key == "primaryColor" || input.Key == "accentColor" || input.Key == "defaultStreamKey" || input.Key == "defaultStreamer" { 172 + } else if input.Key == "siteTitle" || input.Key == "siteDescription" || input.Key == "primaryColor" || input.Key == "accentColor" || input.Key == "defaultStreamer" { 174 173 maxSize = 1024 // 1KB for text values 175 174 } 176 175 // sidebarBackgroundImage uses default 500KB limit ··· 194 193 log.Error(ctx, "failed to store branding blob", "err", err) 195 194 return nil, echo.NewHTTPError(http.StatusInternalServerError, "unable to store branding blob") 196 195 } 197 - 198 - // invalidate cache 199 - cacheKey := fmt.Sprintf("%s:%s", broadcasterID, input.Key) 200 - s.BrandingCache.Delete(cacheKey) 201 196 202 197 return &placestreamtypes.BrandingUpdateBlob_Output{ 203 198 Success: true, ··· 231 226 log.Error(ctx, "failed to delete branding blob", "err", err) 232 227 return nil, echo.NewHTTPError(http.StatusInternalServerError, "unable to delete branding blob") 233 228 } 234 - 235 - // invalidate cache 236 - cacheKey := fmt.Sprintf("%s:%s", broadcasterID, input.Key) 237 - s.BrandingCache.Delete(cacheKey) 238 229 239 230 return &placestreamtypes.BrandingDeleteBlob_Output{ 240 231 Success: true,
+14 -17
pkg/spxrpc/spxrpc.go
··· 24 24 ) 25 25 26 26 type Server struct { 27 - e *echo.Echo 28 - cli *config.CLI 29 - model model.Model 30 - OGImageCache *cache.Cache 31 - BrandingCache *cache.Cache 32 - ATSync *atproto.ATProtoSynchronizer 33 - statefulDB *statedb.StatefulDB 34 - bus *bus.Bus 27 + e *echo.Echo 28 + cli *config.CLI 29 + model model.Model 30 + OGImageCache *cache.Cache 31 + ATSync *atproto.ATProtoSynchronizer 32 + statefulDB *statedb.StatefulDB 33 + bus *bus.Bus 35 34 } 36 35 37 36 func NewServer(ctx context.Context, cli *config.CLI, model model.Model, statefulDB *statedb.StatefulDB, op *oatproxy.OATProxy, mdlw middleware.Middleware, atsync *atproto.ATProtoSynchronizer, bus *bus.Bus) (*Server, error) { 38 37 e := echo.New() 39 38 s := &Server{ 40 - e: e, 41 - cli: cli, 42 - model: model, 43 - OGImageCache: cache.New(5*time.Minute, 10*time.Minute), // 5min TTL, 10min cleanup 44 - BrandingCache: cache.New(1*time.Hour, 15*time.Minute), // 1hr TTL, 15min cleanup 45 - ATSync: atsync, 46 - statefulDB: statefulDB, 47 - bus: bus, 39 + e: e, 40 + cli: cli, 41 + model: model, 42 + OGImageCache: cache.New(5*time.Minute, 10*time.Minute), // 5min TTL, 10min cleanup 43 + ATSync: atsync, 44 + statefulDB: statefulDB, 45 + bus: bus, 48 46 } 49 47 e.Use(s.ErrorHandlingMiddleware()) 50 48 e.Use(s.ContextPreservingMiddleware()) ··· 65 63 e.GET("/xrpc/_health", func(c echo.Context) error { 66 64 return c.JSON(http.StatusOK, map[string]string{"version": cli.Build.Version}) 67 65 }) 68 - e.GET("/favicon.ico", s.HandleFaviconICO) 69 66 e.GET("/xrpc/com.atproto.sync.subscribeRepos", s.handleComAtprotoSyncSubscribeRepos) 70 67 e.GET("/xrpc/place.stream.live.subscribeSegments", s.handlePlaceStreamLiveSubscribeSegments) 71 68 e.GET("/xrpc/*", s.HandleWildcard)