fix: code review — XSS, credentials 0600, interval leak, path bugs, title, unused imports

This commit is contained in:
Claus Lohmar 2026-08-05 16:13:25 +01:00
parent 09592b90d2
commit cda6ca4785
8 changed files with 63 additions and 25 deletions

13
api.go
View file

@ -90,6 +90,8 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: safe})
case http.MethodPost:
s.mu.Lock()
defer s.mu.Unlock()
var newCfg Config
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON: " + err.Error()})
@ -135,7 +137,7 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
go2rtcURL := "http://127.0.0.1:1984/api/stream.mjpeg?src=" + camID + "_" + streamType
resp, err := http.Get(go2rtcURL)
resp, err := httpClient.Get(go2rtcURL)
if err != nil {
http.Error(w, "stream unavailable", http.StatusServiceUnavailable)
return
@ -363,7 +365,7 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
snapName := strings.Replace(name, "rec_", "snap_", 1)
snapName = strings.Replace(snapName, ".mp4", ".jpg", 1)
snapPath := camDir + "/" + snapName
if _, err := os.Stat(recDir + "/" + snapName); err == nil {
if _, err := os.Stat(scanDir + "/" + snapName); err == nil {
clip.Snap = snapPath
}
@ -381,7 +383,12 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
}
}
clips = append([]Clip{clip}, clips...) // newest first
clips = append(clips, clip)
}
// Reverse — newest first.
for i, j := 0, len(clips)-1; i < j; i, j = i+1, j-1 {
clips[i], clips[j] = clips[j], clips[i]
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})

View file

@ -125,7 +125,7 @@ func SaveConfig(cfg Config, path string) error {
header := []byte("# NextNVR Configuration\n# Generated by NextNVR setup\n\n")
out := append(header, data...)
if err := os.WriteFile(path, out, 0644); err != nil {
if err := os.WriteFile(path, out, 0600); err != nil {
return fmt.Errorf("writing config: %w", err)
}
return nil

View file

@ -68,7 +68,9 @@ func (g *Go2RTCManager) Stop() {
if g.cmd != nil && g.cmd.Process != nil {
log.Println("go2rtc: stopping...")
g.cmd.Process.Signal(os.Interrupt)
g.mu.Unlock()
time.Sleep(2 * time.Second)
g.mu.Lock()
g.cmd.Process.Kill()
g.running = false
log.Println("go2rtc: stopped")
@ -139,5 +141,5 @@ func (g *Go2RTCManager) writeConfig(cameras []CameraConfig) error {
sb.WriteString(fmt.Sprintf(" %s_main: %s\n", cam.ID, mainURL))
}
return os.WriteFile("go2rtc.yaml", []byte(sb.String()), 0644)
return os.WriteFile("go2rtc.yaml", []byte(sb.String()), 0600)
}

View file

@ -10,7 +10,6 @@ package main
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net"
@ -238,6 +237,3 @@ func buildRTSPURLs(ip, user, pass, manufacturer string) (string, string) {
return replacer.Replace(patterns.main), replacer.Replace(patterns.sub)
}
// suppress unused import warning for encoding/xml.
var _ = xml.Unmarshal

View file

@ -61,8 +61,12 @@ async function loadCameras() {
}
// ── Live Grid ──
let gridRefreshTimer = null;
function renderLiveGrid() {
const grid = document.getElementById('grid');
// Clear previous refresh timer.
if (gridRefreshTimer) { clearInterval(gridRefreshTimer); gridRefreshTimer = null; }
grid.innerHTML = '';
for (let i = 0; i < 8; i++) {
const cam = cameras[i];
@ -80,16 +84,23 @@ function renderLiveGrid() {
<span class="tile-status online"></span>
<span class="tile-label">${cam.name}</span>
`;
setInterval(() => {
const img = tile.querySelector('img');
if (img) img.src = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`;
}, 3000);
tile.addEventListener('click', () => openFocus(cam));
} else {
tile.innerHTML = '<span class="tile-placeholder">No Camera</span>';
}
grid.appendChild(tile);
}
// Single global refresh timer — avoids interval leak.
gridRefreshTimer = setInterval(() => {
grid.querySelectorAll('.grid-tile:not(.offline) img.grid-snap').forEach(img => {
const camId = img.closest('.grid-tile').dataset.camId;
if (camId) {
const cam = cameras.find(c => c.id === camId);
if (cam) img.src = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`;
}
});
}, 3000);
}
// ── Focus Overlay ──
@ -153,16 +164,36 @@ async function loadClips() {
}
function renderClips(clips) {
const grid = document.getElementById('pb-clips');
if (clips.length === 0) { grid.innerHTML = '<p style="color:var(--text-muted);grid-column:1/-1">No recordings found for this selection.</p>'; return; }
grid.innerHTML = clips.map(c => `
<div class="clip-card" onclick="playClip('${c.path}')">
<img src="${c.snap || ''}" class="clip-thumb" loading="lazy"
onerror="this.style.display='none'" alt="${c.time}">
<div>${c.live ? '🔴 ' : '🎬 '}${c.time}</div>
<div class="clip-time">${c.name}</div>
<div class="clip-size">${formatSize(c.size)}</div>
</div>
`).join('');
grid.innerHTML = '';
if (clips.length === 0) {
grid.innerHTML = '<p style="color:var(--text-muted);grid-column:1/-1">No recordings found for this selection.</p>';
return;
}
clips.forEach(c => {
const card = document.createElement('div');
card.className = 'clip-card';
card.addEventListener('click', () => playClip(c.path));
if (c.snap) {
const thumb = document.createElement('img');
thumb.className = 'clip-thumb';
thumb.src = c.snap;
thumb.loading = 'lazy';
thumb.onerror = () => { thumb.style.display = 'none'; };
card.appendChild(thumb);
}
const timeDiv = document.createElement('div');
timeDiv.textContent = (c.live ? '🔴 ' : '🎬 ') + c.time;
card.appendChild(timeDiv);
const nameDiv = document.createElement('div');
nameDiv.className = 'clip-time';
nameDiv.textContent = c.name;
card.appendChild(nameDiv);
const sizeDiv = document.createElement('div');
sizeDiv.className = 'clip-size';
sizeDiv.textContent = formatSize(c.size);
card.appendChild(sizeDiv);
grid.appendChild(card);
});
}
function playClip(path) {
document.getElementById('pb-player').classList.remove('hidden');

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NextNVR — Casa Alba Mindelo</title>
<title>NextNVR</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>

View file

@ -85,7 +85,7 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) {
cam.Username, cam.Password, cam.IP)
}
log.Printf("recorder: starting %s → %s", cam.ID, rtspURL)
log.Printf("recorder: starting %s", cam.ID)
rm.mu.Lock()
proc := &recorderProcess{

View file

@ -11,6 +11,7 @@ import (
"net/http/httputil"
"net/url"
"strings"
"sync"
)
//go:embed public/*
@ -22,6 +23,7 @@ type Server struct {
http *http.Server
mux *http.ServeMux
appConfig *Config // mutable config reference for hot-reload
mu sync.RWMutex
}
// NewServer creates and configures the HTTP server.