commit 7b515518562b08b4ab67658ae47a273980343ccc Author: nke4 Date: Fri Oct 23 18:54:25 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ce3761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +docker-compose.yml +Dockerfile +ecosystem.config.js +README.md +/data/*.json diff --git a/assets/main.js b/assets/main.js new file mode 100644 index 0000000..f5c1356 --- /dev/null +++ b/assets/main.js @@ -0,0 +1,9 @@ +// light follows the cursor across a card's glass +document.querySelectorAll('.shard').forEach(el=>{ + el.addEventListener('pointermove',e=>{ + const r=el.getBoundingClientRect(); + el.style.setProperty('--mx',(e.clientX-r.left)+'px'); + el.style.setProperty('--my',(e.clientY-r.top)+'px'); + }); + el.addEventListener('pointerleave',()=>{el.style.removeProperty('--mx');el.style.removeProperty('--my')}); +}); diff --git a/assets/shatter.svg b/assets/shatter.svg new file mode 100644 index 0000000..c1bf41f --- /dev/null +++ b/assets/shatter.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..88785c2 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,92 @@ +:root{ + --bg:#0a1119; + --glass:#121d29; + --edge:#27384a; + --ink:#dfe7ee; + --dim:#8497a9; + --ice:#8fd3ff; + --hot:#ff4f8b; + --display:"Bahnschrift","Oswald","Arial Narrow","Roboto Condensed",sans-serif; + --body:"Segoe UI Variable Text","Segoe UI","Helvetica Neue",Arial,sans-serif; + --mono:ui-monospace,"Cascadia Mono","JetBrains Mono",Consolas,monospace; +} +*{box-sizing:border-box;margin:0} +html{background:var(--bg);color-scheme:dark} +body{ + min-height:100vh;font:400 1rem/1.55 var(--body);color:var(--ink); + background: + linear-gradient(115deg,transparent 0 62%,rgba(143,211,255,.045) 62% 63%,transparent 63%), + url(/assets/shatter.svg) center/cover no-repeat fixed, + radial-gradient(120% 80% at 85% 20%,#13263a 0,var(--bg) 60%); +} +a{color:inherit;text-decoration:none} +:focus-visible{outline:2px solid var(--ice);outline-offset:3px} + +/* top bar */ +.bar{ + position:sticky;top:0;z-index:5;display:flex;align-items:stretch;gap:2px; + padding:0 clamp(1rem,4vw,3rem);height:3.4rem; + background:rgba(10,17,25,.78);backdrop-filter:blur(10px); + border-bottom:1px solid var(--edge); +} +.brand{ + display:flex;align-items:center;margin-right:auto; + font:700 1.35rem/1 var(--display);letter-spacing:.04em; +} +.brand i{font-style:normal;color:var(--hot);margin:0 .08em} +.bar nav{display:flex;gap:2px} +.bar nav a{ + display:flex;align-items:center;padding:0 1.5rem;color:var(--dim); + font:500 1rem var(--display);letter-spacing:.06em; + clip-path:polygon(14px 0,100% 0,calc(100% - 14px) 100%,0 100%); + transition:background .15s,color .15s; +} +.bar nav a:hover{color:var(--ink);background:rgba(143,211,255,.08)} +.bar nav a[aria-current]{color:var(--bg);background:var(--ice)} +@media(max-width:640px){.bar nav a{padding:0 .8rem}.brand{font-size:1.1rem}} + +/* page frame */ +main{max-width:1080px;margin:0 auto;padding:clamp(2rem,6vw,4.5rem) clamp(1rem,4vw,2rem) 6rem} +.title{font:700 clamp(2.6rem,7vw,4.6rem)/.95 var(--display);letter-spacing:.01em;margin-bottom:.9rem} +.lede{max-width:46ch;color:var(--dim)} + +/* home */ +.home{display:grid;place-items:center;min-height:calc(100vh - 3.4rem)} +.home p{font:400 .95rem var(--mono);color:var(--dim)} +.home p::after{content:"";display:inline-block;width:.6ch;height:1.1em;margin-left:.5ch;vertical-align:-.2em;background:var(--ice);animation:blink 1.1s steps(1) infinite} +@keyframes blink{50%{opacity:0}} + +/* services */ +.group{margin-top:3.5rem} +.group h2{font:600 1.5rem var(--display);letter-spacing:.03em;display:flex;align-items:baseline;gap:1rem;margin-bottom:1.3rem} +.group h2 small{font:400 .9rem var(--body);color:var(--dim);letter-spacing:0} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1.1rem} +@media(min-width:700px){.grid>:nth-child(3n+2){margin-top:1.4rem}.grid>:nth-child(3n){margin-top:.5rem}} + +.shard{ + --a:16px;--b:30px;--c:22px;--line:var(--edge);--glint:rgba(143,211,255,.22); + --cut:polygon(0 var(--a),var(--b) 0,100% 0,100% calc(100% - var(--c)),calc(100% - var(--b)) 100%,0 100%); + position:relative;display:flex;flex-direction:column;gap:.35rem;min-height:9.5rem; + padding:1.4rem 1.3rem 1.2rem;background:var(--line);clip-path:var(--cut); + transition:transform .18s ease,background .18s; +} +.shard::before{ + content:"";position:absolute;inset:1px;z-index:0;clip-path:var(--cut); + background: + radial-gradient(220px circle at var(--mx,-50%) var(--my,-50%),var(--glint),transparent 70%), + linear-gradient(160deg,rgba(255,255,255,.05),transparent 40%),var(--glass); +} +.shard>*{position:relative;z-index:1} +.shard:nth-child(3n+2){--a:26px;--b:14px;--c:34px} +.shard:nth-child(3n){--a:10px;--b:40px;--c:14px} +.shard:nth-child(5n){--a:34px;--b:22px;--c:18px} +.shard:hover,.shard:focus-visible{--line:var(--ice);transform:translate(-3px,-3px)} +.shard h3{font:600 1.45rem/1.1 var(--display);letter-spacing:.02em} +.shard p{color:var(--dim);font-size:.92rem;max-width:30ch} +.host{margin-top:auto;padding-top:.8rem;font:400 .8rem var(--mono);color:var(--ice)} +.admin{--glint:rgba(255,79,139,.22)} +.admin:hover,.admin:focus-visible{--line:var(--hot)} +.admin .host{color:var(--hot)} +.badge{align-self:flex-start;margin-bottom:.3rem;padding:.1rem .6rem .1rem .5rem;font:500 .78rem var(--body);color:var(--bg);background:var(--hot);clip-path:polygon(0 0,100% 0,calc(100% - 7px) 100%,0 100%)} +.heart{color:var(--hot)} +@media(prefers-reduced-motion:reduce){.shard{transition:none}.home p::after{animation:none}} diff --git a/config/admin-services.json b/config/admin-services.json new file mode 100644 index 0000000..9ddf164 --- /dev/null +++ b/config/admin-services.json @@ -0,0 +1,5 @@ +[ + { "name": "Glance", "desc": "Dashboard with feeds and status at a glance.", "url": "http://10.67.67.69:8405" }, + { "name": "Dozzle", "desc": "Live container logs.", "url": "http://10.67.67.69:8407" }, + { "name": "Scrutiny", "desc": "Drive health and SMART data.", "url": "http://10.67.67.69:8406" } +] diff --git a/index.html b/index.html new file mode 100644 index 0000000..f4474b6 --- /dev/null +++ b/index.html @@ -0,0 +1,21 @@ + + + + + +nkelabs + + + +
+ nke/labs + +
+

