Skip to content

Commit 675f594

Browse files
sysoclaude
andcommitted
feat: add API token CRUD endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9b34e45 commit 675f594

2 files changed

Lines changed: 264 additions & 3 deletions

File tree

server/handlers_api_v2.go

Lines changed: 243 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,10 @@ func (s *Server) apiGetStreams(w http.ResponseWriter, r *http.Request) {
5858
}
5959

6060
var result []streamInfo
61+
seen := make(map[string]bool)
6162
for _, st := range allStreams {
6263
if s.hasAccess(user, st.MountName) {
64+
seen[st.MountName] = true
6365
result = append(result, streamInfo{
6466
Mount: st.MountName,
6567
ContentType: st.ContentType,
@@ -75,6 +77,35 @@ func (s *Server) apiGetStreams(w http.ResponseWriter, r *http.Request) {
7577
})
7678
}
7779
}
80+
81+
// Add configured mounts that are not currently active (offline)
82+
for mount := range s.Config.Mounts {
83+
if !seen[mount] && s.hasAccess(user, mount) {
84+
seen[mount] = true
85+
disabled := s.Config.DisabledMounts[mount]
86+
visible := s.Config.VisibleMounts[mount]
87+
result = append(result, streamInfo{
88+
Mount: mount,
89+
Visible: visible,
90+
Enabled: !disabled,
91+
})
92+
}
93+
}
94+
for _, u := range s.Config.Users {
95+
for mount := range u.Mounts {
96+
if !seen[mount] && s.hasAccess(user, mount) {
97+
seen[mount] = true
98+
disabled := s.Config.DisabledMounts[mount]
99+
visible := s.Config.VisibleMounts[mount]
100+
result = append(result, streamInfo{
101+
Mount: mount,
102+
Visible: visible,
103+
Enabled: !disabled,
104+
})
105+
}
106+
}
107+
}
108+
78109
if result == nil {
79110
result = []streamInfo{}
80111
}
@@ -612,7 +643,7 @@ func (s *Server) apiAddToPlaylist(w http.ResponseWriter, r *http.Request) {
612643
}
613644
streamer.SavePlaylist()
614645
for _, adj := range s.Config.AutoDJs {
615-
if adj.Mount == body.Mount {
646+
if adj.Mount == mount {
616647
adj.Playlist = playlistCopy
617648
adj.LastPlaylist = lastPl
618649
s.Config.SaveConfig()
@@ -633,8 +664,19 @@ func (s *Server) apiRemoveFromPlaylist(w http.ResponseWriter, r *http.Request) {
633664
}
634665

635666
mount := r.URL.Query().Get("mount")
636-
var idx int
637-
fmt.Sscanf(r.URL.Query().Get("id"), "%d", &idx)
667+
668+
var body struct {
669+
Mount string `json:"mount"`
670+
ID int `json:"id"`
671+
}
672+
json.NewDecoder(r.Body).Decode(&body)
673+
if mount == "" {
674+
mount = body.Mount
675+
}
676+
idx := body.ID
677+
if idx == 0 {
678+
fmt.Sscanf(r.URL.Query().Get("id"), "%d", &idx)
679+
}
638680

639681
streamer := s.StreamerM.GetStreamer(mount)
640682
if streamer == nil {
@@ -1514,6 +1556,204 @@ func (s *Server) apiUpdateBranding(w http.ResponseWriter, r *http.Request) {
15141556
jsonResponse(w, map[string]string{"status": "updated"})
15151557
}
15161558

1559+
// ---------------------------------------------------------------------------
1560+
// Logo upload + serve
1561+
// ---------------------------------------------------------------------------
1562+
1563+
func (s *Server) apiUploadLogo(w http.ResponseWriter, r *http.Request) {
1564+
if !s.isCSRFSafe(r) {
1565+
jsonError(w, "Forbidden", http.StatusForbidden)
1566+
return
1567+
}
1568+
if _, ok := s.checkAuth(r); !ok {
1569+
jsonError(w, "Unauthorized", http.StatusUnauthorized)
1570+
return
1571+
}
1572+
1573+
if err := r.ParseMultipartForm(2 << 20); err != nil {
1574+
jsonError(w, "File too large (max 2MB)", http.StatusBadRequest)
1575+
return
1576+
}
1577+
file, header, err := r.FormFile("logo")
1578+
if err != nil {
1579+
jsonError(w, "No file uploaded", http.StatusBadRequest)
1580+
return
1581+
}
1582+
defer file.Close()
1583+
1584+
ext := strings.ToLower(filepath.Ext(header.Filename))
1585+
if ext == "" {
1586+
ext = ".png"
1587+
}
1588+
destPath := filepath.Join("branding", "logo"+ext)
1589+
1590+
os.MkdirAll("branding", 0755)
1591+
dst, err := os.Create(destPath)
1592+
if err != nil {
1593+
jsonError(w, "Failed to save logo", http.StatusInternalServerError)
1594+
return
1595+
}
1596+
defer dst.Close()
1597+
1598+
if _, err := dst.ReadFrom(file); err != nil {
1599+
jsonError(w, "Failed to write logo", http.StatusInternalServerError)
1600+
return
1601+
}
1602+
1603+
s.Config.LogoPath = destPath
1604+
s.Config.SaveConfig()
1605+
jsonResponse(w, map[string]string{"status": "ok", "path": destPath})
1606+
}
1607+
1608+
func (s *Server) handleServeLogo(w http.ResponseWriter, r *http.Request) {
1609+
if s.Config.LogoPath == "" {
1610+
http.NotFound(w, r)
1611+
return
1612+
}
1613+
http.ServeFile(w, r, s.Config.LogoPath)
1614+
}
1615+
1616+
// ---------------------------------------------------------------------------
1617+
// API Tokens
1618+
// ---------------------------------------------------------------------------
1619+
1620+
func (s *Server) apiGetTokens(w http.ResponseWriter, r *http.Request) {
1621+
user, ok := s.checkAuth(r)
1622+
if !ok {
1623+
jsonError(w, "Unauthorized", http.StatusUnauthorized)
1624+
return
1625+
}
1626+
1627+
type tokenInfo struct {
1628+
ID string `json:"id"`
1629+
Name string `json:"name"`
1630+
Username string `json:"username"`
1631+
CreatedAt string `json:"created_at"`
1632+
LastUsedAt string `json:"last_used_at"`
1633+
LastUsedIP string `json:"last_used_ip"`
1634+
ExpiresAt string `json:"expires_at"`
1635+
Prefix string `json:"prefix"`
1636+
}
1637+
1638+
var result []tokenInfo
1639+
for _, tok := range s.Config.APITokens {
1640+
if user.Role != config.RoleSuperAdmin && tok.Username != user.Username {
1641+
continue
1642+
}
1643+
prefix := "ti_XXXX..."
1644+
if len(tok.TokenHash) >= 8 {
1645+
prefix = "ti_" + tok.TokenHash[:4] + "..."
1646+
}
1647+
result = append(result, tokenInfo{
1648+
ID: tok.ID,
1649+
Name: tok.Name,
1650+
Username: tok.Username,
1651+
CreatedAt: tok.CreatedAt,
1652+
LastUsedAt: tok.LastUsedAt,
1653+
LastUsedIP: tok.LastUsedIP,
1654+
ExpiresAt: tok.ExpiresAt,
1655+
Prefix: prefix,
1656+
})
1657+
}
1658+
if result == nil {
1659+
result = []tokenInfo{}
1660+
}
1661+
jsonResponse(w, result)
1662+
}
1663+
1664+
func (s *Server) apiCreateToken(w http.ResponseWriter, r *http.Request) {
1665+
if !s.isCSRFSafe(r) {
1666+
jsonError(w, "Forbidden", http.StatusForbidden)
1667+
return
1668+
}
1669+
user, ok := s.checkAuth(r)
1670+
if !ok {
1671+
jsonError(w, "Unauthorized", http.StatusUnauthorized)
1672+
return
1673+
}
1674+
1675+
var body struct {
1676+
Name string `json:"name"`
1677+
ExpiresAt string `json:"expires_at"`
1678+
}
1679+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
1680+
jsonError(w, "Invalid request body", http.StatusBadRequest)
1681+
return
1682+
}
1683+
if body.Name == "" {
1684+
jsonError(w, "Name is required", http.StatusBadRequest)
1685+
return
1686+
}
1687+
1688+
raw, err := generateToken()
1689+
if err != nil {
1690+
jsonError(w, "Failed to generate token", http.StatusInternalServerError)
1691+
return
1692+
}
1693+
1694+
hash := hashToken(raw)
1695+
id := hash[:16]
1696+
1697+
tok := &config.APIToken{
1698+
ID: id,
1699+
Name: body.Name,
1700+
TokenHash: hash,
1701+
Username: user.Username,
1702+
Role: user.Role,
1703+
CreatedAt: time.Now().Format(time.RFC3339),
1704+
ExpiresAt: body.ExpiresAt,
1705+
}
1706+
1707+
s.Config.APITokens = append(s.Config.APITokens, tok)
1708+
s.Config.SaveConfig()
1709+
1710+
jsonResponse(w, map[string]string{
1711+
"id": id,
1712+
"token": raw,
1713+
"name": body.Name,
1714+
})
1715+
}
1716+
1717+
func (s *Server) apiDeleteToken(w http.ResponseWriter, r *http.Request) {
1718+
if !s.isCSRFSafe(r) {
1719+
jsonError(w, "Forbidden", http.StatusForbidden)
1720+
return
1721+
}
1722+
user, ok := s.checkAuth(r)
1723+
if !ok {
1724+
jsonError(w, "Unauthorized", http.StatusUnauthorized)
1725+
return
1726+
}
1727+
1728+
id := r.URL.Query().Get("id")
1729+
if id == "" {
1730+
jsonError(w, "ID is required", http.StatusBadRequest)
1731+
return
1732+
}
1733+
1734+
newTokens := make([]*config.APIToken, 0, len(s.Config.APITokens))
1735+
found := false
1736+
for _, tok := range s.Config.APITokens {
1737+
if tok.ID == id {
1738+
if user.Role != config.RoleSuperAdmin && tok.Username != user.Username {
1739+
jsonError(w, "Forbidden", http.StatusForbidden)
1740+
return
1741+
}
1742+
found = true
1743+
continue
1744+
}
1745+
newTokens = append(newTokens, tok)
1746+
}
1747+
if !found {
1748+
jsonError(w, "Token not found", http.StatusNotFound)
1749+
return
1750+
}
1751+
1752+
s.Config.APITokens = newTokens
1753+
s.Config.SaveConfig()
1754+
jsonResponse(w, map[string]string{"status": "deleted"})
1755+
}
1756+
15171757
// ---------------------------------------------------------------------------
15181758
// Settings
15191759
// ---------------------------------------------------------------------------

server/server.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,27 @@ func (s *Server) setupRoutes() *http.ServeMux {
577577

578578
mux.HandleFunc("/api/stats", s.apiGetStats)
579579

580+
mux.HandleFunc("/api/tokens", func(w http.ResponseWriter, r *http.Request) {
581+
switch r.Method {
582+
case http.MethodGet:
583+
s.apiGetTokens(w, r)
584+
case http.MethodPost:
585+
s.apiCreateToken(w, r)
586+
case http.MethodDelete:
587+
s.apiDeleteToken(w, r)
588+
default:
589+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
590+
}
591+
})
592+
593+
// Logo
594+
mux.HandleFunc("/api/branding/logo", s.apiUploadLogo)
595+
mux.HandleFunc("/branding/logo", s.handleServeLogo)
596+
597+
// OpenAPI / Swagger
598+
mux.HandleFunc("/api/openapi.yaml", s.handleOpenAPISpec)
599+
mux.HandleFunc("/api/docs", s.handleSwaggerUI)
600+
580601
return mux
581602
}
582603

0 commit comments

Comments
 (0)