peertube-plugin-sponsorblock 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/client/video-watch.js +10 -2
- package/main.js +141 -48
- package/package.json +4 -4
- package/server/ffmpeg.js +12 -2
- package/server/routes.js +181 -53
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Allow PeerTube instances to leverage the crowdsourced SponsorBlock database to i
|
|
|
12
12
|
|
|
13
13
|
Phase 1 (client-side skip), Phase 2 (admin dashboard & periodic sync), and Phase 3 (permanent removal) are implemented.
|
|
14
14
|
|
|
15
|
-
See the [User Guide](https://
|
|
15
|
+
See the [User Guide](https://github.com/jblemee/peertube-plugin-sponsorblock/blob/develop/USER_GUIDE.md) for installation and usage instructions.
|
|
16
16
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
@@ -80,12 +80,12 @@ See the [User Guide](https://git.ut0pia.org/jbl/peertube-plugin-sponsorblock/src
|
|
|
80
80
|
|
|
81
81
|
## Documentation
|
|
82
82
|
|
|
83
|
-
- [User Guide](https://
|
|
84
|
-
- [Development Guide](https://
|
|
85
|
-
- [Changelog](https://
|
|
86
|
-
- [TODO](https://
|
|
87
|
-
- [Research](https://
|
|
88
|
-
- [Technical Analysis](https://
|
|
83
|
+
- [User Guide](https://github.com/jblemee/peertube-plugin-sponsorblock/blob/develop/USER_GUIDE.md) — Installation, configuration, and usage guide for instance administrators
|
|
84
|
+
- [Development Guide](https://github.com/jblemee/peertube-plugin-sponsorblock/blob/develop/DEVELOPMENT.md) — Development setup, project structure, testing, and contributing
|
|
85
|
+
- [Changelog](https://github.com/jblemee/peertube-plugin-sponsorblock/blob/develop/CHANGELOG.md) — Version history and release notes
|
|
86
|
+
- [TODO](https://github.com/jblemee/peertube-plugin-sponsorblock/blob/develop/TODO.md) — Roadmap, planned features, and known issues
|
|
87
|
+
- [Research](https://github.com/jblemee/peertube-plugin-sponsorblock/blob/develop/RESEARCH.md) — State-of-the-art research and PeerTube plugin capabilities
|
|
88
|
+
- [Technical Analysis](https://github.com/jblemee/peertube-plugin-sponsorblock/blob/develop/TECHNICAL_ANALYSIS.md) — Technical analysis of permanent segment removal with FFmpeg
|
|
89
89
|
|
|
90
90
|
## Research highlights
|
|
91
91
|
|
package/client/video-watch.js
CHANGED
|
@@ -210,7 +210,11 @@ function register({ registerHook, peertubeHelpers }) {
|
|
|
210
210
|
const currentDiv = document.createElement('div')
|
|
211
211
|
currentDiv.className = 'sponsorblock-widget-current'
|
|
212
212
|
if (currentMapping) {
|
|
213
|
-
currentDiv.
|
|
213
|
+
currentDiv.textContent = ''
|
|
214
|
+
currentDiv.appendChild(document.createTextNode(currentLabel + ' '))
|
|
215
|
+
const code = document.createElement('code')
|
|
216
|
+
code.textContent = currentMapping.youtube_id
|
|
217
|
+
currentDiv.appendChild(code)
|
|
214
218
|
}
|
|
215
219
|
content.appendChild(currentDiv)
|
|
216
220
|
|
|
@@ -299,7 +303,11 @@ function register({ registerHook, peertubeHelpers }) {
|
|
|
299
303
|
}
|
|
300
304
|
|
|
301
305
|
// Update current mapping display
|
|
302
|
-
currentDiv.
|
|
306
|
+
currentDiv.textContent = ''
|
|
307
|
+
currentDiv.appendChild(document.createTextNode(currentLabel + ' '))
|
|
308
|
+
const codeEl = document.createElement('code')
|
|
309
|
+
codeEl.textContent = data.youtubeId
|
|
310
|
+
currentDiv.appendChild(codeEl)
|
|
303
311
|
input.value = ''
|
|
304
312
|
|
|
305
313
|
} catch (e) {
|
package/main.js
CHANGED
|
@@ -5,6 +5,73 @@
|
|
|
5
5
|
|
|
6
6
|
const { registerRoutes } = require('./server/routes')
|
|
7
7
|
const { getVideoDuration, processVideoFile, findVideoFiles } = require('./server/ffmpeg')
|
|
8
|
+
const { URL } = require('url')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate that an API URL is safe (not targeting internal/private networks)
|
|
12
|
+
* Rejects private IPs, loopback, link-local, and non-HTTPS schemes
|
|
13
|
+
*/
|
|
14
|
+
function validateApiUrl(urlString) {
|
|
15
|
+
let parsed
|
|
16
|
+
try {
|
|
17
|
+
parsed = new URL(urlString)
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error(`Invalid API URL: ${urlString}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (parsed.protocol !== 'https:') {
|
|
23
|
+
throw new Error(`API URL must use HTTPS, got: ${parsed.protocol}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const hostname = parsed.hostname
|
|
27
|
+
|
|
28
|
+
// Reject IPv6 private/loopback
|
|
29
|
+
if (hostname.startsWith('[')) {
|
|
30
|
+
const ipv6 = hostname.slice(1, -1).toLowerCase()
|
|
31
|
+
if (ipv6 === '::1' || ipv6.startsWith('fc') || ipv6.startsWith('fd') || ipv6.startsWith('fe80')) {
|
|
32
|
+
throw new Error(`API URL must not target private/internal addresses: ${hostname}`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Reject IPv4 private/loopback/link-local ranges
|
|
37
|
+
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
38
|
+
if (ipv4Match) {
|
|
39
|
+
const [, a, b] = ipv4Match.map(Number)
|
|
40
|
+
if (
|
|
41
|
+
a === 127 || // 127.0.0.0/8 loopback
|
|
42
|
+
a === 10 || // 10.0.0.0/8 private
|
|
43
|
+
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
|
|
44
|
+
(a === 192 && b === 168) || // 192.168.0.0/16 private
|
|
45
|
+
(a === 169 && b === 254) || // 169.254.0.0/16 link-local
|
|
46
|
+
a === 0 // 0.0.0.0/8
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(`API URL must not target private/internal addresses: ${hostname}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Reject localhost by name
|
|
53
|
+
if (hostname === 'localhost' || hostname.endsWith('.local')) {
|
|
54
|
+
throw new Error(`API URL must not target local addresses: ${hostname}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return parsed.toString()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate a segment from the SponsorBlock API response
|
|
62
|
+
*/
|
|
63
|
+
function validateSegment(segment) {
|
|
64
|
+
if (!segment || typeof segment !== 'object') return false
|
|
65
|
+
if (typeof segment.UUID !== 'string' || segment.UUID.length === 0 || segment.UUID.length > 128) return false
|
|
66
|
+
if (!Array.isArray(segment.segment) || segment.segment.length !== 2) return false
|
|
67
|
+
const [start, end] = segment.segment
|
|
68
|
+
if (typeof start !== 'number' || typeof end !== 'number') return false
|
|
69
|
+
if (start < 0 || end < 0 || start >= end) return false
|
|
70
|
+
if (!isFinite(start) || !isFinite(end)) return false
|
|
71
|
+
if (typeof segment.category !== 'string' || segment.category.length === 0 || segment.category.length > 50) return false
|
|
72
|
+
if (segment.votes !== undefined && typeof segment.votes !== 'number') return false
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
8
75
|
|
|
9
76
|
let workerIntervalId = null
|
|
10
77
|
let syncIntervalId = null
|
|
@@ -102,7 +169,7 @@ function registerSettings(registerSetting) {
|
|
|
102
169
|
label: 'SponsorBlock API URL',
|
|
103
170
|
type: 'input',
|
|
104
171
|
default: 'https://sponsor.ajay.app',
|
|
105
|
-
private:
|
|
172
|
+
private: true
|
|
106
173
|
})
|
|
107
174
|
|
|
108
175
|
registerSetting({
|
|
@@ -230,9 +297,9 @@ function registerImportHooks(registerHook, peertubeHelpers, settingsManager) {
|
|
|
230
297
|
target: 'filter:api.video.post-import-url.accept.result',
|
|
231
298
|
handler: async (result, params) => {
|
|
232
299
|
try {
|
|
233
|
-
const { videoImport } = params
|
|
300
|
+
const { videoImport, video } = params
|
|
234
301
|
|
|
235
|
-
if (!videoImport || !
|
|
302
|
+
if (!videoImport || !video) {
|
|
236
303
|
return result
|
|
237
304
|
}
|
|
238
305
|
|
|
@@ -244,12 +311,12 @@ function registerImportHooks(registerHook, peertubeHelpers, settingsManager) {
|
|
|
244
311
|
return result
|
|
245
312
|
}
|
|
246
313
|
|
|
247
|
-
logger.info(`Video imported from YouTube: ${youtubeId} -> ${
|
|
314
|
+
logger.info(`Video imported from YouTube: ${youtubeId} -> ${video.uuid}`)
|
|
248
315
|
|
|
249
316
|
// Save mapping
|
|
250
317
|
await saveYouTubeMapping(
|
|
251
318
|
peertubeHelpers,
|
|
252
|
-
|
|
319
|
+
video.uuid,
|
|
253
320
|
youtubeId
|
|
254
321
|
)
|
|
255
322
|
|
|
@@ -265,7 +332,7 @@ function registerImportHooks(registerHook, peertubeHelpers, settingsManager) {
|
|
|
265
332
|
if (mode === 'remove') {
|
|
266
333
|
await queueVideoProcessing(
|
|
267
334
|
peertubeHelpers,
|
|
268
|
-
|
|
335
|
+
video.uuid,
|
|
269
336
|
youtubeId
|
|
270
337
|
)
|
|
271
338
|
}
|
|
@@ -322,6 +389,7 @@ async function fetchAndCacheSegments(peertubeHelpers, settingsManager, youtubeId
|
|
|
322
389
|
|
|
323
390
|
try {
|
|
324
391
|
const apiUrl = await settingsManager.getSetting('api_url') || 'https://sponsor.ajay.app'
|
|
392
|
+
validateApiUrl(apiUrl)
|
|
325
393
|
const url = `${apiUrl}/api/skipSegments?videoID=${youtubeId}`
|
|
326
394
|
|
|
327
395
|
logger.debug(`Fetching SponsorBlock segments for ${youtubeId}`)
|
|
@@ -338,30 +406,42 @@ async function fetchAndCacheSegments(peertubeHelpers, settingsManager, youtubeId
|
|
|
338
406
|
|
|
339
407
|
const segments = await response.json()
|
|
340
408
|
|
|
341
|
-
// Delete old segments
|
|
342
|
-
await database.query(
|
|
343
|
-
|
|
344
|
-
`, { bind: [youtubeId] })
|
|
345
|
-
|
|
346
|
-
// Insert new segments
|
|
347
|
-
for (const segment of segments) {
|
|
409
|
+
// Delete old and insert new segments in a transaction
|
|
410
|
+
await database.query('BEGIN')
|
|
411
|
+
try {
|
|
348
412
|
await database.query(`
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
413
|
+
DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1
|
|
414
|
+
`, { bind: [youtubeId] })
|
|
415
|
+
|
|
416
|
+
let cached = 0
|
|
417
|
+
for (const segment of segments) {
|
|
418
|
+
if (!validateSegment(segment)) {
|
|
419
|
+
logger.warn(`Skipping invalid segment from API: ${JSON.stringify(segment).slice(0, 200)}`)
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
await database.query(`
|
|
423
|
+
INSERT INTO plugin_sponsorblock_segments
|
|
424
|
+
(youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
|
|
425
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
426
|
+
ON CONFLICT (segment_uuid) DO NOTHING
|
|
427
|
+
`, { bind: [
|
|
428
|
+
youtubeId,
|
|
429
|
+
segment.UUID,
|
|
430
|
+
segment.segment[0],
|
|
431
|
+
segment.segment[1],
|
|
432
|
+
segment.category,
|
|
433
|
+
segment.actionType || 'skip',
|
|
434
|
+
segment.votes || 0
|
|
435
|
+
] })
|
|
436
|
+
cached++
|
|
437
|
+
}
|
|
363
438
|
|
|
364
|
-
|
|
439
|
+
await database.query('COMMIT')
|
|
440
|
+
logger.info(`Cached ${cached} segments for ${youtubeId}`)
|
|
441
|
+
} catch (txError) {
|
|
442
|
+
await database.query('ROLLBACK')
|
|
443
|
+
throw txError
|
|
444
|
+
}
|
|
365
445
|
|
|
366
446
|
} catch (error) {
|
|
367
447
|
logger.error(`Failed to fetch segments for ${youtubeId}`, error)
|
|
@@ -524,6 +604,7 @@ function startSyncTimer(peertubeHelpers, settingsManager) {
|
|
|
524
604
|
)
|
|
525
605
|
|
|
526
606
|
const apiUrl = await settingsManager.getSetting('api_url') || 'https://sponsor.ajay.app'
|
|
607
|
+
validateApiUrl(apiUrl)
|
|
527
608
|
|
|
528
609
|
for (const mapping of (mappings || [])) {
|
|
529
610
|
try {
|
|
@@ -532,26 +613,38 @@ function startSyncTimer(peertubeHelpers, settingsManager) {
|
|
|
532
613
|
if (response.ok) {
|
|
533
614
|
const apiSegments = await response.json()
|
|
534
615
|
|
|
535
|
-
await database.query(
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
(
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
616
|
+
await database.query('BEGIN')
|
|
617
|
+
try {
|
|
618
|
+
await database.query(
|
|
619
|
+
'DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1',
|
|
620
|
+
{ bind: [mapping.youtube_id] }
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
for (const segment of apiSegments) {
|
|
624
|
+
if (!validateSegment(segment)) {
|
|
625
|
+
logger.warn(`Periodic sync: skipping invalid segment: ${JSON.stringify(segment).slice(0, 200)}`)
|
|
626
|
+
continue
|
|
627
|
+
}
|
|
628
|
+
await database.query(`
|
|
629
|
+
INSERT INTO plugin_sponsorblock_segments
|
|
630
|
+
(youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
|
|
631
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
632
|
+
ON CONFLICT (segment_uuid) DO NOTHING
|
|
633
|
+
`, { bind: [
|
|
634
|
+
mapping.youtube_id,
|
|
635
|
+
segment.UUID,
|
|
636
|
+
segment.segment[0],
|
|
637
|
+
segment.segment[1],
|
|
638
|
+
segment.category,
|
|
639
|
+
segment.actionType || 'skip',
|
|
640
|
+
segment.votes || 0
|
|
641
|
+
] })
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
await database.query('COMMIT')
|
|
645
|
+
} catch (txError) {
|
|
646
|
+
await database.query('ROLLBACK')
|
|
647
|
+
throw txError
|
|
555
648
|
}
|
|
556
649
|
}
|
|
557
650
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peertube-plugin-sponsorblock",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Automatically skip or remove sponsor segments in videos imported from YouTube using the SponsorBlock API",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"engine": {
|
|
@@ -15,15 +15,15 @@
|
|
|
15
15
|
"skip",
|
|
16
16
|
"segments"
|
|
17
17
|
],
|
|
18
|
-
"homepage": "https://
|
|
19
|
-
"bugs": "https://
|
|
18
|
+
"homepage": "https://github.com/jblemee/peertube-plugin-sponsorblock",
|
|
19
|
+
"bugs": "https://github.com/jblemee/peertube-plugin-sponsorblock/issues",
|
|
20
20
|
"author": {
|
|
21
21
|
"name": "Jean-Baptiste L.",
|
|
22
22
|
"email": "git@lemee.co"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
26
|
-
"url": "https://
|
|
26
|
+
"url": "https://github.com/jblemee/peertube-plugin-sponsorblock.git"
|
|
27
27
|
},
|
|
28
28
|
"library": "./main.js",
|
|
29
29
|
"staticDirs": {
|
package/server/ffmpeg.js
CHANGED
|
@@ -172,7 +172,12 @@ async function findVideoFiles(database, videoUuid, storagePath, logger) {
|
|
|
172
172
|
)
|
|
173
173
|
|
|
174
174
|
for (const row of (webVideoFiles || [])) {
|
|
175
|
-
const
|
|
175
|
+
const baseDir = path.resolve(storagePath, 'web-videos')
|
|
176
|
+
const filePath = path.resolve(baseDir, row.filename)
|
|
177
|
+
if (!filePath.startsWith(baseDir + path.sep)) {
|
|
178
|
+
logger.warn(`Path traversal blocked for web-video: ${row.filename}`)
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
176
181
|
if (await fileExists(filePath)) {
|
|
177
182
|
files.push({ type: 'web-video', path: filePath })
|
|
178
183
|
}
|
|
@@ -191,7 +196,12 @@ async function findVideoFiles(database, videoUuid, storagePath, logger) {
|
|
|
191
196
|
)
|
|
192
197
|
|
|
193
198
|
for (const row of (hlsFiles || [])) {
|
|
194
|
-
const
|
|
199
|
+
const baseDir = path.resolve(storagePath, 'streaming-playlists', 'hls', videoUuid)
|
|
200
|
+
const filePath = path.resolve(baseDir, row.filename)
|
|
201
|
+
if (!filePath.startsWith(baseDir + path.sep)) {
|
|
202
|
+
logger.warn(`Path traversal blocked for HLS file: ${row.filename}`)
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
195
205
|
if (await fileExists(filePath)) {
|
|
196
206
|
files.push({ type: 'hls', path: filePath })
|
|
197
207
|
}
|
package/server/routes.js
CHANGED
|
@@ -2,16 +2,74 @@
|
|
|
2
2
|
* Server-side API routes
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
6
|
+
|
|
7
|
+
function isValidUuid(value) {
|
|
8
|
+
return typeof value === 'string' && UUID_REGEX.test(value)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Simple in-memory token-bucket rate limiter
|
|
13
|
+
* @param {number} maxTokens - Maximum requests allowed in the window
|
|
14
|
+
* @param {number} windowMs - Time window in milliseconds
|
|
15
|
+
*/
|
|
16
|
+
function createRateLimiter(maxTokens, windowMs) {
|
|
17
|
+
const buckets = new Map()
|
|
18
|
+
|
|
19
|
+
// Periodically clean up expired entries to avoid memory leaks
|
|
20
|
+
setInterval(() => {
|
|
21
|
+
const now = Date.now()
|
|
22
|
+
for (const [key, bucket] of buckets) {
|
|
23
|
+
if (now - bucket.lastRefill > windowMs * 2) {
|
|
24
|
+
buckets.delete(key)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}, windowMs).unref()
|
|
28
|
+
|
|
29
|
+
return (req, res, next) => {
|
|
30
|
+
const ip = req.ip || req.connection.remoteAddress || 'unknown'
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
|
|
33
|
+
let bucket = buckets.get(ip)
|
|
34
|
+
if (!bucket) {
|
|
35
|
+
bucket = { tokens: maxTokens, lastRefill: now }
|
|
36
|
+
buckets.set(ip, bucket)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Refill tokens based on elapsed time
|
|
40
|
+
const elapsed = now - bucket.lastRefill
|
|
41
|
+
const refill = Math.floor((elapsed / windowMs) * maxTokens)
|
|
42
|
+
if (refill > 0) {
|
|
43
|
+
bucket.tokens = Math.min(maxTokens, bucket.tokens + refill)
|
|
44
|
+
bucket.lastRefill = now
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (bucket.tokens <= 0) {
|
|
48
|
+
return res.status(429).json({ error: 'Too many requests, please try again later' })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
bucket.tokens--
|
|
52
|
+
next()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
5
56
|
async function registerRoutes({ router, peertubeHelpers }) {
|
|
6
57
|
const logger = peertubeHelpers.logger
|
|
7
58
|
|
|
59
|
+
// Rate limiter: 60 requests per minute per IP for public endpoints
|
|
60
|
+
const segmentsRateLimiter = createRateLimiter(60, 60 * 1000)
|
|
61
|
+
|
|
8
62
|
/**
|
|
9
63
|
* GET /segments/:videoUuid
|
|
10
64
|
* Returns SponsorBlock segments for a given video
|
|
11
65
|
*/
|
|
12
|
-
router.get('/segments/:videoUuid', async (req, res) => {
|
|
66
|
+
router.get('/segments/:videoUuid', segmentsRateLimiter, async (req, res) => {
|
|
13
67
|
const videoUuid = req.params.videoUuid
|
|
14
68
|
|
|
69
|
+
if (!isValidUuid(videoUuid)) {
|
|
70
|
+
return res.status(400).json({ error: 'Invalid video UUID format' })
|
|
71
|
+
}
|
|
72
|
+
|
|
15
73
|
try {
|
|
16
74
|
const database = peertubeHelpers.database
|
|
17
75
|
|
|
@@ -66,7 +124,17 @@ async function registerRoutes({ router, peertubeHelpers }) {
|
|
|
66
124
|
router.get('/mapping/:videoUuid', async (req, res) => {
|
|
67
125
|
const videoUuid = req.params.videoUuid
|
|
68
126
|
|
|
127
|
+
if (!isValidUuid(videoUuid)) {
|
|
128
|
+
return res.status(400).json({ error: 'Invalid video UUID format' })
|
|
129
|
+
}
|
|
130
|
+
|
|
69
131
|
try {
|
|
132
|
+
// Auth check: admin/moderator only
|
|
133
|
+
const user = await peertubeHelpers.user.getAuthUser(res)
|
|
134
|
+
if (!user || (user.role !== 0 && user.role !== 1)) {
|
|
135
|
+
return res.status(403).json({ error: 'Admin or moderator access required' })
|
|
136
|
+
}
|
|
137
|
+
|
|
70
138
|
const database = peertubeHelpers.database
|
|
71
139
|
|
|
72
140
|
const [mappings] = await database.query(`
|
|
@@ -98,6 +166,10 @@ async function registerRoutes({ router, peertubeHelpers }) {
|
|
|
98
166
|
router.post('/mapping/:videoUuid', async (req, res) => {
|
|
99
167
|
const videoUuid = req.params.videoUuid
|
|
100
168
|
|
|
169
|
+
if (!isValidUuid(videoUuid)) {
|
|
170
|
+
return res.status(400).json({ error: 'Invalid video UUID format' })
|
|
171
|
+
}
|
|
172
|
+
|
|
101
173
|
try {
|
|
102
174
|
// Auth check: admin/moderator only
|
|
103
175
|
const user = await peertubeHelpers.user.getAuthUser(res)
|
|
@@ -221,6 +293,10 @@ async function registerRoutes({ router, peertubeHelpers }) {
|
|
|
221
293
|
router.post('/process/:videoUuid', async (req, res) => {
|
|
222
294
|
const videoUuid = req.params.videoUuid
|
|
223
295
|
|
|
296
|
+
if (!isValidUuid(videoUuid)) {
|
|
297
|
+
return res.status(400).json({ error: 'Invalid video UUID format' })
|
|
298
|
+
}
|
|
299
|
+
|
|
224
300
|
try {
|
|
225
301
|
// Auth check: admin/moderator only
|
|
226
302
|
const user = await peertubeHelpers.user.getAuthUser(res)
|
|
@@ -438,6 +514,10 @@ async function registerRoutes({ router, peertubeHelpers }) {
|
|
|
438
514
|
router.delete('/mapping/:videoUuid', async (req, res) => {
|
|
439
515
|
const videoUuid = req.params.videoUuid
|
|
440
516
|
|
|
517
|
+
if (!isValidUuid(videoUuid)) {
|
|
518
|
+
return res.status(400).json({ error: 'Invalid video UUID format' })
|
|
519
|
+
}
|
|
520
|
+
|
|
441
521
|
try {
|
|
442
522
|
const user = await peertubeHelpers.user.getAuthUser(res)
|
|
443
523
|
if (!user || user.role !== 0) {
|
|
@@ -544,7 +624,17 @@ async function registerRoutes({ router, peertubeHelpers }) {
|
|
|
544
624
|
router.post('/sync/:videoUuid', async (req, res) => {
|
|
545
625
|
const videoUuid = req.params.videoUuid
|
|
546
626
|
|
|
627
|
+
if (!isValidUuid(videoUuid)) {
|
|
628
|
+
return res.status(400).json({ error: 'Invalid video UUID format' })
|
|
629
|
+
}
|
|
630
|
+
|
|
547
631
|
try {
|
|
632
|
+
// Auth check: admin/moderator only
|
|
633
|
+
const user = await peertubeHelpers.user.getAuthUser(res)
|
|
634
|
+
if (!user || (user.role !== 0 && user.role !== 1)) {
|
|
635
|
+
return res.status(403).json({ error: 'Admin or moderator access required' })
|
|
636
|
+
}
|
|
637
|
+
|
|
548
638
|
const database = peertubeHelpers.database
|
|
549
639
|
|
|
550
640
|
// Get YouTube ID
|
|
@@ -573,29 +663,40 @@ async function registerRoutes({ router, peertubeHelpers }) {
|
|
|
573
663
|
|
|
574
664
|
const segments = await response.json()
|
|
575
665
|
|
|
576
|
-
// Delete old segments
|
|
577
|
-
await database.query(
|
|
578
|
-
DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1
|
|
579
|
-
`, { bind: [youtubeId] })
|
|
580
|
-
|
|
581
|
-
// Insert new segments
|
|
666
|
+
// Delete old and insert new segments in a transaction
|
|
667
|
+
await database.query('BEGIN')
|
|
582
668
|
let insertedCount = 0
|
|
583
|
-
|
|
669
|
+
try {
|
|
584
670
|
await database.query(`
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
671
|
+
DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1
|
|
672
|
+
`, { bind: [youtubeId] })
|
|
673
|
+
|
|
674
|
+
for (const segment of segments) {
|
|
675
|
+
if (!validateSegment(segment)) {
|
|
676
|
+
logger.warn(`Skipping invalid segment from API: ${JSON.stringify(segment).slice(0, 200)}`)
|
|
677
|
+
continue
|
|
678
|
+
}
|
|
679
|
+
await database.query(`
|
|
680
|
+
INSERT INTO plugin_sponsorblock_segments
|
|
681
|
+
(youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
|
|
682
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
683
|
+
ON CONFLICT (segment_uuid) DO NOTHING
|
|
684
|
+
`, { bind: [
|
|
685
|
+
youtubeId,
|
|
686
|
+
segment.UUID,
|
|
687
|
+
segment.segment[0],
|
|
688
|
+
segment.segment[1],
|
|
689
|
+
segment.category,
|
|
690
|
+
segment.actionType || 'skip',
|
|
691
|
+
segment.votes || 0
|
|
692
|
+
] })
|
|
693
|
+
insertedCount++
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
await database.query('COMMIT')
|
|
697
|
+
} catch (txError) {
|
|
698
|
+
await database.query('ROLLBACK')
|
|
699
|
+
throw txError
|
|
599
700
|
}
|
|
600
701
|
|
|
601
702
|
// Update last_sync timestamp
|
|
@@ -653,6 +754,23 @@ function extractYoutubeId(input) {
|
|
|
653
754
|
return null
|
|
654
755
|
}
|
|
655
756
|
|
|
757
|
+
/**
|
|
758
|
+
* Validate a segment from the SponsorBlock API response
|
|
759
|
+
* Returns true if the segment has valid shape, false otherwise
|
|
760
|
+
*/
|
|
761
|
+
function validateSegment(segment) {
|
|
762
|
+
if (!segment || typeof segment !== 'object') return false
|
|
763
|
+
if (typeof segment.UUID !== 'string' || segment.UUID.length === 0 || segment.UUID.length > 128) return false
|
|
764
|
+
if (!Array.isArray(segment.segment) || segment.segment.length !== 2) return false
|
|
765
|
+
const [start, end] = segment.segment
|
|
766
|
+
if (typeof start !== 'number' || typeof end !== 'number') return false
|
|
767
|
+
if (start < 0 || end < 0 || start >= end) return false
|
|
768
|
+
if (!isFinite(start) || !isFinite(end)) return false
|
|
769
|
+
if (typeof segment.category !== 'string' || segment.category.length === 0 || segment.category.length > 50) return false
|
|
770
|
+
if (segment.votes !== undefined && typeof segment.votes !== 'number') return false
|
|
771
|
+
return true
|
|
772
|
+
}
|
|
773
|
+
|
|
656
774
|
/**
|
|
657
775
|
* Fetch segments from SponsorBlock API and cache them in database
|
|
658
776
|
*/
|
|
@@ -671,39 +789,49 @@ async function fetchAndCacheSegments(database, youtubeId, logger) {
|
|
|
671
789
|
|
|
672
790
|
const apiSegments = await response.json()
|
|
673
791
|
|
|
674
|
-
// Delete old segments
|
|
675
|
-
await database.query(
|
|
676
|
-
|
|
677
|
-
`, { bind: [youtubeId] })
|
|
678
|
-
|
|
679
|
-
// Insert new segments
|
|
680
|
-
const segments = []
|
|
681
|
-
for (const segment of apiSegments) {
|
|
792
|
+
// Delete old and insert new segments in a transaction
|
|
793
|
+
await database.query('BEGIN')
|
|
794
|
+
try {
|
|
682
795
|
await database.query(`
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
796
|
+
DELETE FROM plugin_sponsorblock_segments WHERE youtube_id = $1
|
|
797
|
+
`, { bind: [youtubeId] })
|
|
798
|
+
|
|
799
|
+
const segments = []
|
|
800
|
+
for (const segment of apiSegments) {
|
|
801
|
+
if (!validateSegment(segment)) {
|
|
802
|
+
logger.warn(`Skipping invalid segment from API: ${JSON.stringify(segment).slice(0, 200)}`)
|
|
803
|
+
continue
|
|
804
|
+
}
|
|
805
|
+
await database.query(`
|
|
806
|
+
INSERT INTO plugin_sponsorblock_segments
|
|
807
|
+
(youtube_id, segment_uuid, start_time, end_time, category, action_type, votes)
|
|
808
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
809
|
+
ON CONFLICT (segment_uuid) DO NOTHING
|
|
810
|
+
`, { bind: [
|
|
811
|
+
youtubeId,
|
|
812
|
+
segment.UUID,
|
|
813
|
+
segment.segment[0],
|
|
814
|
+
segment.segment[1],
|
|
815
|
+
segment.category,
|
|
816
|
+
segment.actionType || 'skip',
|
|
817
|
+
segment.votes || 0
|
|
818
|
+
] })
|
|
819
|
+
segments.push({
|
|
820
|
+
segment_uuid: segment.UUID,
|
|
821
|
+
start_time: segment.segment[0],
|
|
822
|
+
end_time: segment.segment[1],
|
|
823
|
+
category: segment.category,
|
|
824
|
+
action_type: segment.actionType || 'skip',
|
|
825
|
+
votes: segment.votes || 0
|
|
826
|
+
})
|
|
827
|
+
}
|
|
705
828
|
|
|
706
|
-
|
|
829
|
+
await database.query('COMMIT')
|
|
830
|
+
return segments
|
|
831
|
+
} catch (txError) {
|
|
832
|
+
await database.query('ROLLBACK')
|
|
833
|
+
throw txError
|
|
834
|
+
}
|
|
707
835
|
}
|
|
708
836
|
|
|
709
837
|
module.exports = { registerRoutes }
|