nothing here yet

+ + diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..b0a506b --- /dev/null +++ b/public/404.html @@ -0,0 +1,22 @@ + + + + + +Not found · nkelabs + + + +
+ nke/labs + +
+

404

That page doesn't exist. Pick something from the bar above.

+ + + diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..fbf27fb --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,27 @@ + + + + + +Admin · nkelabs + + + + +
+ nke/labs + +
+
+

Admin

+

Contact requests and admin tools.

+
+
+ + + diff --git a/public/assets/icons/discord.svg b/public/assets/icons/discord.svg new file mode 100644 index 0000000..9ddb183 --- /dev/null +++ b/public/assets/icons/discord.svg @@ -0,0 +1 @@ +Discord \ No newline at end of file diff --git a/public/assets/icons/gitea.svg b/public/assets/icons/gitea.svg new file mode 100644 index 0000000..194a6ce --- /dev/null +++ b/public/assets/icons/gitea.svg @@ -0,0 +1 @@ +Gitea \ No newline at end of file diff --git a/public/assets/icons/riotgames.svg b/public/assets/icons/riotgames.svg new file mode 100644 index 0000000..5daabec --- /dev/null +++ b/public/assets/icons/riotgames.svg @@ -0,0 +1 @@ +Riot Games \ No newline at end of file diff --git a/public/assets/icons/spotify.svg b/public/assets/icons/spotify.svg new file mode 100644 index 0000000..8cfd9ee --- /dev/null +++ b/public/assets/icons/spotify.svg @@ -0,0 +1 @@ +Spotify \ No newline at end of file diff --git a/public/assets/icons/steam.svg b/public/assets/icons/steam.svg new file mode 100644 index 0000000..8c98b1f --- /dev/null +++ b/public/assets/icons/steam.svg @@ -0,0 +1 @@ +Steam \ No newline at end of file diff --git a/public/assets/icons/twitch.svg b/public/assets/icons/twitch.svg new file mode 100644 index 0000000..36fe0a2 --- /dev/null +++ b/public/assets/icons/twitch.svg @@ -0,0 +1 @@ +Twitch \ No newline at end of file diff --git a/public/assets/main.js b/public/assets/main.js new file mode 100644 index 0000000..62527fc --- /dev/null +++ b/public/assets/main.js @@ -0,0 +1,133 @@ +const $ = (s, r = document) => r.querySelector(s); +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const safe = u => /^(https?:|mailto:)/i.test(u || '') ? esc(u) : ''; +const host = u => { try { return new URL(u).host } catch { return '' } }; + +// light follows the cursor across a card's glass +document.addEventListener('pointermove', e => { + const el = e.target.closest?.('.shard'); if (!el) return; + const r = el.getBoundingClientRect(); + el.style.setProperty('--mx', e.clientX - r.left + 'px'); + el.style.setProperty('--my', e.clientY - r.top + 'px'); +}); +document.addEventListener('pointerout', e => { + const el = e.target.closest?.('.shard'); + if (el && !el.contains(e.relatedTarget)) { el.style.removeProperty('--mx'); el.style.removeProperty('--my'); } +}); + +const load = src => fetch(src).then(r => r.json()).catch(() => []); +const empty = file => `

