forked from 4ducky/4ducky
Rust port #1
@@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
/data/4ducky.db*
|
||||||
|
/data/images/
|
||||||
|
/data/thumbs/
|
||||||
|
/data/avatars/*
|
||||||
|
!/data/avatars/default.png
|
||||||
|
/config.toml
|
||||||
Generated
+3408
File diff suppressed because it is too large
Load Diff
+39
@@ -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
|
||||||
@@ -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
@@ -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();
|
||||||
@@ -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 = ""
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -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
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user