diff options
| author | kj_sh604 <43.splash@gmail.com> | 2026-03-01 19:07:42 -0500 |
|---|---|---|
| committer | kj_sh604 <43.splash@gmail.com> | 2026-03-01 19:07:42 -0500 |
| commit | 41fa7fe9ed84c4b8989f622fb532722b7f39ad72 (patch) | |
| tree | c0cff2582ae6bfa4f06a699dc13e4210d76c318f | |
| parent | a181069363b19274f65e36e69b172e7063647c1e (diff) | |
refactor: src/
| -rw-r--r-- | src/font.php | 44 | ||||
| -rw-r--r-- | src/fonts.php | 49 | ||||
| -rw-r--r-- | src/index.php | 693 | ||||
| -rw-r--r-- | src/upload.php | 66 | ||||
| -rw-r--r-- | src/uploads/.htaccess | 2 |
5 files changed, 854 insertions, 0 deletions
diff --git a/src/font.php b/src/font.php new file mode 100644 index 0000000..de39569 --- /dev/null +++ b/src/font.php @@ -0,0 +1,44 @@ +<?php +/* font.php — serve font files from the server's font directories */ + +$encoded = $_GET['f'] ?? ''; +if (empty($encoded)) { + http_response_code(400); + exit('Missing parameter'); +} + +$file = base64_decode($encoded, true); +if ($file === false || !file_exists($file)) { + http_response_code(404); + exit('Font not found'); +} + +$real = realpath($file); +$allowed = ['/usr/share/fonts', '/usr/local/share/fonts']; +$ok = false; + +foreach ($allowed as $dir) { + if (str_starts_with($real, $dir)) { + $ok = true; + break; + } +} + +if (!$ok) { + http_response_code(403); + exit('Access denied'); +} + +$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); +$mime = match ($ext) { + 'ttf' => 'font/ttf', + 'otf' => 'font/otf', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + default => 'application/octet-stream', +}; + +header("Content-Type: $mime"); +header('Cache-Control: public, max-age=31536000, immutable'); +header('Content-Length: ' . filesize($file)); +readfile($file); diff --git a/src/fonts.php b/src/fonts.php new file mode 100644 index 0000000..1959f51 --- /dev/null +++ b/src/fonts.php @@ -0,0 +1,49 @@ +<?php +// fonts.php — LIST server-side fonts via fontconfig + +header('Content-Type: application/json'); +header('Cache-Control: public, max-age=3600'); + +$output = shell_exec('fc-list --format="%{family}|%{file}\n" 2>/dev/null'); +if (!$output) { + echo json_encode([]); + exit; +} + +$lines = array_filter(explode("\n", trim($output))); +$fonts = []; +$seen = []; + +foreach ($lines as $line) { + $parts = explode('|', $line, 2); + if (count($parts) < 2) continue; + + /* take first family name (some entries are comma-separated) */ + $families = explode(',', $parts[0]); + $family = trim($families[0]); + + if (empty($family) || isset($seen[$family])) continue; + + $file = trim($parts[1]); + if (!file_exists($file)) continue; + + $seen[$family] = true; + + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $format = match ($ext) { + 'ttf' => 'truetype', + 'otf' => 'opentype', + 'woff' => 'woff', + 'woff2' => 'woff2', + default => 'truetype', + }; + + $fonts[] = [ + 'family' => $family, + 'file' => base64_encode($file), + 'format' => $format, + ]; +} + +usort($fonts, fn($a, $b) => strcasecmp($a['family'], $b['family'])); +echo json_encode($fonts); diff --git a/src/index.php b/src/index.php new file mode 100644 index 0000000..062d80e --- /dev/null +++ b/src/index.php @@ -0,0 +1,693 @@ +<?php /* sent-web — index.php */ ?> +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>sent-web</title> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css"> + <style> :root { --sent-fg: #000000; --sent-bg: #ffffff; --sent-font: 'Noto Color Emoji', 'DejaVu Sans', sans-serif; } body { max-width: 960px; margin: 0 auto; padding: 1rem; } .subtitle { opacity: 0.6; font-size: 0.9em; margin-top: -0.8em; } /* ── controls ── */ #controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; padding: 0.75rem; border: 1px solid currentColor; border-radius: 4px; } #controls label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; } #controls input[type="color"] { width: 2rem; height: 2rem; padding: 0; border: 1px solid currentColor; cursor: pointer; background: none; } #controls select { max-width: 200px; } .upload-area { display: flex; align-items: center; gap: 0.5rem; } #upload-input { display: none; } #upload-status { font-size: 0.85rem; opacity: 0.7; } /* ── editor ── */ #input { width: 100%; min-height: 420px; font-family: monospace; font-size: 0.95rem; resize: vertical; tab-size: 4; } .btn-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; } .hint { font-size: 0.8rem; opacity: 0.5; margin-top: 0.25rem; } /* ── presentation overlay ── */ #presentation { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: flex-start; background: var(--sent-bg); color: var(--sent-fg); cursor: none; overflow: hidden; padding-left: 7.5%; } #presentation.active { display: flex; } #slide-content { text-align: left; white-space: pre-line; word-wrap: break-word; font-family: var(--sent-font); } #slide-content img { max-width: 85vw; max-height: 85vh; width: 85vw; height: 85vh; object-fit: contain; } </style> +</head> + +<body> + + <header> + <h1>sent-web</h1> + <p class="subtitle">suckless's sent tool ported to the very sucky web world</p> + </header> + + <main> + <section id="controls"> + <label> + fg + <input type="color" id="fg-color" value="#000000"> + </label> + <label> + bg + <input type="color" id="bg-color" value="#ffffff"> + </label> + <label> + font + <select id="font-select"> + <option value="">loading…</option> + </select> + </label> + <div class="upload-area"> + <button type="button" onclick="document.getElementById('upload-input').click()">upload image</button> + <input type="file" id="upload-input" accept="image/*"> + <span id="upload-status"></span> + </div> + </section> + + <textarea id="input"> +sent-web + +a port of suckless sent +for the very sucky web 🌎 + +# this is a comment and will not appear +# the next slide is blank slide since we escaped with \ + +\ + +why? +• PPTX sucks +• LATEX sucks +• PDF sucks +• but everything is in + the web now 😓 + +easy to use + +▸ one slide per paragraph +▸ lines starting with # are ignored +▸ image slide: @filename +▸ empty slide: just use \ + +navigate with: +← → ↑ ↓ h j k l +space, enter, backspace +or mouse clicks + +press escape to exit + +😀😁😂😃😄😅😆😇😈😉😊 +emoji just works™ + +thanks. +questions?</textarea> + + <div class="btn-row"> + <button type="button" id="btn-present" onclick="App.startPresentation()">present</button> + <button type="button" onclick="App.downloadSent()">download for local sent usage</button> + <button type="button" onclick="App.exportPDF()">export .pdf</button> + </div> + <p class="hint">F5 to present · Esc to exit · ← → h l j k space enter to navigate</p> + </main> + + <!-- presentation overlay --> + <div id="presentation"> + <div id="slide-content"></div> + </div> + + <script> + // sent-web — vanilla JS presentation engine + + const App = { + slides: [], + idx: 0, + presenting: false, + loadedFonts: new Set(), + + settings: { + fg: '#000000', + bg: '#ffffff', + fontFamily: '', + lineSpacing: 1.4, + usableWidth: 0.85, + usableHeight: 0.85, + }, + + init() { + this.restoreState(); + this.loadFonts(); + this.bindEvents(); + this.updateColors(); + }, + + restoreState() { + const saved = localStorage.getItem('sent-web-content'); + if (saved !== null) { + document.getElementById('input').value = saved; + } + + const raw = localStorage.getItem('sent-web-settings'); + if (raw) { + try { + const s = JSON.parse(raw); + if (s.fg) { + this.settings.fg = s.fg; + document.getElementById('fg-color').value = s.fg; + } + if (s.bg) { + this.settings.bg = s.bg; + document.getElementById('bg-color').value = s.bg; + } + if (s.fontFamily) this.settings.fontFamily = s.fontFamily; + } catch (_) {} + } + }, + + saveSettings() { + localStorage.setItem('sent-web-settings', JSON.stringify({ + fg: this.settings.fg, + bg: this.settings.bg, + fontFamily: this.settings.fontFamily, + })); + }, + + async loadFonts() { + try { + const res = await fetch('fonts.php'); + const data = await res.json(); + const sel = document.getElementById('font-select'); + sel.innerHTML = ''; + + // preload fallback fonts + const notoEmoji = data.find(f => f.family === 'Noto Color Emoji'); + const dejavu = data.find(f => f.family.startsWith('DejaVu Sans') && !f.family.includes('Mono')); + + if (notoEmoji) await this.loadFont(notoEmoji); + if (dejavu) await this.loadFont(dejavu); + + // populate dropdown + let selectedOpt = null; + data.forEach(f => { + const opt = document.createElement('option'); + opt.value = JSON.stringify(f); + opt.textContent = f.family; + sel.appendChild(opt); + + // restore saved selection or default to DejaVu Sans + if (this.settings.fontFamily && f.family === this.settings.fontFamily) { + selectedOpt = opt; + } else if (!this.settings.fontFamily && dejavu && f.family === dejavu.family) { + selectedOpt = opt; + } + }); + + if (selectedOpt) selectedOpt.selected = true; + this.onFontChange(); + } catch (e) { + console.error('Failed to load fonts:', e); + } + }, + + async loadFont(fontData) { + if (this.loadedFonts.has(fontData.family)) return; + + try { + const url = `font.php?f=${encodeURIComponent(fontData.file)}`; + const src = `local('${fontData.family}'), url(${url}) format('${fontData.format}')`; + const face = new FontFace(fontData.family, src, { + display: 'swap' + }); + const loaded = await face.load(); + document.fonts.add(loaded); + this.loadedFonts.add(fontData.family); + } catch (e) { + console.warn(`Could not load font "${fontData.family}":`, e); + } + }, + + async onFontChange() { + const sel = document.getElementById('font-select'); + if (!sel.value) return; + + const fontData = JSON.parse(sel.value); + this.settings.fontFamily = fontData.family; + await this.loadFont(fontData); + + const stack = `'${fontData.family}', 'Noto Color Emoji', 'DejaVu Sans', sans-serif`; + document.documentElement.style.setProperty('--sent-font', stack); + + this.saveSettings(); + if (this.presenting) this.renderSlide(); + }, + + bindEvents() { + document.getElementById('fg-color').addEventListener('input', () => this.updateColors()); + document.getElementById('bg-color').addEventListener('input', () => this.updateColors()); + document.getElementById('font-select').addEventListener('change', () => this.onFontChange()); + document.getElementById('upload-input').addEventListener('change', e => this.handleUpload(e)); + + document.getElementById('input').addEventListener('input', () => { + localStorage.setItem('sent-web-content', document.getElementById('input').value); + }); + + document.addEventListener('keydown', e => this.handleKeydown(e)); + + const pres = document.getElementById('presentation'); + pres.addEventListener('click', e => { + if (e.clientX < window.innerWidth / 2) this.navigate(-1); + else this.navigate(1); + }); + pres.addEventListener('wheel', e => { + e.preventDefault(); + this.navigate(e.deltaY > 0 ? 1 : -1); + }, { + passive: false + }); + + window.addEventListener('resize', () => { + if (this.presenting) this.renderSlide(); + }); + + // stop presentation whenever fullscreen is exited (covers browser- + // intercepted Escape that never reaches the keydown handler) + document.addEventListener('fullscreenchange', () => { + if (!document.fullscreenElement && this.presenting) { + this.stopPresentation(); + } + }); + }, + + updateColors() { + this.settings.fg = document.getElementById('fg-color').value; + this.settings.bg = document.getElementById('bg-color').value; + document.documentElement.style.setProperty('--sent-fg', this.settings.fg); + document.documentElement.style.setProperty('--sent-bg', this.settings.bg); + this.saveSettings(); + if (this.presenting) this.renderSlide(); + }, + + // sent format parser + parseSent(text) { + const slides = []; + const paragraphs = text.split(/\n{2,}/); + + for (const para of paragraphs) { + const rawLines = para.split('\n'); + const lines = []; + let img = null; + let firstContent = true; + + for (const raw of rawLines) { + if (raw.trim() === '' || raw.startsWith('#')) continue; + + let line = raw; + + if (firstContent && line.startsWith('@')) { + img = line.substring(1).trim(); + firstContent = false; + continue; + } + firstContent = false; + + // image slides ignore remaining text lines + if (img !== null) continue; + + // strip leading backslash (escape) + if (line.startsWith('\\')) { + line = line.substring(1); + } + + lines.push(line); + } + + if (img !== null || lines.length > 0) { + slides.push({ + lines, + img + }); + } + } + + return slides; + }, + + // presentation controls + startPresentation() { + const text = document.getElementById('input').value; + this.slides = this.parseSent(text); + + if (this.slides.length === 0) { + alert('No slides to present.'); + return; + } + + this.idx = 0; + this.presenting = true; + document.getElementById('presentation').classList.add('active'); + document.body.style.overflow = 'hidden'; + + const el = document.getElementById('presentation'); + if (el.requestFullscreen) el.requestFullscreen().catch(() => {}); + + this.renderSlide(); + }, + + stopPresentation() { + this.presenting = false; + document.getElementById('presentation').classList.remove('active'); + document.body.style.overflow = ''; + + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } + }, + + navigate(dir) { + if (!this.presenting) return; + const next = this.idx + dir; + if (next >= 0 && next < this.slides.length) { + this.idx = next; + this.renderSlide(); + } + }, + + // rendering engine + renderSlide() { + if (!this.presenting || this.slides.length === 0) return; + + const slide = this.slides[this.idx]; + const content = document.getElementById('slide-content'); + const pres = document.getElementById('presentation'); + + pres.style.backgroundColor = this.settings.bg; + pres.style.color = this.settings.fg; + + content.innerHTML = ''; + + if (slide.img) { + const img = document.createElement('img'); + if (slide.img.startsWith('http://') || slide.img.startsWith('https://')) { + img.src = slide.img; + } else { + img.src = 'uploads/' + slide.img; + } + img.alt = slide.img; + img.style.maxWidth = (this.settings.usableWidth * 100) + 'vw'; + img.style.maxHeight = (this.settings.usableHeight * 100) + 'vh'; + img.style.width = (this.settings.usableWidth * 100) + 'vw'; + img.style.height = (this.settings.usableHeight * 100) + 'vh'; + img.style.objectFit = 'contain'; + content.appendChild(img); + } else { + const fontSize = this.calcFontSize(slide.lines); + content.style.fontSize = fontSize + 'px'; + content.style.lineHeight = String(this.settings.lineSpacing); + + slide.lines.forEach((line, i) => { + if (i > 0) content.appendChild(document.createElement('br')); + content.appendChild(document.createTextNode(line)); + }); + } + + }, + + calcFontSize(lines) { + const maxW = window.innerWidth * this.settings.usableWidth; + const maxH = window.innerHeight * this.settings.usableHeight; + const font = getComputedStyle(document.documentElement) + .getPropertyValue('--sent-font').trim() || 'sans-serif'; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + let lo = 1, + hi = 500, + best = 1; + + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + ctx.font = `${mid}px ${font}`; + + let fits = true; + for (const line of lines) { + if (ctx.measureText(line).width > maxW) { + fits = false; + break; + } + } + + // height check: line-spacing × (n-1) + 1 base height + const totalH = mid * this.settings.lineSpacing * (lines.length - 1) + mid; + if (totalH > maxH) fits = false; + + if (fits) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return best; + }, + + // keyboard handler + + handleKeydown(e) { + // F5 to start presentation from editor + if (!this.presenting && e.key === 'F5') { + e.preventDefault(); + this.startPresentation(); + return; + } + + if (!this.presenting) return; + + switch (e.key) { + case 'Escape': + case 'q': + e.preventDefault(); + this.stopPresentation(); + break; + case 'ArrowRight': + case 'ArrowDown': + case ' ': + case 'Enter': + case 'l': + case 'j': + case 'n': + case 'PageDown': + e.preventDefault(); + this.navigate(1); + break; + case 'ArrowLeft': + case 'ArrowUp': + case 'Backspace': + case 'h': + case 'k': + case 'p': + case 'PageUp': + e.preventDefault(); + this.navigate(-1); + break; + } + }, + + // image upload + + async handleUpload(e) { + const file = e.target.files[0]; + if (!file) return; + + const status = document.getElementById('upload-status'); + status.textContent = 'uploading…'; + + const fd = new FormData(); + fd.append('image', file); + + try { + const res = await fetch('upload.php', { + method: 'POST', + body: fd + }); + const data = await res.json(); + + if (data.error) { + status.textContent = 'error: ' + data.error; + return; + } + + // insert @filename at cursor position + const ta = document.getElementById('input'); + const pos = ta.selectionStart; + const txt = ta.value; + const ins = `\n@${data.filename}\n`; + ta.value = txt.substring(0, pos) + ins + txt.substring(pos); + ta.selectionStart = ta.selectionEnd = pos + ins.length; + ta.focus(); + + localStorage.setItem('sent-web-content', ta.value); + status.textContent = `uploaded: ${data.filename}`; + setTimeout(() => { + status.textContent = ''; + }, 3000); + } catch (err) { + status.textContent = 'upload failed'; + console.error(err); + } + + e.target.value = ''; + }, + + // download .sent file for local usage (base64-encoded to preserve unicode and avoid filename issues) + downloadSent() { + const text = document.getElementById('input').value; + const blob = new Blob([text], { + type: 'text/plain' + }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'presentation.sent'; + a.click(); + URL.revokeObjectURL(a.href); + }, + + // download pdf export of the presentation from canvas + async exportPDF() { + const text = document.getElementById('input').value; + const slides = this.parseSent(text); + + if (slides.length === 0) { + alert('No slides to export.'); + return; + } + + const btn = document.querySelector('button[onclick="App.exportPDF()"]'); + if (btn) { + btn.textContent = 'generating pdf…'; + btn.disabled = true; + } + + try { + if (!window.jspdf) { + await new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js'; + s.onload = resolve; + s.onerror = () => reject(new Error('Failed to load jsPDF')); + document.head.appendChild(s); + }); + } + + // 1440p canvas per slide — rasterise via browser engine + // (handles fonts, unicode, emoji, images exactly as the live view does) + const W = 2560; + const H = 1440; + + const { + jsPDF + } = window.jspdf; + // px unit + hotfix keeps jsPDF from rescaling our pixel-perfect canvases + const pdf = new jsPDF({ + orientation: 'landscape', + unit: 'px', + format: [W, H], + hotfixes: ['px_scaling'], + }); + + for (let i = 0; i < slides.length; i++) { + const canvas = await this.renderSlideToCanvas(slides[i], W, H); + const imgData = canvas.toDataURL('image/jpeg', 0.93); + + if (i > 0) pdf.addPage([W, H], 'landscape'); + pdf.addImage(imgData, 'JPEG', 0, 0, W, H); + } + + const epoch = Math.floor(Date.now() / 1000); + const uid = crypto.randomUUID().replace(/-/g, '').slice(0, 8); + pdf.save(`sent-web${epoch}-${uid}.pdf`); + + } catch (err) { + console.error('PDF export failed:', err); + alert('PDF export failed: ' + err.message); + } finally { + if (btn) { + btn.textContent = 'export .pdf'; + btn.disabled = false; + } + } + }, + + // render one slide to an off-screen canvas, mirrors renderSlide() exactly + async renderSlideToCanvas(slide, W, H) { + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + + const usable = this.settings.usableWidth; // 0.85 + const maxW = W * usable; + const maxH = H * usable; + const marginX = (W - maxW) / 2; + + // resolve the same font stack the live view uses + const fontStack = getComputedStyle(document.documentElement) + .getPropertyValue('--sent-font').trim() || 'sans-serif'; + + // background + ctx.fillStyle = this.settings.bg; + ctx.fillRect(0, 0, W, H); + + if (slide.img) { + // image slide — draw the actual image + await new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + const ratio = img.naturalWidth / img.naturalHeight; + let dw = maxW, + dh = maxH; + if (dw / dh > ratio) dw = dh * ratio; + else dh = dw / ratio; + ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); + resolve(); + }; + img.onerror = resolve; // still produce a page even on failure + img.src = (slide.img.startsWith('http://') || slide.img.startsWith('https://')) ? + slide.img : + 'uploads/' + slide.img; + }); + } else { + // text slide — left-aligned, same sizing logic as live view + const fontSize = this.calcFontSizeCanvas(ctx, slide.lines, fontStack, maxW, maxH); + + ctx.font = `${fontSize}px ${fontStack}`; + ctx.fillStyle = this.settings.fg; + ctx.textBaseline = 'alphabetic'; + + const lineH = fontSize * this.settings.lineSpacing; + const totalH = lineH * (slide.lines.length - 1) + fontSize; + const startX = marginX; + const startY = (H - totalH) / 2 + fontSize; // first baseline + + slide.lines.forEach((line, i) => { + ctx.fillText(line, startX, startY + i * lineH); + }); + } + + return canvas; + }, + + // binary-search font size on a canvas context for given absolute dimensions + calcFontSizeCanvas(ctx, lines, fontStack, maxW, maxH) { + let lo = 1, + hi = 600, + best = 1; + + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + ctx.font = `${mid}px ${fontStack}`; + + let fits = true; + for (const line of lines) { + if (ctx.measureText(line).width > maxW) { + fits = false; + break; + } + } + + const totalH = mid * this.settings.lineSpacing * (lines.length - 1) + mid; + if (totalH > maxH) fits = false; + + if (fits) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return best; + }, + }; + + document.addEventListener('DOMContentLoaded', () => App.init()); + </script> +</body> +</html>
\ No newline at end of file diff --git a/src/upload.php b/src/upload.php new file mode 100644 index 0000000..62db139 --- /dev/null +++ b/src/upload.php @@ -0,0 +1,66 @@ +<?php +/* upload.php — handle image uploads */ + +header('Content-Type: application/json'); + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed']); + exit; +} + +if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) { + $code = $_FILES['image']['error'] ?? 'unknown'; + http_response_code(400); + echo json_encode(['error' => "Upload failed (code: $code)"]); + exit; +} + +$file = $_FILES['image']; +$allowed = [ + 'image/png', 'image/jpeg', 'image/gif', + 'image/webp', 'image/svg+xml', 'image/bmp', +]; + +$finfo = finfo_open(FILEINFO_MIME_TYPE); +$mime = finfo_file($finfo, $file['tmp_name']); +finfo_close($finfo); + +if (!in_array($mime, $allowed, true)) { + http_response_code(400); + echo json_encode(['error' => "Invalid file type: $mime"]); + exit; +} + +$ext = match ($mime) { + 'image/png' => 'png', + 'image/jpeg' => 'jpg', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/svg+xml' => 'svg', + 'image/bmp' => 'bmp', + default => 'bin', +}; + +/* generate safe filename */ +$basename = pathinfo($file['name'], PATHINFO_FILENAME); +$basename = preg_replace('/[^a-zA-Z0-9_-]/', '_', $basename); +$basename = substr($basename, 0, 64); +$filename = $basename . '_' . bin2hex(random_bytes(4)) . '.' . $ext; + +$uploadDir = __DIR__ . '/uploads'; +if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0755, true); +} + +$dest = $uploadDir . '/' . $filename; +if (!move_uploaded_file($file['tmp_name'], $dest)) { + http_response_code(500); + echo json_encode(['error' => 'Failed to save file']); + exit; +} + +echo json_encode([ + 'filename' => $filename, + 'url' => 'uploads/' . $filename, +]); diff --git a/src/uploads/.htaccess b/src/uploads/.htaccess new file mode 100644 index 0000000..2cf2ddc --- /dev/null +++ b/src/uploads/.htaccess @@ -0,0 +1,2 @@ +# prevent PHP execution in uploads directory +php_flag engine off |