Nothing here yet. Add entries to public/data/${file}.json.

`; + +// services: filter + reachability dots + admin section when signed in +const q = $('#q'); +const watch = async card => { + const url = card.dataset.check, dot = document.createElement('span'); + dot.className = 'dot'; card.prepend(dot); + if (location.protocol === 'https:' && url.startsWith('http:')) { dot.title = 'Cannot check http services from an https page'; return; } + const ctl = new AbortController(), timer = setTimeout(() => ctl.abort(), 4000); + try { await fetch(url, { mode: 'no-cors', signal: ctl.signal, cache: 'no-store' }); dot.dataset.s = 'up'; dot.title = 'Reachable'; } + catch { dot.dataset.s = 'down'; dot.title = 'Not reachable from this browser'; } + clearTimeout(timer); +}; +if (q) { + document.addEventListener('keydown', e => { + if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement.tagName)) { e.preventDefault(); q.focus(); } + if (e.key === 'Escape' && document.activeElement === q) { q.value = ''; q.dispatchEvent(new Event('input')); q.blur(); } + }); + q.addEventListener('input', () => { + const t = q.value.trim().toLowerCase(); + document.querySelectorAll('.shard').forEach(c => c.hidden = !c.textContent.toLowerCase().includes(t)); + document.querySelectorAll('.group').forEach(g => g.hidden = !g.querySelector('.shard:not([hidden])')); + }); + document.querySelectorAll('[data-check]').forEach(watch); + fetch('/api/session').then(r => r.json()).then(async s => { + if (!s.admin) return; + const r = await fetch('/api/admin/services'); if (!r.ok) return; + const items = await r.json(); if (!items.length) return; + const sec = document.createElement('section'); sec.className = 'group'; + sec.innerHTML = `

Admin only signed in

` + items.map(i => ` + + Admin only

${esc(i.name)}

${esc(i.desc)}

${esc(host(i.url))}
`).join('') + '
'; + $('main').append(sec); sec.querySelectorAll('[data-check]').forEach(watch); + if (q.value) q.dispatchEvent(new Event('input')); + }).catch(() => {}); +} + +// socials: rendered from JSON +const list = $('#list[data-src]'); +if (list) load(list.dataset.src).then(items => { + if (!items.length) { list.innerHTML = empty('socials'); return; } + list.innerHTML = items.map(s => { + const link = safe(s.url), t = link ? 'a' : 'button', c = esc(s.color || ''); + const attrs = link ? ` href="${link}" target="_blank" rel="noopener"` : ` type="button" data-copy="${esc(s.copy || s.handle)}"`; + return `<${t} class="shard social"${attrs} style="--brand:${c}"> + +

${esc(s.name)}

${esc(s.handle)}

