Merge pull request 'Rust port' (#1) from rust-port into master

Reviewed-on: http://localhost:3000/kibi/5ducky/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
2026-10-17 17:34:12 +00:00
143 changed files with 11133 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
/target
/data/4ducky.db*
/data/images/
/data/thumbs/
/data/avatars/*
!/data/avatars/default.png
/config.toml
Generated
+3408
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "fourducky"
version = "0.1.0"
edition = "2024"
description = "4ducky.com image board - Rust port of the original PHP/Picturelicious site"
publish = false
[dependencies]
ab_glyph = "0.2"
anyhow = "1"
argon2 = "0.5"
askama = "0.14"
axum = { version = "0.8", features = ["multipart", "macros"] }
chrono = "0.4"
clap = { version = "4", features = ["derive"] }
futures-util = "0.3"
hex = "0.4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif"] }
imageproc = { version = "0.25", default-features = false }
percent-encoding = "2"
rand = "0.9"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
subtle = "2"
tokio = { version = "1", features = ["full"] }
toml = "0.8"
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "set-header", "limit", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
[profile.release]
lto = "thin"
strip = true
+91
View File
@@ -0,0 +1,91 @@
# 4ducky.com
Rust port of the original 4ducky.com image board (a Picturelicious fork, PHP 5 / MySQL, ~2012–2016).
Design, layout, URLs and features are kept 1:1. The original PHP source is preserved untouched in
[`legacy/`](legacy/).
**Stack:** Rust · Axum · SQLite (sqlx) · Askama templates · argon2 · vanilla JS
## Running
```sh
cp config.example.toml config.toml # optional, defaults work for local use
cargo run --release # http://127.0.0.1:8080
```
Run it from the repository root: the server serves `media/`, `images/` and `favicon.ico` from there.
The SQLite database and all uploads live in `data_dir` (default `data/`, git-ignored). Migrations run
automatically on start.
### Behind a reverse proxy
Set these in `config.toml`, otherwise form posts are rejected as cross-site and every visitor
looks like `127.0.0.1` (which closes registration via the one-account-per-IP lock):
```toml
frontend_path = "https://your-domain.tld/" # the public URL, exactly as in the address bar
trust_proxy = true # take the client IP from X-Forwarded-For
secure_cookies = true # when the proxy terminates HTTPS
```
The proxy must append the client address to `X-Forwarded-For` (nginx:
`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, Caddy does it by default).
### Admin CLI
```sh
fourducky admin <name> [--revoke] # grant/revoke admin
fourducky settings --register on|off --upload on|off --message '<b>Dolan:</b> "..."'
fourducky invites <name> --count 5 # create invite codes (was runfav.php)
```
Register an account on the site first, then make it admin. User id 1 is the "superadmin" by default
(HoF / Ban links), see `superadmins` in the config.
## Layout
| Path | What |
|---|---|
| `src/` | server: routing (`main.rs`), sessions/CSRF (`app.rs`, `auth.rs`), pages (`browse.rs`, `view.rs`, `account.rs`, `inbox.rs`, `misc.rs`), uploads (`uploader.rs`, `imaging.rs`, `fetch.rs`), `grid.rs`, `bbcode.rs`, `captcha.rs` |
| `templates/` | Askama templates, ported markup from `legacy/templates/*.tpl.php` |
| `media/` | original CSS/images, plus `4ducky.js` (replaces jQuery + picturelicious.js/functions.js/jqpr.js/lightbox.js) and `4ducky.css` (small additions) |
| `migrations/` | SQLite schema (from `legacy/dumb.sql`) |
| `legacy/` | the original PHP site, unchanged |
## Differences to the PHP version
Behaviour and look are the same; these are the deliberate changes:
**Security**
- All SQL is parameterized (the inbox had SQL injection; the DB layer used `preg_replace /e`).
- Output is escaped by default; BBCode runs on escaped text and only allows http(s) URLs. User
websites must be http(s) (no more `javascript:` links). Abuse report links are only clickable when http(s).
- Passwords: argon2id instead of unsalted md5. Sessions are random tokens stored hashed server-side.
- CSRF tokens on every form, AJAX call and state-changing link (logout, reset unread, manage actions);
cross-site POSTs are rejected.
- Strict Content-Security-Policy (`script-src 'self'`): inline `onclick`/`<script>` became `data-*`
hooks in `media/4ducky.js`.
- Remote image fetching (URL upload, comment URLs, bookmarklet) blocks private/loopback/link-local
addresses, limits redirects, size (8 MB) and time.
- Uploads are checked by magic bytes, decoded with size limits, and get server-generated file names.
- Admin endpoints check permissions server-side (HoF/Ban only for `superadmins`); TimeBlock duration
is computed on the server.
- Login, register, abuse and PMs are rate limited per IP/user.
**Replaced or removed**
- reCAPTCHA v1 → self-hosted image captcha.
- Google Analytics, AddThis, Facebook like-box, coin widget → removed. `/chat`, `/forum`, `/shop` keep
their layout and take an optional embed URL from the config.
- No e-mail: accounts are active right after registering (as they were in practice).
- The bookmarklet opens a small first-party popup with a "Post" button instead of uploading from a
cross-site iframe (modern browsers block third-party cookies; the old flow was also a CSRF hole).
- jQuery slimScroll → native thin scrollbars.
**Bugs fixed on the way**
- White-style users couldn't comment (form was inside the black-style branch).
- Blocking a user from the profile page wiped your website field.
- Comments could still be posted to closed threads via AJAX.
- `.b2 { width: 64x }` CSS typo made guest teaser thumbnails overflow (fixed in `4ducky.css`).
- Quicktagger referenced a missing `#home` element and never worked.
The original content is kept verbatim, including rank badges and texts, for archival purposes.
+112
View File
@@ -0,0 +1,112 @@
// Bookmarklet script (was media/post.js.php). Injected into third party pages.
// The dialog iframe only shows instructions; posting happens in a first-party
// popup window so the session cookie (SameSite) and CSRF token work.
function RP_RemotePost( postURL, stylesheet ) {
this.postURL = postURL;
this.stylesheet = stylesheet;
this.visible = false;
this.dialog = null;
this.iframe = null;
this.minImageSize = 32;
this.create = function(){
var that = this;
var css = document.createElement('link');
css.type = 'text/css';
css.rel = 'stylesheet';
css.href = this.stylesheet;
(document.head || document.body).appendChild( css );
var closeButton = document.createElement('a');
closeButton.appendChild( document.createTextNode("x") );
closeButton.className = 'close';
closeButton.onclick = function() { return that.toggle(); };
closeButton.href = '#';
this.dialog = document.createElement('div');
this.dialog.id = 'RP_Dialog';
this.iframe = document.createElement('iframe');
this.iframe.src = this.postURL + '?frame=1';
this.dialog.appendChild( this.iframe );
this.dialog.appendChild( closeButton );
document.body.appendChild( this.dialog );
};
this.selectImage = function( image ) {
var imageSrc = image.src;
if(
image.parentNode.tagName.match(/^a$/i) &&
image.parentNode.href &&
image.parentNode.href.match(/\.(jpe?g|gif|png)$/i)
) {
imageSrc = image.parentNode.href;
}
window.open(
this.postURL + '?url=' + encodeURIComponent( imageSrc ),
'RP_Post',
'width=520,height=260,scrollbars=yes'
);
this.hide();
return false;
};
this.show = function() {
this.visible = true;
var that = this;
this.dialog.style.display = 'block';
var images = document.getElementsByTagName('img');
for( var i=0; i<images.length; i++ ) {
var img = images[i];
if( img && img.src && img.src.match(/(space|blank)[^\/]*\.gif$/i) ) {
img.style.display = 'none';
}
else if( img && img.src && img.width > this.minImageSize && img.height > this.minImageSize) {
img.onclick = function( ev ) {
if( ev && ev.stopPropagation ) {
ev.stopPropagation();
}
return that.selectImage(this);
};
img.className = img.className ? img.className + ' RP_PostImage' : 'RP_PostImage';
}
}
};
this.hide = function() {
this.visible = false;
this.dialog.style.display = 'none';
var images = document.getElementsByTagName('img');
for( var i=0; i<images.length; i++ ) {
var img = images[i];
if( img && img.src && img.width > this.minImageSize && img.height > this.minImageSize) {
img.onclick = null;
img.className = img.className.replace(/\s*RP_PostImage/, '');
}
}
};
this.toggle = function() {
if( !this.visible ) {
this.show();
} else {
this.hide();
}
return false;
};
this.create();
}
if( typeof(RP_Instance) == 'undefined' ) {
var RP_Instance = new RP_RemotePost( %POST_URL%, %STYLESHEET% );
}
RP_Instance.toggle();
+35
View File
@@ -0,0 +1,35 @@
# Copy to config.toml and adjust. Every key is optional; the values below are the defaults.
bind = "127.0.0.1:8080"
data_dir = "data" # uploads, thumbnails, avatars
database = "data/4ducky.db" # SQLite file
secure_cookies = false # set to true when served over HTTPS
trust_proxy = false # use X-Forwarded-For (only behind your own reverse proxy)
frontend_path = "http://www.4ducky.com/" # absolute URL, used in share codes and the bookmarklet
site_name = "4ducky"
site_title = "4ducky.com - fol witzik"
# Spam protection / scores (lib/config.php)
upload_lock_time = 7200
max_num_uploads = 50
post_score = 10
tag_score_per_char = 0.5
tag_score_max = 20.0
thumbs_per_page = 125
users_per_page = 100
color_search_dev = 50
max_random_thumbs = 10
min_random_score = 10.0
max_download = 8097152
jpeg_quality = 100
# Who sees the HoF/Ban links on images, and the Unblock link on /manage.
superadmins = [1]
unblockers = [1, 11]
# Optional iframe targets for /chat, /forum and /shop (empty = placeholder text).
chat_url = ""
forum_url = ""
shop_url = ""
View File
View File
View File
View File
View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File
View File
View File
View File
View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File
View File
View File
+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);
}
+185
View File
@@ -0,0 +1,185 @@
-- 4ducky schema, ported from the original MySQL dump (legacy/dumb.sql).
-- Times are unix timestamps (seconds) unless noted.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
registered INTEGER NOT NULL,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
pass TEXT NOT NULL, -- argon2id PHC string
valid INTEGER NOT NULL DEFAULT 1,
admin INTEGER NOT NULL DEFAULT 0,
score INTEGER NOT NULL DEFAULT 0,
images INTEGER NOT NULL DEFAULT 0,
avatar TEXT NOT NULL DEFAULT 'data/avatars/default.png',
website TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '' COLLATE NOCASE,
color TEXT NOT NULL DEFAULT '0',
style TEXT NOT NULL DEFAULT 'black',
spamblocked INTEGER NOT NULL DEFAULT 0,
time INTEGER NOT NULL DEFAULT 0, -- spam block until (0 = permanent)
spam_reason TEXT NOT NULL DEFAULT '',
invite TEXT NOT NULL DEFAULT ''
);
CREATE INDEX users_email ON users(email);
CREATE TABLE images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
logged INTEGER NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
user INTEGER NOT NULL,
score REAL NOT NULL DEFAULT 0,
votes INTEGER NOT NULL DEFAULT 0,
keyword TEXT NOT NULL UNIQUE, -- YYYY/MM/slug
tags TEXT NOT NULL DEFAULT '',
image TEXT NOT NULL, -- file name inside data/images/YYYY/MM/
thumb TEXT NOT NULL, -- file name inside data/thumbs/YYYY/MM/<size>/
hash TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
fame INTEGER NOT NULL DEFAULT 0,
comment INTEGER NOT NULL DEFAULT 0 -- 1 = thread closed
);
CREATE INDEX images_user ON images(user);
CREATE INDEX images_hash ON images(hash);
CREATE INDEX images_fame ON images(fame);
CREATE TABLE imagecolors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
imageId INTEGER NOT NULL,
r INTEGER NOT NULL,
g INTEGER NOT NULL,
b INTEGER NOT NULL
);
CREATE INDEX imagecolors_image ON imagecolors(imageId);
CREATE TABLE comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
imageId INTEGER NOT NULL,
userId INTEGER NOT NULL,
created INTEGER NOT NULL,
content TEXT NOT NULL,
thumb INTEGER NOT NULL DEFAULT 0,
c_like INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX comments_image ON comments(imageId);
CREATE INDEX comments_user ON comments(userId, created);
CREATE INDEX comments_created ON comments(created);
CREATE TABLE commentuploads (
c_id INTEGER PRIMARY KEY,
image_link TEXT NOT NULL,
thumb_link TEXT NOT NULL
);
CREATE TABLE fav (
imageId INTEGER NOT NULL,
user INTEGER NOT NULL,
PRIMARY KEY (imageId, user)
);
CREATE INDEX fav_user ON fav(user);
CREATE TABLE abuse (
id INTEGER PRIMARY KEY AUTOINCREMENT,
imageid INTEGER NOT NULL DEFAULT 0,
reason TEXT NOT NULL,
email TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
logged INTEGER NOT NULL,
imagelink TEXT NOT NULL DEFAULT '',
user INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE banned (
id INTEGER PRIMARY KEY,
reason TEXT NOT NULL DEFAULT ''
);
CREATE TABLE bannedimages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT NOT NULL UNIQUE
);
-- The PHP version stored a serialized array of lowercased names per user.
CREATE TABLE blocked (
user_id INTEGER NOT NULL, -- who blocks
blocked_id INTEGER NOT NULL, -- who is blocked
PRIMARY KEY (user_id, blocked_id)
);
CREATE TABLE invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner INTEGER NOT NULL,
code TEXT NOT NULL UNIQUE,
used INTEGER NOT NULL DEFAULT 0,
usedby INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX invites_owner ON invites(owner);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_m INTEGER NOT NULL,
to_m INTEGER NOT NULL,
title_m TEXT NOT NULL DEFAULT '',
message_m TEXT NOT NULL DEFAULT '',
read_m INTEGER NOT NULL DEFAULT 0,
created INTEGER NOT NULL
);
CREATE INDEX messages_to ON messages(to_m, read_m);
CREATE INDEX messages_from ON messages(from_m);
CREATE TABLE registerlock (
ip TEXT PRIMARY KEY,
created INTEGER NOT NULL
);
CREATE TABLE settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
register INTEGER NOT NULL,
upload INTEGER NOT NULL,
message TEXT NOT NULL
);
INSERT INTO settings (id, register, upload, message)
VALUES (1, 1, 1, 'Dolan: "Viel Spaß mit dem Script!"');
CREATE TABLE taglog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tagged INTEGER NOT NULL,
userId INTEGER NOT NULL,
imageId INTEGER NOT NULL,
locked INTEGER NOT NULL
);
CREATE INDEX taglog_image ON taglog(imageId);
CREATE TABLE unread (
imageid INTEGER NOT NULL,
userid INTEGER NOT NULL,
keyword TEXT NOT NULL,
createdby INTEGER NOT NULL,
PRIMARY KEY (imageid, userid)
);
CREATE INDEX unread_user ON unread(userid);
CREATE TABLE uploadlock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
ts INTEGER NOT NULL,
user INTEGER NOT NULL
);
CREATE INDEX uploadlock_ip ON uploadlock(ip, ts);
-- New: server-side sessions (token stored as SHA-256 hash only).
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created INTEGER NOT NULL,
expires INTEGER NOT NULL,
remember INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE INDEX sessions_user ON sessions(user_id);
-- New: self-hosted captcha challenges (replaces reCAPTCHA v1).
CREATE TABLE captchas (
id TEXT PRIMARY KEY,
answer TEXT NOT NULL,
expires INTEGER NOT NULL
);
+528
View File
@@ -0,0 +1,528 @@
//! Login, logout, registration (+ invites, captcha) and the profile page.
use std::collections::HashMap;
use askama::Template;
use axum::{
extract::{Multipart, Path, Query, State},
http::{HeaderMap, header},
response::{IntoResponse, Redirect},
};
use serde::Deserialize;
use crate::{
app::{AppError, AppResult, AppState, Ctx, Inner},
auth::{self, LoginResult},
browse::{TokenQuery, render},
captcha, imaging,
layout::Layout,
util,
};
// ---------------------------------------------------------------- login
#[derive(Template)]
#[template(path = "login.html")]
struct LoginPage {
l: Layout,
wrong_login: bool,
banned: Option<String>,
name: String,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct LoginForm {
name: String,
pass: String,
remember: String,
#[serde(rename = "_csrf")]
csrf: String,
}
pub async fn login_get(State(st): State<AppState>, ctx: Ctx) -> AppResult {
if ctx.user.is_some() {
return Ok(Redirect::to("/").into_response());
}
render(LoginPage { l: Layout::new(&st, &ctx).await?, wrong_login: false, banned: None, name: String::new() })
}
pub async fn login_post(State(st): State<AppState>, ctx: Ctx, axum::Form(f): axum::Form<LoginForm>) -> AppResult {
ctx.check_csrf(&f.csrf)?;
st.limiter.check("login", &ctx.ip, 10, 300)?;
match auth::login(&st, &f.name, &f.pass, !f.remember.is_empty(), &ctx.ip).await? {
LoginResult::Ok { token, remember } => Ok(auth::login_redirect(&st, &token, remember, "/")),
LoginResult::Wrong => render(LoginPage { l: Layout::new(&st, &ctx).await?, wrong_login: true, banned: None, name: f.name }),
LoginResult::Banned(reason) => {
render(LoginPage { l: Layout::new(&st, &ctx).await?, wrong_login: false, banned: Some(reason), name: f.name })
}
}
}
pub async fn logout(State(st): State<AppState>, ctx: Ctx, headers: HeaderMap, Query(q): Query<TokenQuery>) -> AppResult {
ctx.check_csrf(&q.t)?;
Ok(auth::logout(&st, &headers).await?)
}
// ---------------------------------------------------------------- register
#[derive(Default)]
struct RegMsgs {
name_in_use: bool,
name_invalid: bool,
pass_short: bool,
pass_not_equal: bool,
email_in_use: bool,
wrong_email: bool,
captcha_inc: bool,
}
impl RegMsgs {
fn any(&self) -> bool {
self.name_in_use || self.name_invalid || self.pass_short || self.pass_not_equal || self.email_in_use || self.wrong_email
}
}
#[derive(Template)]
#[template(path = "register.html")]
struct RegisterPage {
l: Layout,
m: RegMsgs,
name: String,
email: String,
invite: Option<String>,
captcha: String,
}
#[derive(Template)]
#[template(path = "register_closed.html")]
struct RegisterClosedPage {
l: Layout,
}
#[derive(Template)]
#[template(path = "registered.html")]
struct RegisteredPage {
l: Layout,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct RegisterForm {
name: String,
email: String,
pass: String,
pass2: String,
invite: String,
captcha_id: String,
captcha: String,
#[serde(rename = "_csrf")]
csrf: String,
}
/// `validInvite()` from index.php - also bans owners who handed invites to spammers.
async fn valid_invite(st: &Inner, code: &str) -> anyhow::Result<bool> {
if code.is_empty() {
return Ok(false);
}
let row: Option<(i64, i64)> = sqlx::query_as("SELECT owner, used FROM invites WHERE code = ?")
.bind(code)
.fetch_optional(&st.db)
.await?;
let Some((owner, used)) = row else { return Ok(false) };
if used != 0 || auth::is_banned(st, owner).await?.is_some() {
return Ok(false);
}
let spam: Option<i64> = sqlx::query_scalar("SELECT spamblocked FROM users WHERE id = ?")
.bind(owner)
.fetch_optional(&st.db)
.await?;
if spam == Some(1) {
sqlx::query("INSERT OR IGNORE INTO banned (id, reason) VALUES (?, 'Invitemissbrauch')")
.bind(owner)
.execute(&st.db)
.await?;
return Ok(false);
}
Ok(true)
}
/// Registration closed globally, or this IP already has/used an account in the last 24h.
async fn register_closed(st: &Inner, ip: &str) -> anyhow::Result<bool> {
sqlx::query("DELETE FROM registerlock WHERE created < ?")
.bind(util::now() - 1440 * 60)
.execute(&st.db)
.await?;
let open: i64 = sqlx::query_scalar("SELECT register FROM settings WHERE id = 1").fetch_one(&st.db).await?;
let locked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM registerlock WHERE ip = ?")
.bind(ip)
.fetch_one(&st.db)
.await?;
Ok(open != 1 || locked > 0)
}
async fn register_page(st: &Inner, ctx: &Ctx, m: RegMsgs, name: String, email: String, invite: Option<String>) -> AppResult {
render(RegisterPage { l: Layout::new(st, ctx).await?, m, name, email, invite, captcha: captcha::create(st).await? })
}
pub async fn register_get(State(st): State<AppState>, ctx: Ctx) -> AppResult {
if ctx.user.is_some() {
return Ok(Redirect::to("/").into_response());
}
if register_closed(&st, &ctx.ip).await? {
return render(RegisterClosedPage { l: Layout::new(&st, &ctx).await? });
}
register_page(&st, &ctx, RegMsgs::default(), String::new(), String::new(), None).await
}
pub async fn register_post(State(st): State<AppState>, ctx: Ctx, axum::Form(f): axum::Form<RegisterForm>) -> AppResult {
if ctx.user.is_some() {
return Ok(Redirect::to("/").into_response());
}
ctx.check_csrf(&f.csrf)?;
st.limiter.check("register", &ctx.ip, 10, 600)?;
let invite_ok = valid_invite(&st, &f.invite).await?;
let closed = register_closed(&st, &ctx.ip).await?;
if !invite_ok && closed {
return render(RegisterClosedPage { l: Layout::new(&st, &ctx).await? });
}
if invite_ok && closed {
// Invite used from an IP that already has an account: the invite is burned.
sqlx::query("UPDATE invites SET used = 1, usedby = 1 WHERE code = ?").bind(&f.invite).execute(&st.db).await?;
return render(RegisterClosedPage { l: Layout::new(&st, &ctx).await? });
}
let invite = invite_ok.then(|| f.invite.clone());
let mut m = RegMsgs::default();
if !captcha::verify(&st, &f.captcha_id, &f.captcha).await? {
m.captcha_inc = true;
return register_page(&st, &ctx, m, f.name, f.email, invite).await;
}
if !util::valid_username(&f.name) {
m.name_invalid = true;
} else {
let taken: Option<i64> = sqlx::query_scalar("SELECT id FROM users WHERE name = ?").bind(&f.name).fetch_optional(&st.db).await?;
m.name_in_use = taken.is_some();
}
if f.pass.chars().count() < 6 {
m.pass_short = true;
} else if f.pass != f.pass2 {
m.pass_not_equal = true;
}
if !util::valid_email(&f.email) {
m.wrong_email = true;
} else {
let taken: Option<i64> = sqlx::query_scalar("SELECT id FROM users WHERE email = ?").bind(&f.email).fetch_optional(&st.db).await?;
m.email_in_use = taken.is_some();
}
if m.any() {
return register_page(&st, &ctx, m, f.name, f.email, invite).await;
}
let hash = auth::hash_password(f.pass.clone()).await?;
let res = sqlx::query(
"INSERT INTO users (registered, name, pass, valid, score, images, website, avatar, admin, email, color, style, invite) \
VALUES (?, ?, ?, 1, 0, 0, '', 'data/avatars/default.png', 0, ?, '0', 'black', ?)",
)
.bind(util::now())
.bind(&f.name)
.bind(hash)
.bind(&f.email)
.bind(invite.clone().unwrap_or_default())
.execute(&st.db)
.await;
let uid = match res {
Ok(r) => r.last_insert_rowid(),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
m.name_in_use = true;
return register_page(&st, &ctx, m, f.name, f.email, invite).await;
}
Err(e) => return Err(e.into()),
};
if let Some(code) = &invite {
sqlx::query("UPDATE invites SET used = 1, usedby = ? WHERE code = ?").bind(uid).bind(code).execute(&st.db).await?;
}
auth::touch_registerlock(&st, &ctx.ip).await?;
render(RegisteredPage { l: Layout::new(&st, &ctx).await? })
}
pub async fn captcha_image(State(st): State<AppState>, Path(file): Path<String>) -> AppResult {
let id = file.trim_end_matches(".png");
match captcha::image(&st, id).await? {
Some(png) => Ok(([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "no-store")], png).into_response()),
None => Ok(axum::http::StatusCode::NOT_FOUND.into_response()),
}
}
// ---------------------------------------------------------------- invites
struct InviteRow {
code: String,
used_by: Option<String>,
}
#[derive(Template)]
#[template(path = "invites.html")]
struct InvitesPage {
l: Layout,
invites: Vec<InviteRow>,
}
pub async fn invites(State(st): State<AppState>, ctx: Ctx) -> AppResult {
let Some(u) = &ctx.user else { return Ok(Redirect::to("/").into_response()) };
let rows: Vec<(String, i64, Option<String>)> = sqlx::query_as(
"SELECT i.code, i.used, u.name FROM invites i LEFT JOIN users u ON u.id = i.usedby WHERE i.owner = ? ORDER BY i.id",
)
.bind(u.id)
.fetch_all(&st.db)
.await?;
let invites = rows
.into_iter()
.map(|(code, used, name)| InviteRow { code, used_by: (used != 0).then(|| name.unwrap_or_default()) })
.collect();
render(InvitesPage { l: Layout::new(&st, &ctx).await?, invites })
}
/// /invites/<code>: logged in users see their invites, guests the invite registration.
pub async fn invite_code(State(st): State<AppState>, ctx: Ctx, Path(code): Path<String>) -> AppResult {
if ctx.user.is_some() {
return invites(State(st), ctx).await;
}
if valid_invite(&st, &code).await? {
return register_page(&st, &ctx, RegMsgs::default(), String::new(), String::new(), Some(code)).await;
}
Ok(Redirect::to("/").into_response())
}
// ---------------------------------------------------------------- profile
#[derive(Default)]
struct ProfileMsgs {
pass_short: bool,
pass_not_equal: bool,
avatar_failed: bool,
color_failed: bool,
blocked: bool,
block_failed: bool,
block_already: bool,
user_failed: bool,
unblocked: bool,
unblock_failed: bool,
not_blocked: bool,
}
impl ProfileMsgs {
fn any(&self) -> bool {
self.pass_short || self.pass_not_equal || self.avatar_failed || self.color_failed || self.blocked
|| self.block_failed || self.block_already || self.user_failed || self.unblocked
|| self.unblock_failed || self.not_blocked
}
}
struct BlockedUser {
name: String,
name_enc: String,
avatar: String,
images: i64,
}
#[derive(Template)]
#[template(path = "profile.html")]
struct ProfilePage {
l: Layout,
m: ProfileMsgs,
has_email: bool,
website: String,
color: String,
style: String,
blocked: Vec<BlockedUser>,
}
async fn profile_page(st: &Inner, ctx: &Ctx, m: ProfileMsgs) -> AppResult {
let uid = ctx.uid();
let (email, website, color, style): (String, String, String, String) =
sqlx::query_as("SELECT email, website, color, style FROM users WHERE id = ?")
.bind(uid)
.fetch_one(&st.db)
.await?;
let rows: Vec<(String, String, i64)> = sqlx::query_as(
"SELECT u.name, u.avatar, u.images FROM blocked b JOIN users u ON u.id = b.blocked_id WHERE b.user_id = ? ORDER BY u.name",
)
.bind(uid)
.fetch_all(&st.db)
.await?;
let mut l = Layout::new(st, ctx).await?;
l.jscolor = true;
render(ProfilePage {
l,
m,
has_email: !email.is_empty(),
website,
color,
style,
blocked: rows
.into_iter()
.map(|(name, avatar, images)| BlockedUser { name_enc: util::path_enc(&name), name, avatar, images })
.collect(),
})
}
pub async fn profile_get(State(st): State<AppState>, ctx: Ctx) -> AppResult {
if ctx.user.is_none() {
return Ok(Redirect::to("/login").into_response());
}
profile_page(&st, &ctx, ProfileMsgs::default()).await
}
pub async fn profile_post(State(st): State<AppState>, ctx: Ctx, mut mp: Multipart) -> AppResult {
let Some(user) = ctx.user.clone() else { return Ok(Redirect::to("/login").into_response()) };
let mut f: HashMap<String, String> = HashMap::new();
let mut avatar: Option<Vec<u8>> = None;
while let Some(field) = mp.next_field().await.map_err(|e| AppError::BadRequest(e.to_string()))? {
let name = field.name().unwrap_or("").to_string();
if name == "avatar" {
let has_name = field.file_name().is_some_and(|n| !n.is_empty());
let data = field.bytes().await.map_err(|e| AppError::BadRequest(e.to_string()))?;
if has_name && !data.is_empty() {
avatar = Some(data.to_vec());
}
} else {
f.insert(name, field.text().await.unwrap_or_default());
}
}
let get = |k: &str| f.get(k).map(String::as_str);
ctx.check_csrf(get("_csrf").unwrap_or(""))?;
let mut m = ProfileMsgs::default();
let uid = user.id;
if let Some(w) = get("website") {
let w = w.trim();
if w.is_empty() || util::safe_website(w).is_some() {
sqlx::query("UPDATE users SET website = ? WHERE id = ?").bind(w).bind(uid).execute(&st.db).await?;
}
}
let p = get("cpass").unwrap_or("").trim();
if !p.is_empty() {
let raw = get("cpass").unwrap_or("");
if raw.chars().count() < 6 {
m.pass_short = true;
} else if Some(raw) != get("cpass2") {
m.pass_not_equal = true;
} else {
let hash = auth::hash_password(raw.to_string()).await?;
sqlx::query("UPDATE users SET pass = ? WHERE id = ?").bind(hash).bind(uid).execute(&st.db).await?;
}
}
if let Some(bytes) = avatar {
match save_avatar(&st, bytes).await {
Ok(path) => {
if user.avatar.starts_with("data/avatars/") && user.avatar != "data/avatars/default.png" {
let _ = tokio::fs::remove_file(st.data_path(&user.avatar)).await;
}
sqlx::query("UPDATE users SET avatar = ? WHERE id = ?").bind(path).bind(uid).execute(&st.db).await?;
}
Err(e) => {
tracing::info!("avatar failed: {e:#}");
m.avatar_failed = true;
}
}
}
// PHP's empty("0") is true, so the default color "0" counts as "not set".
if let Some(c) = get("color").map(str::trim).filter(|c| !c.is_empty() && *c != "0") {
if c.len() == 6 && c.bytes().all(|b| b.is_ascii_hexdigit()) {
let c = if c.eq_ignore_ascii_case("ffffff") { "0".to_string() } else { c.to_string() };
sqlx::query("UPDATE users SET color = ? WHERE id = ?").bind(c).bind(uid).execute(&st.db).await?;
} else {
m.color_failed = true;
}
}
if let Some(name) = get("blockuser").map(str::trim).filter(|s| !s.is_empty()) {
if !util::valid_username(name) {
m.block_failed = true;
} else {
let target: Option<i64> = sqlx::query_scalar("SELECT id FROM users WHERE name = ?").bind(name).fetch_optional(&st.db).await?;
match target {
None => m.user_failed = true,
Some(tid) => {
let r = sqlx::query("INSERT OR IGNORE INTO blocked (user_id, blocked_id) VALUES (?, ?)")
.bind(uid)
.bind(tid)
.execute(&st.db)
.await?;
if r.rows_affected() == 0 { m.block_already = true } else { m.blocked = true }
}
}
}
}
if let Some(name) = get("unblockuser").map(str::trim).filter(|s| !s.is_empty() && *s != "select") {
if !util::valid_username(name) {
m.unblock_failed = true;
} else {
let target: Option<i64> = sqlx::query_scalar("SELECT id FROM users WHERE name = ?").bind(name).fetch_optional(&st.db).await?;
match target {
None => m.user_failed = true,
Some(tid) => {
let r = sqlx::query("DELETE FROM blocked WHERE user_id = ? AND blocked_id = ?")
.bind(uid)
.bind(tid)
.execute(&st.db)
.await?;
if r.rows_affected() == 0 { m.not_blocked = true } else { m.unblocked = true }
}
}
}
}
if let Some(s) = get("stylecolor").filter(|s| *s == "white" || *s == "black") {
sqlx::query("UPDATE users SET style = ? WHERE id = ?").bind(s).bind(uid).execute(&st.db).await?;
}
if user.email.is_empty()
&& let Some(e) = get("email").map(str::trim).filter(|e| util::valid_email(e)) {
let taken: Option<i64> = sqlx::query_scalar("SELECT id FROM users WHERE email = ? AND id != ?")
.bind(e)
.bind(uid)
.fetch_optional(&st.db)
.await?;
if taken.is_none() {
sqlx::query("UPDATE users SET email = ? WHERE id = ?").bind(e).bind(uid).execute(&st.db).await?;
}
}
if !m.any() {
return Ok(Redirect::to("/").into_response());
}
// Reload the user so the page reflects the new style.
let mut ctx = ctx;
if let Some(u) = ctx.user.as_mut() {
u.style = sqlx::query_scalar("SELECT style FROM users WHERE id = ?").bind(uid).fetch_one(&st.db).await?;
}
profile_page(&st, &ctx, m).await
}
/// 64x64 GIFs are kept as-is (animated avatars), everything else becomes a 64x64 jpg.
async fn save_avatar(st: &Inner, bytes: Vec<u8>) -> anyhow::Result<String> {
let dir = st.data_path("data/avatars");
tokio::fs::create_dir_all(&dir).await?;
let quality = st.cfg.jpeg_quality;
let name = util::random_token(8);
tokio::task::spawn_blocking(move || {
let (format, _) = imaging::detect_format(&bytes).ok_or_else(|| anyhow::anyhow!("not an image"))?;
let img = imaging::decode(&bytes)?;
if format == image::ImageFormat::Gif && img.width() == 64 && img.height() == 64 {
std::fs::write(dir.join(format!("{name}.gif")), &bytes)?;
Ok(format!("data/avatars/{name}.gif"))
} else {
imaging::save_jpeg(&imaging::make_thumb(&img, 64, 64), &dir.join(format!("{name}.jpg")), quality)?;
Ok(format!("data/avatars/{name}.jpg"))
}
})
.await?
}
+353
View File
@@ -0,0 +1,353 @@
//! Shared state, the per-request context (session user + CSRF token) and errors.
use std::{
collections::HashMap,
net::SocketAddr,
path::PathBuf,
sync::{Arc, Mutex},
};
use ab_glyph::FontArc;
use axum::{
extract::{ConnectInfo, FromRequestParts, Request, State},
http::{HeaderMap, HeaderValue, StatusCode, header, request::Parts},
middleware::Next,
response::{IntoResponse, Response},
};
use sqlx::SqlitePool;
use subtle::ConstantTimeEq;
use crate::{config::Config, util};
pub const SESSION_COOKIE: &str = "sid";
pub const CSRF_COOKIE: &str = "csrf";
/// Non-remembered sessions expire after this much inactivity.
pub const SESSION_IDLE: i64 = 12 * 3600;
pub const REMEMBER_TIME: i64 = 3600 * 24 * 365;
#[derive(Clone)]
pub struct AppState(pub Arc<Inner>);
pub struct Inner {
pub db: SqlitePool,
pub cfg: Config,
pub http: reqwest::Client,
pub font: FontArc,
pub watermark: image::DynamicImage,
/// Origins of the configured /chat /forum /shop embeds, for the CSP.
pub embed_origins: String,
/// images/rs/* - teaser thumbnails shown to guests instead of the real ones.
pub teasers: Vec<String>,
pub limiter: RateLimiter,
}
impl std::ops::Deref for AppState {
type Target = Inner;
fn deref(&self) -> &Inner {
&self.0
}
}
impl Inner {
pub fn data_path(&self, rel: &str) -> PathBuf {
// `rel` always starts with "data/..." like the PHP paths stored in the DB.
let rel = rel.strip_prefix("data/").unwrap_or(rel);
self.cfg.data_dir.join(rel)
}
}
/// The logged-in user, loaded fresh from the DB on every request.
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct SessionUser {
pub id: i64,
pub name: String,
pub admin: i64,
pub style: String,
pub avatar: String,
pub email: String,
}
impl SessionUser {
pub fn is_admin(&self) -> bool {
self.admin != 0
}
}
#[derive(Clone)]
pub struct Ctx {
pub user: Option<SessionUser>,
pub csrf: String,
pub ip: String,
}
impl Ctx {
pub fn uid(&self) -> i64 {
self.user.as_ref().map_or(0, |u| u.id)
}
pub fn is_admin(&self) -> bool {
self.user.as_ref().is_some_and(|u| u.is_admin())
}
pub fn white(&self) -> bool {
self.user.as_ref().is_some_and(|u| u.style == "white")
}
pub fn check_csrf(&self, token: &str) -> Result<(), AppError> {
if !token.is_empty() && bool::from(token.as_bytes().ct_eq(self.csrf.as_bytes())) {
Ok(())
} else {
Err(AppError::Forbidden("Invalid or missing CSRF token. Reload the page and try again."))
}
}
/// For AJAX requests the token travels in the `X-CSRF-Token` header.
pub fn check_csrf_header(&self, headers: &HeaderMap) -> Result<(), AppError> {
let t = headers.get("x-csrf-token").and_then(|v| v.to_str().ok()).unwrap_or("");
self.check_csrf(t)
}
}
impl<S: Send + Sync> FromRequestParts<S> for Ctx {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<Ctx>()
.cloned()
.ok_or(AppError::Internal(anyhow::anyhow!("context middleware missing")))
}
}
pub fn get_cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|kv| kv.trim().split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v)
}
pub fn cookie(st: &Inner, name: &str, value: &str, max_age: Option<i64>) -> HeaderValue {
let mut c = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
if let Some(age) = max_age {
c.push_str(&format!("; Max-Age={age}"));
}
if st.cfg.secure_cookies {
c.push_str("; Secure");
}
HeaderValue::from_str(&c).expect("cookie is ascii")
}
fn client_ip(st: &Inner, req: &Request) -> String {
// The last X-Forwarded-For entry is the one our own proxy appended; earlier
// entries come from the client and can be forged.
if st.cfg.trust_proxy {
let h = req.headers();
let forwarded = h
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.rsplit(',').next())
.or_else(|| h.get("x-real-ip").and_then(|v| v.to_str().ok()))
.map(str::trim)
.filter(|ip| ip.parse::<std::net::IpAddr>().is_ok());
if let Some(ip) = forwarded {
return ip.to_string();
}
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
tracing::warn!("trust_proxy is set but the proxy sends no X-Forwarded-For / X-Real-IP header; all visitors share one IP")
});
}
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip().to_string())
.unwrap_or_else(|| "0.0.0.0".into())
}
/// `host[:port]` of an origin/URL, the form used in Host headers.
fn host_port(u: &url::Url) -> Option<String> {
let host = u.host_str()?;
Some(match u.port() {
Some(p) => format!("{host}:{p}"),
None => host.to_string(),
})
}
/// Is this POST's Origin our own site? Accepts the configured `frontend_path`,
/// the Host header and, behind a trusted proxy, X-Forwarded-Host.
fn origin_allowed(cfg: &Config, headers: &HeaderMap) -> bool {
let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else {
return true; // no Origin header: the CSRF token decides
};
if origin == "null" {
return true;
}
let Some(o) = url::Url::parse(origin).ok() else { return false };
if let Ok(front) = url::Url::parse(&cfg.frontend_path)
&& front.origin() == o.origin()
{
return true;
}
let Some(ohost) = host_port(&o) else { return false };
let header_host = |name: header::HeaderName| {
headers.get(name).and_then(|v| v.to_str().ok()).and_then(|v| v.split(',').next()).map(str::trim)
};
if header_host(header::HOST) == Some(ohost.as_str()) {
return true;
}
cfg.trust_proxy && header_host(header::HeaderName::from_static("x-forwarded-host")) == Some(ohost.as_str())
}
/// Equivalent of `$user->login()` for the session part: runs on every request.
pub async fn context_mw(State(st): State<AppState>, mut req: Request, next: Next) -> Response {
let ip = client_ip(&st, &req);
let headers = req.headers();
// Reject cross-site form posts outright (defense in depth on top of the CSRF token).
if req.method() == axum::http::Method::POST {
let cross = headers.get("sec-fetch-site").is_some_and(|v| v == "cross-site");
if cross || !origin_allowed(&st.cfg, headers) {
tracing::warn!(
"blocked cross-site POST {} origin={:?} host={:?} - set frontend_path to your public URL",
req.uri().path(),
headers.get(header::ORIGIN),
headers.get(header::HOST)
);
return AppError::Forbidden("Cross-site request blocked.").into_response();
}
}
let (csrf, new_csrf) = match get_cookie(headers, CSRF_COOKIE) {
Some(t) if t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit()) => (t.to_string(), false),
_ => (util::random_token(32), true),
};
let mut clear_session = false;
let mut user = None;
if let Some(token) = get_cookie(headers, SESSION_COOKIE).map(str::to_string) {
match crate::auth::load_session(&st, &token, &ip).await {
Ok(Some(u)) => user = Some(u),
Ok(None) => clear_session = true,
Err(e) => tracing::error!("session lookup failed: {e:#}"),
}
}
req.extensions_mut().insert(Ctx { user, csrf: csrf.clone(), ip });
let mut res = next.run(req).await;
let h = res.headers_mut();
if new_csrf {
h.append(header::SET_COOKIE, cookie(&st, CSRF_COOKIE, &csrf, None));
}
if clear_session && !h.get_all(header::SET_COOKIE).iter().any(|v| v.as_bytes().starts_with(b"sid=")) {
h.append(header::SET_COOKIE, cookie(&st, SESSION_COOKIE, "", Some(0)));
}
res
}
/// Security headers. Inline styles stay allowed (the original markup is full of
/// them), scripts only from our own origin.
pub async fn headers_mw(State(st): State<AppState>, req: Request, next: Next) -> Response {
let framable = req.uri().path() == "/remotepost.php";
let mut res = next.run(req).await;
let h = res.headers_mut();
let ancestors = if framable { "*" } else { "'none'" };
let csp = format!(
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:; \
media-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; \
form-action 'self'; frame-src 'self' https://www.youtube-nocookie.com https://www.youtube.com \
https://flashservice.xvideos.com https://www.xvideos.com {}; frame-ancestors {ancestors}",
st.embed_origins
);
h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_str(&csp).unwrap());
h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
h.insert(header::REFERRER_POLICY, HeaderValue::from_static("same-origin"));
if !framable {
h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
}
res
}
#[derive(Debug)]
pub enum AppError {
Forbidden(&'static str),
BadRequest(String),
TooMany,
Internal(anyhow::Error),
}
impl<E: Into<anyhow::Error>> From<E> for AppError {
fn from(e: E) -> Self {
AppError::Internal(e.into())
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
AppError::Forbidden(m) => (StatusCode::FORBIDDEN, m).into_response(),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m).into_response(),
AppError::TooMany => (StatusCode::TOO_MANY_REQUESTS, "Too many requests. Wait a few minutes.").into_response(),
AppError::Internal(e) => {
tracing::error!("internal error: {e:#}");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
}
}
}
}
pub type AppResult<T = Response> = Result<T, AppError>;
/// Fixed-window per-IP rate limiter for login/register/abuse.
#[derive(Default)]
pub struct RateLimiter {
hits: Mutex<HashMap<(&'static str, String), Vec<i64>>>,
}
impl RateLimiter {
pub fn check(&self, kind: &'static str, ip: &str, max: usize, window: i64) -> Result<(), AppError> {
let now = util::now();
let mut map = self.hits.lock().unwrap();
if map.len() > 50_000 {
map.retain(|_, v| v.last().is_some_and(|t| now - t < 3600));
}
let v = map.entry((kind, ip.to_string())).or_default();
v.retain(|t| now - t < window);
if v.len() >= max {
return Err(AppError::TooMany);
}
v.push(now);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn h(pairs: &[(&'static str, &str)]) -> HeaderMap {
let mut m = HeaderMap::new();
for (k, v) in pairs {
m.insert(*k, HeaderValue::from_str(v).unwrap());
}
m
}
#[test]
fn origin_behind_reverse_proxy() {
let cfg = Config { frontend_path: "https://www.4ducky.com/".into(), ..Config::default() };
// proxy rewrote Host, but Origin is the configured public URL
assert!(origin_allowed(&cfg, &h(&[("origin", "https://www.4ducky.com"), ("host", "127.0.0.1:8080")])));
// other sites are still rejected
assert!(!origin_allowed(&cfg, &h(&[("origin", "https://evil.example"), ("host", "127.0.0.1:8080")])));
// X-Forwarded-Host only counts with trust_proxy
let fwd = h(&[("origin", "https://other.example"), ("host", "127.0.0.1:8080"), ("x-forwarded-host", "other.example")]);
assert!(!origin_allowed(&cfg, &fwd));
assert!(origin_allowed(&Config { trust_proxy: true, ..cfg.clone() }, &fwd));
// direct access without a proxy
assert!(origin_allowed(&cfg, &h(&[("origin", "http://127.0.0.1:8080"), ("host", "127.0.0.1:8080")])));
assert!(origin_allowed(&cfg, &h(&[("host", "127.0.0.1:8080")])));
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Login, sessions and password hashing (replaces the unsalted md5 of users.php).
use std::sync::LazyLock;
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
};
use axum::{
http::{HeaderValue, header},
response::{IntoResponse, Redirect, Response},
};
use crate::{
app::{self, AppState, Inner, SessionUser},
util,
};
pub async fn hash_password(pass: String) -> anyhow::Result<String> {
tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(pass.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| anyhow::anyhow!("hash: {e}"))
})
.await?
}
/// Verifies against a dummy hash when the user doesn't exist so response
/// times don't reveal which names are registered.
pub async fn verify_password(pass: String, hash: Option<String>) -> bool {
static DUMMY: LazyLock<String> = LazyLock::new(|| {
let salt = SaltString::generate(&mut OsRng);
Argon2::default().hash_password(b"dummy-password", &salt).unwrap().to_string()
});
tokio::task::spawn_blocking(move || {
let real = hash.is_some();
let h = hash.unwrap_or_else(|| DUMMY.clone());
let ok = PasswordHash::new(&h)
.map(|parsed| Argon2::default().verify_password(pass.as_bytes(), &parsed).is_ok())
.unwrap_or(false);
ok && real
})
.await
.unwrap_or(false)
}
pub async fn load_session(st: &Inner, token: &str, ip: &str) -> anyhow::Result<Option<SessionUser>> {
let now = util::now();
let id = util::sha256_hex(token);
let row: Option<(i64, i64, i64)> = sqlx::query_as(
"SELECT user_id, remember, last_seen FROM sessions WHERE id = ? AND expires > ?",
)
.bind(&id)
.bind(now)
.fetch_optional(&st.db)
.await?;
let Some((uid, remember, last_seen)) = row else { return Ok(None) };
let user: Option<SessionUser> = sqlx::query_as(
"SELECT id, name, admin, style, avatar, email FROM users WHERE id = ? AND valid = 1",
)
.bind(uid)
.fetch_optional(&st.db)
.await?;
let Some(user) = user else { return Ok(None) };
if is_banned(st, uid).await?.is_some() {
sqlx::query("DELETE FROM sessions WHERE user_id = ?").bind(uid).execute(&st.db).await?;
return Ok(None);
}
// Sliding expiry + the original "register lock" bookkeeping, at most every 10 minutes.
if now - last_seen > 600 {
let expires = if remember != 0 { now + app::REMEMBER_TIME } else { now + app::SESSION_IDLE };
sqlx::query("UPDATE sessions SET last_seen = ?, expires = ? WHERE id = ?")
.bind(now)
.bind(expires)
.bind(&id)
.execute(&st.db)
.await?;
touch_registerlock(st, ip).await?;
}
Ok(Some(user))
}
/// A loopback address means "client unknown" (proxy without forwarding headers);
/// locking it would close registration for everyone.
pub fn lockable_ip(ip: &str) -> bool {
ip.parse::<std::net::IpAddr>().is_ok_and(|a| !a.is_loopback() && !a.is_unspecified())
}
/// Every IP a user is seen from can't register another account for 24 hours.
pub async fn touch_registerlock(st: &Inner, ip: &str) -> anyhow::Result<()> {
if !lockable_ip(ip) {
return Ok(());
}
sqlx::query("INSERT INTO registerlock (ip, created) VALUES (?, ?) ON CONFLICT(ip) DO UPDATE SET created = excluded.created")
.bind(ip)
.bind(util::now())
.execute(&st.db)
.await?;
Ok(())
}
pub async fn is_banned(st: &Inner, uid: i64) -> anyhow::Result<Option<String>> {
Ok(sqlx::query_scalar("SELECT reason FROM banned WHERE id = ?")
.bind(uid)
.fetch_optional(&st.db)
.await?)
}
pub enum LoginResult {
Ok { token: String, remember: bool },
Wrong,
Banned(String),
}
pub async fn login(st: &Inner, name: &str, pass: &str, remember: bool, ip: &str) -> anyhow::Result<LoginResult> {
let row: Option<(i64, String)> =
sqlx::query_as("SELECT id, pass FROM users WHERE name = ? AND valid = 1")
.bind(name)
.fetch_optional(&st.db)
.await?;
let ok = verify_password(pass.to_string(), row.as_ref().map(|r| r.1.clone())).await;
let Some((uid, _)) = row.filter(|_| ok) else { return Ok(LoginResult::Wrong) };
if let Some(reason) = is_banned(st, uid).await? {
return Ok(LoginResult::Banned(reason));
}
touch_registerlock(st, ip).await?;
let token = util::random_token(32);
let now = util::now();
let expires = if remember { now + app::REMEMBER_TIME } else { now + app::SESSION_IDLE };
sqlx::query("INSERT INTO sessions (id, user_id, created, expires, remember, last_seen) VALUES (?, ?, ?, ?, ?, ?)")
.bind(util::sha256_hex(&token))
.bind(uid)
.bind(now)
.bind(expires)
.bind(remember as i64)
.bind(now)
.execute(&st.db)
.await?;
sqlx::query("DELETE FROM sessions WHERE expires < ?").bind(now).execute(&st.db).await?;
Ok(LoginResult::Ok { token, remember })
}
/// Redirect that sets the new session cookie and rotates the CSRF token.
pub fn login_redirect(st: &AppState, token: &str, remember: bool, to: &str) -> Response {
let mut res = Redirect::to(to).into_response();
let max_age = remember.then_some(app::REMEMBER_TIME);
let h = res.headers_mut();
h.append(header::SET_COOKIE, app::cookie(st, app::SESSION_COOKIE, token, max_age));
h.append(header::SET_COOKIE, app::cookie(st, app::CSRF_COOKIE, &util::random_token(32), None));
res
}
pub async fn logout(st: &AppState, headers: &axum::http::HeaderMap) -> anyhow::Result<Response> {
if let Some(token) = app::get_cookie(headers, app::SESSION_COOKIE) {
sqlx::query("DELETE FROM sessions WHERE id = ?")
.bind(util::sha256_hex(token))
.execute(&st.db)
.await?;
}
let mut res = Redirect::to("/").into_response();
let h = res.headers_mut();
h.append(header::SET_COOKIE, app::cookie(st, app::SESSION_COOKIE, "", Some(0)));
h.append(header::SET_COOKIE, app::cookie(st, app::CSRF_COOKIE, &util::random_token(32), None));
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(res)
}
#[cfg(test)]
mod tests {
#[test]
fn loopback_is_never_locked() {
assert!(!super::lockable_ip("127.0.0.1"));
assert!(!super::lockable_ip("::1"));
assert!(!super::lockable_ip("garbage"));
assert!(super::lockable_ip("203.0.113.7"));
}
}

Some files were not shown because too many files have changed in this diff Show More