📦 deps(thirdparty): update snapshots
This commit is contained in:
+29
-18
@@ -126,6 +126,9 @@ var CURRENT_ID=null, YTID=null, INDEX=null;
|
||||
function onYouTubeIframeAPIReady(){player=new YT.Player('ytplayer',{events:{'onReady':function(){ready=true;if(pending!=null){doPlay(pending);pending=null;}}}});}
|
||||
function fmt(t){t=Math.floor(t);return String(Math.floor(t/60)).padStart(2,'0')+':'+String(t%60).padStart(2,'0');}
|
||||
function esc(s){var d=document.createElement('div');d.textContent=s==null?'':s;return d.innerHTML;}
|
||||
function clear(el){while(el.firstChild)el.removeChild(el.firstChild);}
|
||||
function node(tag,cls,text){var el=document.createElement(tag);if(cls)el.className=cls;if(text!=null)el.textContent=text;return el;}
|
||||
function safeUrl(url,fallback){try{var u=new URL(url||'',window.location.href);return /^https?:$/.test(u.protocol)?u.href:(fallback||'#');}catch(e){return fallback||'#';}}
|
||||
|
||||
/* ---------------- Router ---------------- */
|
||||
function route(){
|
||||
@@ -154,16 +157,19 @@ async function loadIndex(){
|
||||
var r=await fetch(API_URL); if(!r.ok) throw new Error('HTTP '+r.status);
|
||||
var d=await r.json();
|
||||
INDEX=(d.items||[]).filter(function(it){return it.youtube_id;});
|
||||
var g=document.getElementById('grid'); g.innerHTML='';
|
||||
if(!INDEX.length){g.innerHTML='<p style="color:#7a6f5d">No videos in the library yet.</p>';return;}
|
||||
var g=document.getElementById('grid'); clear(g);
|
||||
if(!INDEX.length){var empty=node('p','', 'No videos in the library yet.');empty.style.color='#7a6f5d';g.appendChild(empty);return;}
|
||||
INDEX.forEach(function(it){
|
||||
var slides=it.slides||[]; var thumb=(slides[0]&&slides[0].img)||'';
|
||||
var tags=(it.tags||[]).slice(0,3).map(function(t){return '<span class="tag">'+esc(t)+'</span>';}).join('');
|
||||
var a=document.createElement('a'); a.className='card'; a.href='#/'+encodeURIComponent(it.id);
|
||||
a.innerHTML='<div class="thumb">'+(thumb?'<img src="'+esc(thumb)+'" alt="">':'')+'<span class="play">▶</span><span class="badge">'+(it.slide_count||slides.length)+' slides</span></div>'
|
||||
+'<div class="body"><div class="ct">'+esc(it.title||it.id)+'</div>'
|
||||
+'<div class="cs">'+esc(it.speaker||'')+'</div>'
|
||||
+(tags?'<div class="tags">'+tags+'</div>':'')+'</div>';
|
||||
var thumbBox=node('div','thumb');
|
||||
if(thumb){var img=document.createElement('img');img.src=safeUrl(thumb,'');img.alt='';thumbBox.appendChild(img);}
|
||||
thumbBox.appendChild(node('span','play','▶'));
|
||||
thumbBox.appendChild(node('span','badge',(it.slide_count||slides.length)+' slides'));
|
||||
var body=node('div','body');body.appendChild(node('div','ct',it.title||it.id));body.appendChild(node('div','cs',it.speaker||''));
|
||||
var tagList=(it.tags||[]).slice(0,3);
|
||||
if(tagList.length){var tags=node('div','tags');tagList.forEach(function(t){tags.appendChild(node('span','tag',t));});body.appendChild(tags);}
|
||||
a.appendChild(thumbBox);a.appendChild(body);
|
||||
g.appendChild(a);
|
||||
});
|
||||
}catch(e){var el=document.getElementById('homeErr');el.style.display='block';el.textContent='Could not load the video library: '+e.message+'. Is the backend running?';}
|
||||
@@ -183,7 +189,8 @@ async function loadVideo(id){
|
||||
SLIDES=(m.slides||[]).slice().sort(function(a,b){return a.t-b.t;});
|
||||
document.title=m.title||'Video deep-dive';
|
||||
document.getElementById('title').textContent=m.title||'';
|
||||
document.getElementById('speaker').innerHTML=esc(m.speaker||'')+' · <a target="_blank" href="'+(m.source_url||'#')+'">watch on YouTube ↗</a>';
|
||||
var speaker=document.getElementById('speaker');clear(speaker);speaker.appendChild(document.createTextNode((m.speaker||'')+' · '));
|
||||
var watch=document.createElement('a');watch.target='_blank';watch.rel='noopener noreferrer';watch.href=safeUrl(m.source_url,'#');watch.textContent='watch on YouTube ↗';speaker.appendChild(watch);
|
||||
document.getElementById('deckcount').textContent=SLIDES.length+' slides · drag the divider ⋮⋮ to resize';
|
||||
document.getElementById('now-t').textContent='--:--';
|
||||
document.getElementById('now-tx').textContent='Click any slide to play the video from that point.';
|
||||
@@ -198,25 +205,29 @@ function parseTranscript(body){
|
||||
return out;
|
||||
}
|
||||
function renderDeck(){
|
||||
var deck=document.getElementById('deck');deck.innerHTML='';
|
||||
var deck=document.getElementById('deck');clear(deck);
|
||||
SLIDES.forEach(function(s,i){
|
||||
var d=document.createElement('div');d.className='slide';d.id='slide-'+i;d.dataset.t=s.t;
|
||||
d.innerHTML='<div class="slide-img"><img src="'+esc(s.img)+'" alt="'+esc(s.title)+'"><span class="play-badge">▶</span><span class="slide-t">'+esc(s.mmss||fmt(s.t))+'</span></div>'
|
||||
+'<div class="slide-meta"><h3>'+esc(s.title)+'</h3><button class="btn">▶ Play '+esc(s.mmss||fmt(s.t))+'</button></div>'
|
||||
+'<label class="note-lbl">Notes <span class="saved" id="saved-'+i+'"></span></label>'
|
||||
+'<textarea class="note-area" id="note-'+i+'"></textarea>';
|
||||
var imgBox=node('div','slide-img');
|
||||
var img=document.createElement('img');img.src=safeUrl(s.img,'');img.alt=s.title||'';imgBox.appendChild(img);
|
||||
imgBox.appendChild(node('span','play-badge','▶'));imgBox.appendChild(node('span','slide-t',s.mmss||fmt(s.t)));
|
||||
var meta=node('div','slide-meta');meta.appendChild(node('h3','',s.title||''));
|
||||
var playBtn=node('button','btn','▶ Play '+(s.mmss||fmt(s.t)));meta.appendChild(playBtn);
|
||||
var label=node('label','note-lbl','Notes ');var saved=node('span','saved');saved.id='saved-'+i;label.appendChild(saved);
|
||||
var note=document.createElement('textarea');note.className='note-area';note.id='note-'+i;
|
||||
d.appendChild(imgBox);d.appendChild(meta);d.appendChild(label);d.appendChild(note);
|
||||
deck.appendChild(d);
|
||||
d.querySelector('textarea').value=s.note||'';
|
||||
note.value=s.note||'';
|
||||
d.querySelector('.slide-img').onclick=function(){play(i);};
|
||||
d.querySelector('.btn').onclick=function(){play(i);};
|
||||
d.querySelector('textarea').addEventListener('input',function(){onNote(i,this.value);});
|
||||
playBtn.onclick=function(){play(i);};
|
||||
note.addEventListener('input',function(){onNote(i,this.value);});
|
||||
});
|
||||
}
|
||||
function renderTranscript(){
|
||||
var c=document.getElementById('transcript');c.innerHTML='';
|
||||
var c=document.getElementById('transcript');clear(c);
|
||||
SEGS.forEach(function(seg){
|
||||
var r=document.createElement('div');r.className='trow';r.dataset.t=seg.t;r.dataset.text=seg.text.toLowerCase();
|
||||
r.innerHTML='<span class="tt">'+fmt(seg.t)+'</span><span class="tx">'+esc(seg.text)+'</span>';
|
||||
r.appendChild(node('span','tt',fmt(seg.t)));r.appendChild(node('span','tx',seg.text));
|
||||
r.onclick=function(){seekOnly(seg.t);};c.appendChild(r);
|
||||
});
|
||||
}
|
||||
|
||||
+49
-8
@@ -33,7 +33,12 @@ API = "/api/video-deepdives"
|
||||
FM_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
|
||||
SAFE_SLUG_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
SAFE_MEDIA_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
SAFE_PATH_PART_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
SAFE_CTYPE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*(?:; charset=[A-Za-z0-9._-]+)?$")
|
||||
LOCAL_ORIGINS = {
|
||||
"http://127.0.0.1:8000": "http://127.0.0.1:8000",
|
||||
"http://localhost:8000": "http://localhost:8000",
|
||||
}
|
||||
|
||||
|
||||
def split_frontmatter(text):
|
||||
@@ -52,7 +57,13 @@ def dump_file(meta, body):
|
||||
|
||||
def library_path(lib, *parts):
|
||||
root = Path(lib).resolve()
|
||||
candidate = root.joinpath(*parts).resolve()
|
||||
candidate = root
|
||||
for part in parts:
|
||||
value = str(part)
|
||||
if not SAFE_PATH_PART_RE.fullmatch(value) or value in {".", ".."}:
|
||||
return None
|
||||
candidate = candidate / value
|
||||
candidate = candidate.resolve()
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError:
|
||||
@@ -60,14 +71,38 @@ def library_path(lib, *parts):
|
||||
return candidate
|
||||
|
||||
|
||||
def media_path(lib, filename):
|
||||
if not SAFE_MEDIA_RE.fullmatch(filename or ""):
|
||||
return None
|
||||
media_dir = library_path(lib, "_media")
|
||||
if not media_dir or not media_dir.is_dir():
|
||||
return None
|
||||
for path in media_dir.iterdir():
|
||||
if path.is_file() and path.name == filename:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def item_path(lib, slug):
|
||||
if not SAFE_SLUG_RE.fullmatch(slug or ""):
|
||||
return None
|
||||
target = slug + ".md"
|
||||
for path in Path(lib).resolve().iterdir():
|
||||
if path.is_file() and path.name == target:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def safe_content_type(ctype):
|
||||
return ctype if isinstance(ctype, str) and SAFE_CTYPE_RE.match(ctype) else "application/octet-stream"
|
||||
|
||||
|
||||
def safe_local_origin(origin):
|
||||
return LOCAL_ORIGINS.get(origin or "")
|
||||
|
||||
|
||||
def load_item(lib, slug):
|
||||
if not SAFE_SLUG_RE.match(slug):
|
||||
return None
|
||||
path = library_path(lib, slug + ".md")
|
||||
path = item_path(lib, slug)
|
||||
if not path or not path.is_file():
|
||||
return None
|
||||
meta, body = split_frontmatter(path.read_text(encoding="utf-8"))
|
||||
@@ -110,9 +145,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Video-Library-Token")
|
||||
origin = safe_local_origin(self.headers.get("Origin"))
|
||||
if origin:
|
||||
self.send_header("Access-Control-Allow-Origin", origin)
|
||||
self.send_header("Vary", "Origin")
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
@@ -134,9 +172,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
if path.startswith(API + "/_media/"):
|
||||
fn = posixpath.basename(path) # strip any traversal
|
||||
if not SAFE_MEDIA_RE.match(fn):
|
||||
if not SAFE_MEDIA_RE.fullmatch(fn):
|
||||
return self._send(400, {"error": "bad media name"})
|
||||
fp = library_path(self.lib, "_media", fn)
|
||||
fp = media_path(self.lib, fn)
|
||||
if not fp or not fp.is_file():
|
||||
return self._send(404, {"error": "no such media"})
|
||||
ctype = mimetypes.guess_type(str(fp))[0] or "application/octet-stream"
|
||||
@@ -186,9 +224,12 @@ def self_test():
|
||||
(root / "_media" / "video_1-slide-01.jpg").write_bytes(b"x")
|
||||
assert load_item(str(root), "video_1")
|
||||
assert load_item(str(root), "../secret") is None
|
||||
assert library_path(str(root), "_media", "../video_1.md") == root.resolve() / "video_1.md"
|
||||
assert library_path(str(root), "_media", "../video_1.md") is None
|
||||
assert safe_content_type("text/html; charset=utf-8") == "text/html; charset=utf-8"
|
||||
assert safe_content_type("text/html\r\nX-Bad: 1") == "application/octet-stream"
|
||||
assert safe_local_origin("http://localhost:8000") == LOCAL_ORIGINS["http://localhost:8000"]
|
||||
assert safe_local_origin("http://localhost:3000") is None
|
||||
assert safe_local_origin("http://localhost:8000\r\nX-Bad: 1") is None
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Reference in New Issue
Block a user