+ ${link ? esc(host(s.url)) : 'Click to copy'}`; + }).join(''); + list.addEventListener('click', e => { + const b = e.target.closest('[data-copy]'); if (!b) return; + const h = $('.host', b), old = h.textContent; + navigator.clipboard?.writeText(b.dataset.copy).then(() => { h.textContent = 'Copied'; setTimeout(() => h.textContent = old, 1500); }); + }); +}); + +// contact: info list with copy buttons + form +const info = $('#info'); +if (info) load(info.dataset.src).then(items => { + info.innerHTML = items.map(i => `
  • ${esc(i.label)}${safe(i.href) ? `${esc(i.value)}` : esc(i.value)} +
  • `).join(''); + info.addEventListener('click', e => { + const b = e.target.closest('[data-copy]'); if (!b) return; + navigator.clipboard?.writeText(b.dataset.copy).then(() => { b.textContent = 'Copied'; setTimeout(() => b.textContent = 'Copy', 1400); }); + }); +}); +const form = $('#form'); +if (form) form.addEventListener('submit', async e => { + e.preventDefault(); + const out = $('#out'), btn = $('.btn', form); out.removeAttribute('data-ok'); + if (!form.checkValidity()) { out.textContent = 'Fill in name, a valid email and a message of at least 5 characters.'; return; } + btn.disabled = true; out.textContent = 'Sending...'; + try { + const r = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.fromEntries(new FormData(form))) }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || 'Could not send. Try again later.'); + form.reset(); out.dataset.ok = 1; out.textContent = 'Message sent.'; + } catch (err) { out.textContent = err.message; } + btn.disabled = false; +}); + +// admin: sign in + contact requests +const adm = $('#admin'); +if (adm) { + const post = (u, d) => fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d || {}) }); + const login = enabled => { + adm.innerHTML = enabled === false + ? '

    Admin is disabled. Set the ADMIN_PASSWORD environment variable and restart the server.

    ' + : `
    +

    `; + $('#lf')?.addEventListener('submit', async e => { + e.preventDefault(); const out = $('#out'); out.textContent = ''; + const r = await post('/api/login', { password: e.target.password.value }); + if (r.ok) return show(); + out.textContent = (await r.json().catch(() => ({}))).error || 'Sign in failed.'; + }); + }; + const show = async () => { + const s = await fetch('/api/session').then(r => r.json()).catch(() => ({})); + if (!s.admin) return login(s.enabled); + const r = await fetch('/api/admin/messages'), msgs = r.ok ? await r.json() : []; + adm.innerHTML = `

    ${msgs.length} message${msgs.length === 1 ? '' : 's'}

    ` + + (msgs.length ? msgs.map(m => `
    +
    ${esc(m.name)}${esc(m.email)}
    +

    ${esc(m.message)}

    +
    Reply
    +
    `).join('') : '

    No messages yet.

    '); + }; + adm.addEventListener('click', async e => { + if (e.target.closest('#lo')) { await post('/api/logout'); return login(true); } + const d = e.target.closest('[data-del]'); if (!d || !confirm('Delete this message?')) return; + await fetch('/api/admin/messages/' + encodeURIComponent(d.closest('.msg').dataset.id), { method: 'DELETE' }); show(); + }); + show(); +} diff --git a/public/assets/shatter.svg b/public/assets/shatter.svg new file mode 100644 index 0000000..c1bf41f --- /dev/null +++ b/public/assets/shatter.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/style.css b/public/assets/style.css new file mode 100644 index 0000000..1babb96 --- /dev/null +++ b/public/assets/style.css @@ -0,0 +1,164 @@ +:root{ + --bg:#0a1119; + --glass:#121d29; + --edge:#27384a; + --ink:#dfe7ee; + --dim:#8497a9; + --ice:#8fd3ff; + --hot:#ff4f8b; + --display:"Bahnschrift","Oswald","Arial Narrow","Roboto Condensed",sans-serif; + --body:"Segoe UI Variable Text","Segoe UI","Helvetica Neue",Arial,sans-serif; + --mono:ui-monospace,"Cascadia Mono","JetBrains Mono",Consolas,monospace; +} +*{box-sizing:border-box;margin:0} +html{background:var(--bg);color-scheme:dark} +body{ + min-height:100vh;font:400 1rem/1.55 var(--body);color:var(--ink); + background: + linear-gradient(115deg,transparent 0 62%,rgba(143,211,255,.045) 62% 63%,transparent 63%), + url(/assets/shatter.svg) center/cover no-repeat fixed, + radial-gradient(120% 80% at 85% 20%,#13263a 0,var(--bg) 60%); +} +a{color:inherit;text-decoration:none} +:focus-visible{outline:2px solid var(--ice);outline-offset:3px} + +/* top bar */ +.bar{ + position:sticky;top:0;z-index:5;display:flex;align-items:stretch;gap:2px; + padding:0 clamp(1rem,4vw,3rem);height:3.4rem; + background:rgba(10,17,25,.78);backdrop-filter:blur(10px); + border-bottom:1px solid var(--edge); +} +.brand{ + display:flex;align-items:center;margin-right:auto; + font:700 1.35rem/1 var(--display);letter-spacing:.04em; +} +.brand i{font-style:normal;color:var(--hot);margin:0 .08em} +.bar nav{display:flex;gap:2px} +.bar nav a{ + display:flex;align-items:center;padding:0 1.5rem;color:var(--dim); + font:500 1rem var(--display);letter-spacing:.06em; + clip-path:polygon(14px 0,100% 0,calc(100% - 14px) 100%,0 100%); + transition:background .15s,color .15s; +} +.bar nav a:hover{color:var(--ink);background:rgba(143,211,255,.08)} +.bar nav a[aria-current]{color:var(--bg);background:var(--ice)} +@media(max-width:640px){.bar nav a{padding:0 .8rem}.brand{font-size:1.1rem}} + +/* page frame */ +main{max-width:1080px;margin:0 auto;padding:clamp(2rem,6vw,4.5rem) clamp(1rem,4vw,2rem) 6rem} +.title{font:700 clamp(2.6rem,7vw,4.6rem)/.95 var(--display);letter-spacing:.01em;margin-bottom:.9rem} +.lede{max-width:46ch;color:var(--dim)} + +/* home */ +.home{display:flex;flex-direction:column;align-items:center;min-height:calc(100vh - 3.4rem)} +.home p{margin:auto 0;font:400 .95rem var(--mono);color:var(--dim)} +.logo{display:flex;align-items:baseline;font:700 clamp(3.5rem,13vw,8.5rem)/1 var(--display);letter-spacing:.04em} +.logo span{ + background:linear-gradient(100deg,var(--ink) 42%,#fff 50%,var(--ink) 58%) 120% 0/300% 100%; + -webkit-background-clip:text;background-clip:text;color:transparent; + animation:split .8s .3s cubic-bezier(.2,.7,.2,1) both,glint 6s 1.4s ease-in-out infinite; +} +.logo .r{--dir:-1;animation-delay:.3s,1.6s} +.logo i{ + display:inline-block;font-style:normal;color:var(--hot);margin:0 .08em; + animation:strike .55s cubic-bezier(.2,.7,.2,1) both,pulse 3.2s 1.2s ease-in-out infinite; +} +@keyframes split{from{opacity:0;transform:translateX(calc(.35em * var(--dir,1)))}} +@keyframes glint{0%,70%{background-position:120% 0}100%{background-position:-20% 0}} +@keyframes strike{from{opacity:0;transform:translate(.15em,-.7em) skewX(-18deg) scaleY(1.8)}} +@keyframes pulse{50%{text-shadow:0 0 .3em rgba(255,79,139,.65)}} +.home p::after{content:"";display:inline-block;width:.6ch;height:1.1em;margin-left:.5ch;vertical-align:-.2em;background:var(--ice);animation:blink 1.1s steps(1) infinite} +@keyframes blink{50%{opacity:0}} + +/* services */ +.group{margin-top:3.5rem} +.group h2{font:600 1.5rem var(--display);letter-spacing:.03em;display:flex;align-items:baseline;gap:1rem;margin-bottom:1.3rem} +.group h2 small{font:400 .9rem var(--body);color:var(--dim);letter-spacing:0} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1.1rem} +@media(min-width:700px){.grid>:nth-child(3n+2){margin-top:1.4rem}.grid>:nth-child(3n){margin-top:.5rem}} + +.shard{ + --a:16px;--b:30px;--c:22px;--line:var(--edge);--glint:rgba(143,211,255,.22); + --cut:polygon(0 var(--a),var(--b) 0,100% 0,100% calc(100% - var(--c)),calc(100% - var(--b)) 100%,0 100%); + position:relative;display:flex;flex-direction:column;gap:.35rem;min-height:9.5rem; + padding:1.4rem 1.3rem 1.2rem;background:var(--line);clip-path:var(--cut); + transition:transform .18s ease,background .18s; +} +.shard::before{ + content:"";position:absolute;inset:1px;z-index:0;clip-path:var(--cut); + background: + radial-gradient(220px circle at var(--mx,-50%) var(--my,-50%),var(--glint),transparent 70%), + linear-gradient(160deg,rgba(255,255,255,.05),transparent 40%),var(--glass); +} +.shard>*{position:relative;z-index:1} +.shard:nth-child(3n+2){--a:26px;--b:14px;--c:34px} +.shard:nth-child(3n){--a:10px;--b:40px;--c:14px} +.shard:nth-child(5n){--a:34px;--b:22px;--c:18px} +.shard:hover,.shard:focus-visible{--line:var(--ice);transform:translate(-3px,-3px)} +.shard h3{font:600 1.45rem/1.1 var(--display);letter-spacing:.02em} +.shard p{color:var(--dim);font-size:.92rem;max-width:30ch} +.host{margin-top:auto;padding-top:.8rem;font:400 .8rem var(--mono);color:var(--ice)} +.admin{--glint:rgba(255,79,139,.22)} +.admin:hover,.admin:focus-visible{--line:var(--hot)} +.admin .host{color:var(--hot)} +.badge{align-self:flex-start;margin-bottom:.3rem;padding:.1rem .6rem .1rem .5rem;font:500 .78rem var(--body);color:var(--bg);background:var(--hot);clip-path:polygon(0 0,100% 0,calc(100% - 7px) 100%,0 100%)} +.heart{color:var(--hot)} +@media(prefers-reduced-motion:reduce){.shard{transition:none}.home p::after,.logo span,.logo i{animation:none}} + +/* tools, status, chips */ +[hidden]{display:none!important} +.tools{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;margin-top:2rem} +input,textarea{font:inherit;color:var(--ink);background:var(--glass);border:1px solid var(--edge);border-radius:0;padding:.65rem .8rem;width:100%} +input:focus,textarea:focus{outline:none;border-color:var(--ice);box-shadow:-4px 0 0 var(--ice)} +.tools input{max-width:20rem} +.hint{color:var(--dim);font-size:.85rem} +kbd{font:.8rem var(--mono);border:1px solid var(--edge);padding:0 .35rem} +.dot{position:absolute;top:1.1rem;right:1.7rem;z-index:2;width:9px;height:9px;rotate:45deg;background:var(--dim);opacity:.6} +.dot[data-s=up]{background:#7dffc2;box-shadow:0 0 8px #7dffc2;opacity:1} +.dot[data-s=down]{background:transparent;border:1px solid var(--dim)} +.tags{display:flex;flex-wrap:wrap;gap:.4rem} +.chip{font:.8rem var(--body);padding:.15rem .8rem .15rem .7rem;color:var(--ink);background:var(--edge);border:0;clip-path:polygon(0 0,100% 0,calc(100% - 8px) 100%,0 100%)} +button.chip{cursor:pointer} +.chip[aria-pressed=true]{background:var(--ice);color:var(--bg)} +.shard .tags{margin-top:auto;padding-top:.8rem} +.empty{grid-column:1/-1;max-width:48ch;color:var(--dim)} +.empty code{font:.85rem var(--mono);color:var(--ice)} + +/* contact */ +.split{display:grid;grid-template-columns:1fr 1.4fr;gap:3rem;margin-top:2.5rem} +@media(max-width:800px){.split{grid-template-columns:1fr}} +.info{list-style:none;padding:0;display:grid;gap:.9rem;align-content:start} +.info li{display:flex;gap:1rem;align-items:baseline;padding-bottom:.7rem;border-bottom:1px solid var(--edge)} +.info li>span{min-width:5rem;color:var(--dim)} +.info button{margin-left:auto} +.form{display:grid;gap:1rem} +label{display:grid;gap:.35rem;font-size:.9rem;color:var(--dim)} +textarea{min-height:10rem;resize:vertical} +.hp{position:absolute;left:-9999px;width:1px} +.btn{justify-self:start;padding:.75rem 2.2rem .75rem 1.6rem;border:0;cursor:pointer;color:var(--bg);background:var(--ice);font:600 1.05rem var(--display);letter-spacing:.04em;clip-path:polygon(0 0,100% 0,calc(100% - 16px) 100%,0 100%);transition:background .15s} +.btn:hover{background:#fff}.btn:disabled{opacity:.5;cursor:wait} +#out{min-height:1.5em;color:var(--hot)}#out[data-ok]{color:#7dffc2} + +/* socials */ +.social{--brand:var(--ice);--glint:color-mix(in srgb,var(--brand) 28%,transparent);min-height:11.5rem;text-align:left;font:inherit;color:inherit;border:0;cursor:pointer;align-items:flex-start} +.social:hover,.social:focus-visible{--line:var(--brand)} +.social .ico{width:2.6rem;height:2.6rem;margin-bottom:.7rem;background:var(--brand);-webkit-mask:var(--i) center/contain no-repeat;mask:var(--i) center/contain no-repeat;transition:transform .2s} +.social:hover .ico{transform:rotate(-6deg) scale(1.08)} +.social .host{color:var(--dim)} +.social:hover .host{color:var(--brand)} +@media(prefers-reduced-motion:reduce){.social .ico{transition:none}} + +/* admin */ +.bar nav a.admin-link{margin-left:1rem} +#admin{margin-top:2.5rem} +.login{max-width:22rem} +.adminbar{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:1.2rem} +.adminbar h2{font:600 1.5rem var(--display);letter-spacing:.03em} +.msg{display:grid;gap:.6rem;margin-bottom:1rem;padding:1.1rem 1.3rem;background:var(--glass);border-left:3px solid var(--ice);clip-path:polygon(0 0,100% 0,100% calc(100% - 16px),calc(100% - 16px) 100%,0 100%)} +.msg header{display:flex;flex-wrap:wrap;gap:.3rem 1.1rem;align-items:baseline} +.msg header a{color:var(--ice)} +.msg time{margin-left:auto;color:var(--dim);font-size:.85rem} +.msg p{white-space:pre-wrap;overflow-wrap:anywhere;max-width:70ch} +.msg .tags{margin:0} +.msg .chip{display:inline-block;text-decoration:none} diff --git a/public/contact/index.html b/public/contact/index.html new file mode 100644 index 0000000..9119ed9 --- /dev/null +++ b/public/contact/index.html @@ -0,0 +1,36 @@ + + + + + +Contact · nkelabs + + + +
    + nke/labs + +
    +
    +

    Contact

    +

    Because everyone has one of these lol

    +
    +
      +
      + + + + + +

      +
      +
      +
      + + + diff --git a/public/data/contact.json b/public/data/contact.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/public/data/contact.json @@ -0,0 +1 @@ +[] diff --git a/public/data/projects.json b/public/data/projects.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/public/data/projects.json @@ -0,0 +1 @@ +[] diff --git a/public/data/socials.json b/public/data/socials.json new file mode 100644 index 0000000..69bfd38 --- /dev/null +++ b/public/data/socials.json @@ -0,0 +1,8 @@ +[ + { "name": "Spotify", "handle": "Artist page", "url": "https://open.spotify.com/artist/6KrMvckCU666WvwZCOvn19", "icon": "spotify", "color": "#1ed760" }, + { "name": "Twitch", "handle": "nke4", "url": "https://twitch.tv/nke4/", "icon": "twitch", "color": "#9146ff" }, + { "name": "Steam", "handle": "nKe4", "url": "https://steamcommunity.com/id/nKe4/", "icon": "steam", "color": "#66c0f4" }, + { "name": "Gitea", "handle": "nke4", "url": "https://gitea.nkelabs.xyz/nke4/", "icon": "gitea", "color": "#7cc13a" }, + { "name": "Discord", "handle": "@pannick_dj", "copy": "pannick_dj", "icon": "discord", "color": "#7b86ff" }, + { "name": "Riot Games", "handle": "6ft4nohoes#1337", "copy": "6ft4nohoes#1337", "icon": "riotgames", "color": "#ff3b57" } +] diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4f881e8 --- /dev/null +++ b/public/index.html @@ -0,0 +1,25 @@ + + + + + +nkelabs + + + +
      + nke/labs + +
      +
      +

      nke/labs

      +

      nothing here yet

      +
      + + + diff --git a/public/projects/index.html b/public/projects/index.html new file mode 100644 index 0000000..e1f0cc3 --- /dev/null +++ b/public/projects/index.html @@ -0,0 +1,27 @@ + + + + + +Projects · nkelabs + + + +
      + nke/labs + +
      +
      +

      Projects

      +

      +
      +
      +
      + + + diff --git a/public/services/index.html b/public/services/index.html new file mode 100644 index 0000000..9b5236a --- /dev/null +++ b/public/services/index.html @@ -0,0 +1,61 @@ + + + + + +Services · nkelabs + + + +
      + nke/labs + +
      +
      +

      Services

      +

      Everything running in the lab. Admin tools appear here when you are signed in.

      +
      + +

      Press / to search. Dots show whether this browser can reach each service.

      +
      + +
      +

      Open to everyone

      + +
      + +
      + + + diff --git a/public/socials/index.html b/public/socials/index.html new file mode 100644 index 0000000..bed95b6 --- /dev/null +++ b/public/socials/index.html @@ -0,0 +1,26 @@ + + + + + +Socials · nkelabs + + + +
      + nke/labs + +
      +
      +

      Socials

      +

      Where to find me. Discord and Riot IDs copy to your clipboard.

      +
      +
      + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..2f88426 --- /dev/null +++ b/server.js @@ -0,0 +1,108 @@ +// Zero-dependency static server + contact endpoint + admin area. Needs Node 18+. +const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto'); +const PORT = process.env.PORT || 8080; +const PUBLIC = path.join(__dirname, 'public'); +const STORE = path.join(__dirname, 'storage', 'messages.jsonl'); +const ADMIN_SERVICES = path.join(__dirname, 'config', 'admin-services.json'); +const WEBHOOK = process.env.WEBHOOK_URL || ''; // Discord webhook or ntfy topic URL +const BEHIND_PROXY = process.env.TRUST_PROXY === '1'; // trust X-Forwarded-* headers +const ADMIN_PW = process.env.ADMIN_PASSWORD || ''; +const SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'); // set it to survive restarts +if (!ADMIN_PW) console.warn('ADMIN_PASSWORD is not set: the admin area is disabled.'); + +const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css', '.js':'text/javascript', '.json':'application/json', + '.svg':'image/svg+xml', '.png':'image/png', '.jpg':'image/jpeg', '.webp':'image/webp', '.ico':'image/x-icon', '.woff2':'font/woff2' }; + +// --- helpers +const send = (res, code, body, type = 'application/json', extra = {}) => { + res.writeHead(code, { 'Content-Type': type, 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', ...extra }); res.end(body); +}; +const json = (res, code, obj, extra = {}) => send(res, code, JSON.stringify(obj), 'application/json', { 'Cache-Control': 'no-store', ...extra }); +const readJson = req => new Promise((ok, no) => { + let r = ''; req.on('data', c => { r += c; if (r.length > 10000) { req.destroy(); no(new Error('too large')); } }); + req.on('end', () => { try { ok(JSON.parse(r || '{}')); } catch { ok(null); } }); req.on('error', no); +}); +const clientIp = req => (BEHIND_PROXY && req.headers['x-forwarded-for']?.split(',')[0].trim()) || req.socket.remoteAddress; +const recent = (map, ip, win) => { const n = Date.now(), a = (map.get(ip) || []).filter(t => n - t < win); map.set(ip, a); return a; }; +const push = (map, ip) => map.get(ip).push(Date.now()); + +// --- sessions: signed, expiring cookie +const sha = s => crypto.createHash('sha256').update(s).digest(); +const sign = v => crypto.createHmac('sha256', SECRET).update(v).digest('hex'); +const makeToken = () => { const exp = Date.now() + 7 * 864e5; return exp + '.' + sign(String(exp)); }; +const validToken = t => { + const [exp, sig] = (t || '').split('.'); if (!exp || !sig || Date.now() > +exp) return false; + const good = sign(exp); return sig.length === good.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(good)); +}; +const cookie = (req, name) => (req.headers.cookie || '').split(';').map(c => c.trim()).find(c => c.startsWith(name + '='))?.slice(name.length + 1); +const isAdmin = req => !!ADMIN_PW && validToken(cookie(req, 'nke_admin')); +const setCookie = (req, val, age) => 'nke_admin=' + val + `; HttpOnly; SameSite=Strict; Path=/; Max-Age=${age}` + + (BEHIND_PROXY && req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : ''); + +// --- messages +const readMsgs = () => { try { return fs.readFileSync(STORE, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); } catch { return []; } }; +const writeMsgs = list => fs.writeFileSync(STORE, list.map(m => JSON.stringify(m)).join('\n') + (list.length ? '\n' : '')); + +async function notify(m) { + if (!WEBHOOK) return; + const text = `New message from ${m.name} <${m.email}>\n\n${m.message}`; + try { + if (/discord(app)?\.com/.test(WEBHOOK)) await fetch(WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: text.slice(0, 1900) }) }); + else await fetch(WEBHOOK, { method: 'POST', headers: { Title: 'nkelabs contact form' }, body: text }); + } catch (e) { console.error('webhook failed:', e.message); } +} + +const contactHits = new Map(), loginFails = new Map(); + +async function api(req, res, url) { + const p = url.pathname, ip = clientIp(req); + + if (p === '/api/contact' && req.method === 'POST') { + const d = await readJson(req); if (!d) return json(res, 400, { error: 'Invalid request.' }); + if (d.website) return json(res, 200, { ok: true }); // honeypot + if (recent(contactHits, ip, 36e5).length >= 5) return json(res, 429, { error: 'Too many messages. Try again in an hour.' }); + const m = { name: String(d.name || '').trim().slice(0, 80), email: String(d.email || '').trim().slice(0, 120), message: String(d.message || '').trim().slice(0, 4000) }; + if (!m.name || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(m.email) || m.message.length < 5) return json(res, 400, { error: 'Check your name, email and message.' }); + push(contactHits, ip); + fs.mkdirSync(path.dirname(STORE), { recursive: true }); + fs.appendFileSync(STORE, JSON.stringify({ id: crypto.randomUUID(), ...m, ip, at: new Date().toISOString() }) + '\n'); + notify(m); return json(res, 200, { ok: true }); + } + + if (p === '/api/session' && req.method === 'GET') return json(res, 200, { admin: isAdmin(req), enabled: !!ADMIN_PW }); + + if (p === '/api/login' && req.method === 'POST') { + if (!ADMIN_PW) return json(res, 503, { error: 'Admin is disabled. Set ADMIN_PASSWORD and restart.' }); + if (recent(loginFails, ip, 9e5).length >= 5) return json(res, 429, { error: 'Too many attempts. Wait 15 minutes.' }); + const d = await readJson(req); + if (d && crypto.timingSafeEqual(sha(String(d.password || '')), sha(ADMIN_PW))) return json(res, 200, { ok: true }, { 'Set-Cookie': setCookie(req, makeToken(), 604800) }); + push(loginFails, ip); return json(res, 401, { error: 'Wrong password.' }); + } + if (p === '/api/logout' && req.method === 'POST') return json(res, 200, { ok: true }, { 'Set-Cookie': setCookie(req, '', 0) }); + + if (p.startsWith('/api/admin/')) { + if (!isAdmin(req)) return json(res, 401, { error: 'Not signed in.' }); + if (p === '/api/admin/services' && req.method === 'GET') { + try { return json(res, 200, JSON.parse(fs.readFileSync(ADMIN_SERVICES, 'utf8'))); } catch { return json(res, 200, []); } + } + if (p === '/api/admin/messages' && req.method === 'GET') return json(res, 200, readMsgs().reverse()); + const del = p.match(/^\/api\/admin\/messages\/(.+)$/); + if (del && req.method === 'DELETE') { + const id = decodeURIComponent(del[1]); writeMsgs(readMsgs().filter(m => (m.id || m.at) !== id)); return json(res, 200, { ok: true }); + } + } + return json(res, 404, { error: 'Not found.' }); +} + +http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://x'); + try { + if (url.pathname.startsWith('/api/')) return await api(req, res, url); + if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'Method not allowed.' }); + let file = path.join(PUBLIC, path.normalize(decodeURIComponent(url.pathname))); + if (!file.startsWith(PUBLIC)) return json(res, 403, { error: 'Forbidden.' }); + if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html'); + if (!fs.existsSync(file)) return send(res, 404, fs.readFileSync(path.join(PUBLIC, '404.html')), MIME['.html']); + send(res, 200, fs.readFileSync(file), MIME[path.extname(file)] || 'application/octet-stream'); + } catch (e) { if (!res.headersSent) json(res, 500, { error: 'Server error.' }); } +}).listen(PORT, () => console.log(`nkelabs listening on :${PORT}`)); diff --git a/services/index.html b/services/index.html new file mode 100644 index 0000000..065ca5f --- /dev/null +++ b/services/index.html @@ -0,0 +1,80 @@ + + + + + +Services · nkelabs + + + +
      + nke/labs + +
      +
      +

      Services

      +

      Everything running in the lab. Admin tools only answer on the home network.

      + +
      +

      Open to everyone

      + +
      + +
      +

      Admin only local network

      + +
      +
      + + +