Compare commits

...
2 Commits
Author SHA1 Message Date
ubuntuandClaude Opus 5.5 16e979fe77 Fix arbitrary file write in /upload and /analyse_m3u_file
/upload stored files under the client-supplied filename (allowing .php
webshells and ../ traversal) and analyse_m3u_file renamed files from
unvalidated filename/id_playlist. Uploads are now always stored as
upload_<uuid>.m3u, id_playlist must be a UUID, and paths go through
safe_join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:54:14 +00:00
ubuntuandClaude Sonnet 5 03ec3f0c68 Speed up device snapshot cadence for a near-live thumbnail
Lower the default and minimum snapshot interval (10s/2s -> 2s/1s) and
refresh the selected device's thumbnail on its own 1s timer instead of
waiting for the 5s device-list poll, so the preview updates fast
enough to feel close to live video without building an actual
streaming pipeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:51:13 +00:00
3 changed files with 65 additions and 18 deletions
+37 -8
View File
@@ -104,6 +104,18 @@ def safe_join(base_dir, user_filename):
return candidate return candidate
UPLOADED_PLAYLIST_RE = re.compile(r'^upload_[0-9a-f]{32}\.m3u$')
def validate_id_playlist(value):
"""Playlist ids are UUIDs; anything else is rejected (they end up in file paths
and Mongo collection names)."""
try:
return str(uuid.UUID(str(value)))
except (ValueError, TypeError, AttributeError):
raise ValueError('invalid id_playlist')
def findInfo(tag, s): def findInfo(tag, s):
end = '"' end = '"'
if (s.find(tag)>-1): if (s.find(tag)>-1):
@@ -451,8 +463,16 @@ async def analyse_m3u_file(request):
playlist_comment = data.get('comment', '').strip() playlist_comment = data.get('comment', '').strip()
new_path_filename = "./" + UPLOAD_PLAYLIST_DIR + '/' + id_playlist + '.m3u' try:
old_path_filename = "./" + UPLOAD_PLAYLIST_DIR + '/' +filename id_playlist = validate_id_playlist(id_playlist)
if not UPLOADED_PLAYLIST_RE.match(filename or ''):
raise ValueError('invalid filename')
new_path_filename = safe_join(UPLOAD_PLAYLIST_DIR, id_playlist + '.m3u')
old_path_filename = safe_join(UPLOAD_PLAYLIST_DIR, filename)
except ValueError as exc:
return web.json_response({'error': str(exc)}, status=400)
if not os.path.isfile(old_path_filename):
return web.json_response({'error': 'uploaded file not found'}, status=400)
os.rename(old_path_filename,new_path_filename) os.rename(old_path_filename,new_path_filename)
collection_channels = get_playlist_collection(id_playlist) collection_channels = get_playlist_collection(id_playlist)
@@ -721,27 +741,36 @@ async def handle_upload(request):
# Handle both form field and file in one pass # Handle both form field and file in one pass
async for part in reader: async for part in reader:
if part.name == "id_playlist_to_action": if part.name == "id_playlist_to_action":
id_playlist_data = await part.text() id_playlist_data = (await part.text()).strip()
if len(id_playlist_data) == 0: if len(id_playlist_data) == 0:
print('No playlist to update', flush=True) print('No playlist to update', flush=True)
id_playlist = str(uuid.uuid4()) id_playlist = str(uuid.uuid4())
else: else:
id_playlist = id_playlist_data try:
id_playlist = validate_id_playlist(id_playlist_data)
except ValueError:
if filepath and os.path.exists(filepath):
os.remove(filepath)
return web.Response(text="Invalid id_playlist.", status=400)
print('playlist to update :', id_playlist, flush=True) print('playlist to update :', id_playlist, flush=True)
elif part.name == "file": elif part.name == "file":
filename = part.filename if not part.filename:
if not filename:
return web.Response(text="No file provided.", status=400) return web.Response(text="No file provided.", status=400)
filepath = os.path.join(UPLOAD_PLAYLIST_DIR, filename) # Never trust the client-supplied filename: always store under a
# server-generated .m3u name inside the playlists directory.
filename = f"upload_{uuid.uuid4().hex}.m3u"
filepath = safe_join(UPLOAD_PLAYLIST_DIR, filename)
with open(filepath, "wb") as f: with open(filepath, "wb") as f:
while chunk := await part.read_chunk(): while chunk := await part.read_chunk():
f.write(chunk) f.write(chunk)
if filepath: if filepath:
return web.json_response({'status': 'ok', 'id_playlist': id_playlist}) if id_playlist is None:
id_playlist = str(uuid.uuid4())
return web.json_response({'status': 'ok', 'id_playlist': id_playlist, 'filename': filename})
return web.Response(text="No file field found in the request.", status=400) return web.Response(text="No file field found in the request.", status=400)
+4 -4
View File
@@ -1114,8 +1114,8 @@ def communication(data):
except (TypeError, ValueError): except (TypeError, ValueError):
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Invalid snapshot interval', 0) sendMessage(toSidDevice, fromSidDevice, 'warning', 'Invalid snapshot interval', 0)
else: else:
if interval_seconds < 2: if interval_seconds < 1:
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Snapshot interval must be at least 2 seconds', 0) sendMessage(toSidDevice, fromSidDevice, 'warning', 'Snapshot interval must be at least 1 second', 0)
else: else:
SNAPSHOT_INTERVAL_SECONDS = interval_seconds SNAPSHOT_INTERVAL_SECONDS = interval_seconds
sendMessage( sendMessage(
@@ -1302,7 +1302,7 @@ def initIPTV():
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# ---------------------------------------------------------------- # ----------------------------------------------------------------
num_version='5.0.6' num_version='5.0.7'
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# ---------------------------------------------------------------- # ----------------------------------------------------------------
@@ -1328,7 +1328,7 @@ mediaRunning = ''
status_vpn = 'disconnected' status_vpn = 'disconnected'
data_running = {} data_running = {}
auto_snapshot_enabled = True auto_snapshot_enabled = True
SNAPSHOT_INTERVAL_SECONDS = 10 SNAPSHOT_INTERVAL_SECONDS = 2
POLLING_INTERVAL_SECONDS = 10 POLLING_INTERVAL_SECONDS = 10
SOCKET_CONNECT_TIMEOUT = 10 SOCKET_CONNECT_TIMEOUT = 10
SOCKET_RETRY_MIN_INTERVAL = 3 SOCKET_RETRY_MIN_INTERVAL = 3
+24 -6
View File
@@ -1220,7 +1220,7 @@
<div class="modal-body"> <div class="modal-body">
<div id="snapshotIntervalSettingsAlert"></div> <div id="snapshotIntervalSettingsAlert"></div>
<label for="snapshot_interval_input" class="form-label">Seconds between 2 snapshots</label> <label for="snapshot_interval_input" class="form-label">Seconds between 2 snapshots</label>
<input type="number" min="2" step="1" class="form-control" id="snapshot_interval_input" placeholder="10"> <input type="number" min="1" step="1" class="form-control" id="snapshot_interval_input" placeholder="2">
<div class="form-text">This value is applied to the currently selected device.</div> <div class="form-text">This value is applied to the currently selected device.</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -1528,7 +1528,7 @@
var PROGRESS_STALE_MS = 15000 var PROGRESS_STALE_MS = 15000
var playlistImportProgress = 0 var playlistImportProgress = 0
var playlistImportInFlight = false var playlistImportInFlight = false
var selectedDeviceSnapshotIntervalSeconds = 10 var selectedDeviceSnapshotIntervalSeconds = 2
var currentSelectedDeviceData = null var currentSelectedDeviceData = null
var socket = io('https://iptv.mrk.ovh', { var socket = io('https://iptv.mrk.ovh', {
@@ -1710,6 +1710,22 @@
$placeholder.hide(); $placeholder.hide();
} }
// Reloads the currently selected device's snapshot image on its own fast
// timer, independent of the 5s full device-list poll, so a low snapshot
// interval (see Settings > Snapshot interval) actually feels near-live.
function refreshSelectedDeviceSnapshotFast() {
var device = currentSelectedDeviceData;
if (!device || !device.snapshot_url) {
return;
}
var dataRunning = device.data_running || {};
var hasChannel = Object.keys(dataRunning).length > 0 && dataRunning.name_channel;
if (!hasChannel) {
return;
}
$('#selectedDeviceSnapshot').attr('src', device.snapshot_url + '?t=' + Date.now());
}
function updateAutoSnapshotToggle(device) { function updateAutoSnapshotToggle(device) {
var $toggle = $('#autoSnapshotToggle'); var $toggle = $('#autoSnapshotToggle');
if (!device || !device.id_device) { if (!device || !device.id_device) {
@@ -1723,12 +1739,12 @@
function updateSnapshotIntervalState(device) { function updateSnapshotIntervalState(device) {
if (!device || !device.id_device) { if (!device || !device.id_device) {
selectedDeviceSnapshotIntervalSeconds = 10; selectedDeviceSnapshotIntervalSeconds = 2;
return; return;
} }
var interval = Number(device.snapshot_interval_seconds); var interval = Number(device.snapshot_interval_seconds);
if (!Number.isFinite(interval) || interval < 2) { if (!Number.isFinite(interval) || interval < 1) {
interval = selectedDeviceSnapshotIntervalSeconds; interval = selectedDeviceSnapshotIntervalSeconds;
} }
selectedDeviceSnapshotIntervalSeconds = interval; selectedDeviceSnapshotIntervalSeconds = interval;
@@ -1789,6 +1805,7 @@
$(window).on('scroll resize', lazyLoad); $(window).on('scroll resize', lazyLoad);
updateListDevices() updateListDevices()
setInterval(updateListDevices, 5000) setInterval(updateListDevices, 5000)
setInterval(refreshSelectedDeviceSnapshotFast, 1000)
update_list_playlist() update_list_playlist()
display_most_frequent() display_most_frequent()
setInterval(display_most_frequent, 15000) setInterval(display_most_frequent, 15000)
@@ -2986,6 +3003,7 @@
$('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Upload successful!</div>'); $('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Upload successful!</div>');
var response = JSON.parse(xhr.response); var response = JSON.parse(xhr.response);
var id_playlist = response['id_playlist']; var id_playlist = response['id_playlist'];
$("#fileName").val(response['filename']);
if (response.status == 'ok') { if (response.status == 'ok') {
$.post('analyse_m3u_file', { $.post('analyse_m3u_file', {
'id_playlist': id_playlist, 'id_playlist': id_playlist,
@@ -3158,9 +3176,9 @@
} }
var intervalValue = Number($('#snapshot_interval_input').val()); var intervalValue = Number($('#snapshot_interval_input').val());
if (!Number.isInteger(intervalValue) || intervalValue < 2) { if (!Number.isInteger(intervalValue) || intervalValue < 1) {
$('#snapshot_interval_input').addClass('is-invalid'); $('#snapshot_interval_input').addClass('is-invalid');
$('#snapshotIntervalSettingsAlert').html('<div class="alert alert-danger" role="alert">Enter an integer value greater than or equal to 2.</div>'); $('#snapshotIntervalSettingsAlert').html('<div class="alert alert-danger" role="alert">Enter an integer value greater than or equal to 1.</div>');
return; return;
} }