Port 4ducky to Rust (Axum + SQLite)

Same design, layout, URLs and features as the PHP version: browse grid,
image view with AJAX comments, BBCode, likes, uploads (file, URL and
bookmarklet), quicktagger, profile, block list, inbox, invites, Hall of
Ducks, Truhe, abuse reports and the admin tools.

- Parameterized SQL, escaped templates (askama), argon2 passwords,
  server-side sessions, CSRF tokens on every write, strict script CSP
- SSRF-safe remote image fetching, magic-byte upload checks
- Self-hosted captcha replaces reCAPTCHA v1; analytics/social widgets removed
- Vanilla JS replaces jQuery and its plugins with the same DOM behaviour
- Reverse proxy support (frontend_path, trust_proxy, secure_cookies)
- Admin CLI: admin, settings, invites

See README.md for running it and the full list of changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 02:15:05 +02:00
co-authored by Claude Opus 5.5
parent 664d8c6117
commit d0f2d526df
56 changed files with 11133 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
/* Additions for the Rust port. Everything else is the original styles_b.css / styles_fb.css. */
/* styles_*.css say `width: 64x` (typo) - guest teaser images overflowed the grid. */
#imageGrid div.b2 {
width: 64px;
}
/* textareas sized by cols= overflowed their box with wider fonts */
#CommentForm textarea, #UForm textarea {
box-sizing: border-box;
width: calc(100% - 10px);
}
form.addComment textarea {
box-sizing: border-box;
max-width: calc(100% - 10px);
}
/* jQuery slimScroll replacement: thin, rounded, gray scrollbars. */
div#rightpanel {
bottom: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #555 transparent;
}
div#rightpanel::-webkit-scrollbar { width: 7px; }
div#rightpanel::-webkit-scrollbar-thumb { background: #555; border-radius: 7px; }
#spendenducks {
height: 190px;
width: 599px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #555 transparent;
}
#spendenducks::-webkit-scrollbar { width: 7px; }
#spendenducks::-webkit-scrollbar-thumb { background: #555; border-radius: 7px; }
/* Links without href used to be href="javascript:;" */
a[data-menu], a[data-toggle], a[data-fav], a[data-bbcode], a[data-admin], a[data-delcomment] {
cursor: pointer;
}
+553
View File
@@ -0,0 +1,553 @@
/*
* 4ducky.com client script.
* Vanilla port of media/picturelicious.js, js/functions.js, js/jqpr.js,
* js/lightbox.js (Lightbox v2.51 by Lokesh Dhakar, CC BY 2.5) and
* media/quicktags.js. Same DOM and behaviour, no jQuery, no inline handlers.
*/
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
function csrf() {
var m = document.querySelector('meta[name="csrf-token"]');
return m ? m.content : '';
}
function post(url, data) {
return fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-CSRF-Token': csrf(), 'X-Requested-With': 'XMLHttpRequest' },
body: new URLSearchParams(data)
}).then(function (r) { return r.text(); });
}
// ------------------------------------------------------------------ cookies
function getCookie(name) {
var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : null;
}
function setCookie(name, value) {
if (value === null) {
document.cookie = name + '=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
} else {
document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax';
}
}
// ------------------------------------------------------------------ fades (jQuery fadeIn/fadeOut)
function fadeOut(el, ms) {
if (!el || el.style.display === 'none') return;
el.style.transition = 'opacity ' + ms + 'ms';
el.style.opacity = '0';
clearTimeout(el._fade);
el._fade = setTimeout(function () { el.style.display = 'none'; }, ms);
}
function fadeIn(el, ms, display) {
if (!el) return;
clearTimeout(el._fade);
if (el.style.display === 'none' || getComputedStyle(el).display === 'none') {
el.style.opacity = '0';
el.style.display = display || 'block';
}
el.style.transition = 'opacity ' + ms + 'ms';
void el.offsetWidth;
el.style.opacity = '';
}
function show(el, display) { if (el) el.style.display = display || 'block'; }
function hide(el) { if (el) el.style.display = 'none'; }
// ------------------------------------------------------------------ AJAX loaders
function LoadData(str, divId, imageId) {
return post('/ajax_getdata.php', { q: str, imageId: imageId || 0 }).then(function (data) {
var d = $(divId);
if (!d) return;
d.innerHTML = data;
d.style.display = 'block';
});
}
function Menu(art, er) {
var ud = $('UploadD');
if (art === 'upload') ud.style.backgroundPosition = '24px top';
if (art === 'addon') ud.style.backgroundPosition = '90px top';
if (er === 'error') {
hide($('UbLoad'));
show($('MenuLoad'));
return;
}
show($('MenuLoad'));
$('UploadD_d').innerHTML = '<div style="margin-top:12px;text-align:center;" id="UbLoad"><div align="center"><img src="/media/load_b.gif" /></div></div>';
LoadData(art, 'UploadD_d', 0);
}
function loadComments() {
var id = document.body.getAttribute('data-image-id');
if (id) LoadData('comments', 'cmts', id);
}
function SubmitComment(imageId) {
var file = $('image_comment'), url = $('url_comment'), content = $('content_comment');
if (content.value.length < 1) {
$('CommentError').textContent = 'Your comment is too short!';
show($('CommentError'));
return;
}
if (file && file.value.length > 4) {
$('CommentForm').submit();
return;
}
var fields = [file, url, content, $('SubBut')];
fields.forEach(function (f) { if (f) f.disabled = true; });
show($('SubLoad'));
post('/ajax_getdata.php', {
q: 'add', imageId: imageId, content: content.value, urlUpload: url ? url.value : ''
}).then(function () {
fields.forEach(function (f) { if (f) f.disabled = false; });
$('CommentForm').reset();
hide($('SubLoad'));
hide($('CommentError'));
loadComments();
});
}
function toggle(id) {
var el = $(id);
if (!el) return;
el.style.display = el.style.display !== 'block' ? 'block' : 'none';
}
function BBcode(code) {
var input = prompt('Enter URL / text / image-link / youtube-link');
if (input === null || input === '') return;
var x = $('content_comment');
x.value = x.value + '[' + code + ']' + input + '[/' + code + ']';
}
function reComment(commentId, user) {
var box = $('comment' + commentId);
if (box) box.style.border = '1px solid #898989';
var output = $(String(commentId)).textContent;
output = '>> ' + user + ': \n"' + output + '"';
var x = $('content_comment');
if (x) x.value = x.value + '[quote]' + output.substr(0, 750) + '[/quote]\n';
}
// ------------------------------------------------------------------ admin (json.php)
function json(action, params) {
return post('/json.php?' + action, params);
}
var admin = {
fame: function (id) {
if (!confirm('Move to Hall of Fame?')) return;
json('fame', { id: id });
},
ban: function (id) {
if (!confirm('Ban this User?')) return;
var rsn = prompt('Why?');
json('banUser', { id: id, rsn: rsn || '' });
},
timeblock: function (id) {
if (!confirm('Spamlock this user?')) return;
var tm = prompt('How long? (in hours)');
var rsn = prompt('Why?');
var now = Math.round(Date.now() / 1000) + 3600 * parseInt(tm || '0', 10);
alert(now);
json('TimeUser', { id: id, hours: tm || '0', rsn: rsn || '' });
},
close: function (id) {
if (!confirm('Close Thread?')) return;
json('CloseThread', { id: id });
},
'delete': function (id) {
if (!confirm('Delete this image: ' + id + ' ?')) return;
json('delete', { id: id }).then(loadComments);
}
};
function delComment(id) {
if (!confirm('Delete this comment?')) return;
json('deleteComment', { id: id }).then(loadComments);
}
// ------------------------------------------------------------------ grid solver (picturelicious.js)
var gridSize = 64, gridMinWidth = 6, gridWidth = 0, grid = null;
var boxDef = { b0: { width: 3, height: 3 }, b1: { width: 2, height: 2 }, b2: { width: 1, height: 1 } };
function gridSolve(cid) {
var container = $(cid);
var newGridWidth = parseInt(container.offsetWidth / gridSize, 10);
newGridWidth = newGridWidth < gridMinWidth ? gridMinWidth : newGridWidth;
if (gridWidth === newGridWidth) return;
gridWidth = newGridWidth;
var boxes = [];
while (container.hasChildNodes()) {
var e = container.removeChild(container.firstChild);
if (e.tagName === 'DIV') boxes.push(e);
}
grid = [];
for (var i = 0; i < gridWidth; i++) grid[i] = 0;
for (i = 0; i < boxes.length; i++) insertThumb(boxes[i], container);
var maxHeight = 0;
for (i = 0; i < grid.length; i++) maxHeight = Math.max(maxHeight, grid[i]);
container.style.height = (maxHeight * gridSize) + 'px';
$('images').style.height = (maxHeight * gridSize) + 'px';
}
function insertThumb(box, container) {
var def = boxDef[box.className] || boxDef.b2;
var boxWidth = def.width, boxHeight = def.height;
var maxHeight = 0, currentHeight = -1, spotWidth = 0, spotLeft = 0, freeSpots = [];
for (var i = 0; i < grid.length; i++) {
if (currentHeight === grid[i]) spotWidth++;
if ((currentHeight !== grid[i] || i + 1 === grid.length) && spotWidth > 0) {
freeSpots.push({ width: spotWidth, left: spotLeft, height: currentHeight });
spotWidth = 1;
spotLeft = i;
if (currentHeight !== grid[i] && i + 1 === grid.length) {
freeSpots.push({ width: spotWidth, left: spotLeft, height: grid[i] });
}
} else if (spotWidth === 0) {
spotWidth = 1;
}
currentHeight = grid[i];
if (grid[i] > maxHeight) maxHeight = grid[i];
}
var targetHeight = 0, targetLeft = 0, bestScore = -1;
for (var j = 0; j < boxWidth; j++) {
if (grid[j] > targetHeight) targetHeight = grid[j];
}
var newHeight = targetHeight + boxHeight;
for (i = 0; i < freeSpots.length; i++) {
var heightScore = maxHeight - freeSpots[i].height;
var widthScore = boxWidth / freeSpots[i].width;
var score = heightScore * heightScore + widthScore * 2;
if (freeSpots[i].width >= boxWidth && score > bestScore) {
targetHeight = freeSpots[i].height;
targetLeft = freeSpots[i].left;
newHeight = freeSpots[i].height + boxHeight;
bestScore = score;
}
}
for (j = 0; j < boxWidth; j++) grid[targetLeft + j] = newHeight;
container.appendChild(box);
box.style.left = (targetLeft * gridSize) + 'px';
box.style.top = (targetHeight * gridSize) + 'px';
}
function gridView(id) {
gridSolve(id);
gridSolve(id);
setInterval(function () { gridSolve(id); }, 250);
}
// ------------------------------------------------------------------ sidebar & layout (jqpr.js)
function hideSidebar() {
hide($('sidebar'));
hide($('MenuRightBlock'));
$('site').style.marginRight = '0px';
var f = $('footer');
if (f) f.style.marginRight = '0px';
$('footer-back-to-top').style.right = '30px';
}
// ------------------------------------------------------------------ lightbox (Lightbox v2.51)
var lightbox = {
album: [],
current: 0,
built: false,
build: function () {
var overlay = document.createElement('div');
overlay.id = 'lightboxOverlay';
var lb = document.createElement('div');
lb.id = 'lightbox';
lb.innerHTML =
'<div class="lb-outerContainer"><div class="lb-container"><img class="lb-image">' +
'<div class="lb-nav"><a class="lb-prev"></a><a class="lb-next"></a></div>' +
'<div class="lb-loader"><a class="lb-cancel"><img src="/media/lightbox/loading.gif"></a></div></div></div>' +
'<div class="lb-dataContainer"><div class="lb-data"><div class="lb-details"><span class="lb-caption"></span>' +
'<span class="lb-number"></span></div><div class="lb-closeContainer"><a class="lb-close">' +
'<img src="/media/lightbox/close.png"></a></div></div></div>';
document.body.appendChild(overlay);
document.body.appendChild(lb);
hide(overlay);
hide(lb);
var that = this;
overlay.addEventListener('click', function (e) { e.preventDefault(); that.end(); });
lb.addEventListener('click', function (e) {
e.preventDefault();
var t = e.target;
if (t.id === 'lightbox') that.end();
else if (t.classList.contains('lb-prev')) that.change(that.current - 1);
else if (t.classList.contains('lb-next')) that.change(that.current + 1);
else if (t.closest('.lb-loader, .lb-close')) that.end();
});
this.keys = function (e) { that.keyboard(e); };
this.built = true;
},
q: function (sel) { return $('lightbox').querySelector(sel); },
sizeOverlay: function () {
var o = $('lightboxOverlay');
o.style.width = document.documentElement.scrollWidth + 'px';
o.style.height = document.documentElement.scrollHeight + 'px';
},
start: function (link) {
if (!this.built) this.build();
var rel = link.getAttribute('rel');
this.album = [];
var number = 0;
if (rel === 'lightbox') {
this.album.push({ link: link.getAttribute('href'), title: link.getAttribute('title') });
} else {
var links = document.querySelectorAll('a[rel="' + rel + '"]');
for (var i = 0; i < links.length; i++) {
this.album.push({ link: links[i].getAttribute('href'), title: links[i].getAttribute('title') });
if (links[i] === link) number = i;
}
}
this.sizeOverlay();
fadeIn($('lightboxOverlay'), 500);
var lb = $('lightbox');
lb.style.top = (window.scrollY + window.innerHeight / 10) + 'px';
lb.style.left = window.scrollX + 'px';
fadeIn(lb, 500);
window.addEventListener('resize', this.sizeOverlay);
this.change(number);
},
change: function (n) {
if (n < 0 || n >= this.album.length) return;
var that = this;
document.removeEventListener('keyup', this.keys);
['.lb-image', '.lb-nav', '.lb-prev', '.lb-next', '.lb-dataContainer', '.lb-caption'].forEach(function (s) { hide(that.q(s)); });
show(this.q('.lb-loader'));
this.q('.lb-outerContainer').classList.add('animating');
var pre = new Image();
pre.onload = function () {
that.q('.lb-image').src = that.album[n].link;
that.size(pre.width, pre.height);
};
pre.src = this.album[n].link;
this.current = n;
},
size: function (w, h) {
var that = this;
var outer = this.q('.lb-outerContainer');
var cs = getComputedStyle(this.q('.lb-container'));
var nw = w + parseInt(cs.paddingLeft, 10) + parseInt(cs.paddingRight, 10);
var nh = h + parseInt(cs.paddingTop, 10) + parseInt(cs.paddingBottom, 10);
outer.style.transition = 'width 700ms, height 700ms';
outer.style.width = nw + 'px';
outer.style.height = nh + 'px';
setTimeout(function () {
that.q('.lb-dataContainer').style.width = nw + 'px';
hide(that.q('.lb-loader'));
fadeIn(that.q('.lb-image'), 600);
show(that.q('.lb-nav'));
if (that.current > 0) show(that.q('.lb-prev'));
if (that.current < that.album.length - 1) show(that.q('.lb-next'));
var item = that.album[that.current];
if (item.title) {
that.q('.lb-caption').textContent = item.title;
fadeIn(that.q('.lb-caption'), 200, 'inline');
}
var num = that.q('.lb-number');
if (that.album.length > 1) {
num.textContent = 'Image ' + (that.current + 1) + ' of ' + that.album.length;
fadeIn(num, 200);
} else {
hide(num);
}
outer.classList.remove('animating');
fadeIn(that.q('.lb-dataContainer'), 700);
that.sizeOverlay();
[that.current + 1, that.current - 1].forEach(function (i) {
if (that.album[i]) new Image().src = that.album[i].link;
});
document.addEventListener('keyup', that.keys);
}, 700);
},
keyboard: function (e) {
var key = String.fromCharCode(e.keyCode).toLowerCase();
if (e.keyCode === 27 || /x|o|c/.test(key)) this.end();
else if (key === 'p' || e.keyCode === 37) this.change(this.current - 1);
else if (key === 'n' || e.keyCode === 39) this.change(this.current + 1);
},
end: function () {
document.removeEventListener('keyup', this.keys);
window.removeEventListener('resize', this.sizeOverlay);
fadeOut($('lightbox'), 500);
fadeOut($('lightboxOverlay'), 500);
}
};
// ------------------------------------------------------------------ quicktagger (quicktags.js)
var nextImage = {};
function quickTag(logId, imageId, inputField) {
var tags = inputField.value;
$('imageLocation').href = nextImage.link || '#';
$('imageLocation').textContent = nextImage.link || '';
$('imageId').value = nextImage.id || 0;
$('logId').value = nextImage.logId || 0;
if (nextImage.image) $('image').src = nextImage.image;
$('tags').textContent = nextImage.tags || '';
$('tagText').value = '';
$('tagText').focus();
if (!nextImage.link) {
$('imageInfo').textContent = 'No more Images found';
}
json('quickTag', { logId: logId, id: imageId, tags: tags }).then(function (txt) {
nextImage = JSON.parse(txt || '{}');
if (nextImage.image) new Image().src = nextImage.image;
});
}
function quickTagInit() {
$('quickTagIntro').className = 'hidden';
$('viewer').className = 'visible';
json('quickTag', {}).then(function (txt) {
nextImage = JSON.parse(txt || '{}');
quickTag(0, 0, $('tagText'));
});
}
// ------------------------------------------------------------------ events
document.addEventListener('click', function (e) {
// Match the element that carries the hook, not an <img> inside a link.
var t = e.target.closest(
'a[rel^="lightbox"], [data-action], [data-menu], [data-toggle], [data-fav], [data-bbcode], ' +
'[data-requote], [data-delcomment], [data-admin], [data-bookmarklet], [data-select], ' +
'#SubBut, #image, #x, #stfu, #del_cookie, #loadcom, #footer-back-to-top'
);
if (!t) return;
var d = t.dataset;
if (t.matches('a[rel^="lightbox"]')) { e.preventDefault(); lightbox.start(t); return; }
if (d.action === 'close-upload') { hide($('MenuLoad')); return; }
if (d.menu) { e.preventDefault(); Menu(d.menu); return; }
if (d.toggle) { e.preventDefault(); toggle(d.toggle); return; }
if (d.fav) { e.preventDefault(); LoadData('fav', 'FavDiv', d.fav); return; }
if (d.bbcode) { e.preventDefault(); BBcode(d.bbcode); return; }
if (d.requote) { reComment(d.requote, d.name); return; }
if (d.delcomment) { e.preventDefault(); delComment(d.delcomment); return; }
if (d.admin && admin[d.admin]) { e.preventDefault(); admin[d.admin](d.id); return; }
if (d.bookmarklet) { e.preventDefault(); alert('Drag this Link to your Bookmarks!'); return; }
if (d.select) { t.focus(); t.select(); return; }
if (d.action === 'quicktag-start') { e.preventDefault(); quickTagInit(); return; }
if (d.action === 'quicktag') { e.preventDefault(); quickTag($('logId').value, $('imageId').value, $('tagText')); return; }
if (t.id === 'SubBut') { SubmitComment(d.imageId); return; }
if (t.id === 'image' && /^(scaled|full)$/.test(t.className)) { t.className = t.className === 'scaled' ? 'full' : 'scaled'; return; }
if (t.id === 'x') {
e.preventDefault();
fadeOut($('sidebar'), 600);
hide($('MenuRightBlock'));
$('site').style.marginRight = '0px';
$('footer-back-to-top').style.right = '30px';
alert('Sidebar closed. Open it by clicking on your avatar in the header. Best regards, Dolan!');
setCookie('close_sidebar', 'yes');
return;
}
if (t.id === 'stfu') { fadeOut($('dolan'), 600); setCookie('close_message', 'yes'); return; }
if (t.id === 'del_cookie') {
e.preventDefault();
setCookie('close_sidebar', null);
setCookie('close_message', null);
location.reload();
return;
}
if (t.id === 'loadcom') { e.preventDefault(); LoadData('lastcomments', 'rightpanel'); return; }
if (t.id === 'footer-back-to-top') { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }
});
document.addEventListener('submit', function (e) {
if (e.target.id === 'addTag') {
e.preventDefault();
quickTag($('logId').value, $('imageId').value, $('tagText'));
}
});
document.addEventListener('mouseover', function (e) {
var t = e.target;
if (t.classList && t.classList.contains('duckfav')) t.src = '/media/duck.png';
var h = t.closest && t.closest('[data-hover]');
if (h) { var el = $(h.dataset.hover); if (el) el.style.visibility = 'visible'; }
});
document.addEventListener('mouseout', function (e) {
var t = e.target;
if (t.classList && t.classList.contains('duckfav')) t.src = '/media/ducksw.png';
var box = $('Last5Div');
if (box && box.contains(t) && !box.contains(e.relatedTarget)) box.style.visibility = 'hidden';
});
// Broken [img] BBCode images fall back to the dolan 404 picture (was onError="...").
document.addEventListener('error', function (e) {
var t = e.target;
if (t.tagName === 'IMG' && t.dataset && t.dataset.fallback && !t.dataset.failed) {
t.dataset.failed = '1';
t.src = t.dataset.fallback;
}
}, true);
document.documentElement.addEventListener('keyup', function (e) {
if (!$('prevBar') || $('lightbox') && $('lightbox').style.display === 'block') return;
var a = document.activeElement;
if (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA')) return;
var c = $('content_comment');
if (c && c.value) return;
var bar = e.keyCode === 37 ? $('prevBar') : e.keyCode === 39 ? $('nextBar') : null;
if (bar && bar.getAttribute('href')) window.location = bar.getAttribute('href');
});
// ------------------------------------------------------------------ init
function init() {
var body = document.body;
var top = $('footer-back-to-top');
if (/iPhone|iPod|iPad|Android|BlackBerry/i.test(navigator.userAgent)) {
hideSidebar();
$('menu').style.position = 'relative';
$('content').style.marginTop = '0.5em';
}
if (body.hasAttribute('data-guest')) {
hide($('sidebar'));
$('site').style.marginRight = '0px';
top.style.right = '30px';
}
if (getCookie('close_sidebar') === 'yes') hideSidebar();
if (getCookie('close_message') === 'yes') hide($('dolan'));
hide(top);
window.addEventListener('scroll', function () {
if (window.scrollY > 200) fadeIn(top, 600); else fadeOut(top, 600);
}, { passive: true });
if ($('imageGrid')) gridView('imageGrid');
var ml = $('MenuLoad');
if (ml && ml.hasAttribute('data-error')) Menu('', 'error');
if ($('rightpanel')) LoadData('lastcomments', 'rightpanel');
loadComments();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
+4
View File
@@ -0,0 +1,4 @@
// Closes the bookmarklet popup shortly after a successful post.
if (document.getElementById('RP_Success') && window.opener) {
setTimeout(function () { window.close(); }, 1500);
}