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 = ""
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
//! Port of lib/bbcode.class.php. Input is escaped first, so the only HTML in
|
||||
//! the output is what the tags below generate. URLs are restricted to http(s)
|
||||
//! and the inline onclick/onError handlers became data attributes for the JS.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::{Captures, Regex};
|
||||
|
||||
use crate::util::{esc, nl2br};
|
||||
|
||||
struct Rule {
|
||||
re: Regex,
|
||||
render: fn(&Captures) -> String,
|
||||
}
|
||||
|
||||
fn rule(pattern: &str, render: fn(&Captures) -> String) -> Rule {
|
||||
Rule { re: Regex::new(pattern).unwrap(), render }
|
||||
}
|
||||
|
||||
static RULES: LazyLock<Vec<Rule>> = LazyLock::new(|| {
|
||||
vec\[/b\]", |c| format!("<strong>{}</strong>", &c[1])),
|
||||
rule(r"\[i\](.*?)\[/i\]", |c| format!("<em>{}</em>", &c[1])),
|
||||
rule(r"\[u\](.*?)\[/u\]", |c| format!("<u>{}</u>", &c[1])),
|
||||
rule(r"(?s)\[quote\](.*?)\[/quote\]", |c| format!("<span class=\"quote\">{}</span>", &c[1])),
|
||||
rule(r"\[img\](.*?)\[/img\]", |c| img(&c[1])),
|
||||
rule(r"\[youtube\](.*?)\[/youtube\]", |c| youtube(&c[1])),
|
||||
rule(r"\[xxx\](.*?)\[/xxx\]", |c| xxx(&c[1])),
|
||||
rule(r"\[link\](.*?)\[/link\]", |c| link(&c[1])),
|
||||
rule(r"(?s)\[spoiler\](.*?)\[/spoiler\]", |c| format!("<span class=\"Spoiler\">{}</span>", &c[1])),
|
||||
]
|
||||
});
|
||||
|
||||
/// `nl2br($bbcode->parse(htmlspecialchars($content)))`
|
||||
pub fn render(raw: &str) -> String {
|
||||
let mut content = esc(raw);
|
||||
for r in RULES.iter() {
|
||||
// The PHP version looped `while (preg_match(...))`; bounded here.
|
||||
for _ in 0..32 {
|
||||
if !r.re.is_match(&content) {
|
||||
break;
|
||||
}
|
||||
content = r.re.replace_all(&content, r.render).into_owned();
|
||||
}
|
||||
}
|
||||
nl2br(&content)
|
||||
}
|
||||
|
||||
/// `s` is escaped text; a safe URL is http(s) without whitespace or markup.
|
||||
fn safe_url(s: &str) -> Option<&str> {
|
||||
let s = s.trim();
|
||||
let lower = s.to_ascii_lowercase();
|
||||
let ok = (lower.starts_with("http://") || lower.starts_with("https://"))
|
||||
&& !s.contains(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"');
|
||||
ok.then_some(s)
|
||||
}
|
||||
|
||||
fn img(url: &str) -> String {
|
||||
match safe_url(url) {
|
||||
Some(u) => format!(
|
||||
"<a href=\"{u}\" rel=\"lightbox[more]\" target=\"_blank\"><img data-fallback=\"/media/dolan404.jpg\" src=\"{u}\" border=\"0\" class=\"imgbbcode\"></a>"
|
||||
),
|
||||
None => format!("[img]{url}[/img]").replace("[img]", "[img]"),
|
||||
}
|
||||
}
|
||||
|
||||
fn youtube(url: &str) -> String {
|
||||
static V: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)v=([A-Za-z0-9_\-]+)").unwrap());
|
||||
static SHORT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)youtu\.be/([A-Za-z0-9_\-]+)").unwrap());
|
||||
let watch = if url.to_ascii_lowercase().contains("youtube") {
|
||||
match V.captures(url) {
|
||||
Some(c) => c[1].to_string(),
|
||||
None => return String::new(),
|
||||
}
|
||||
} else if let Some(c) = SHORT.captures(url) {
|
||||
c[1].to_string()
|
||||
} else {
|
||||
let w: String = url.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
|
||||
if w.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
w
|
||||
};
|
||||
format!(
|
||||
"<a href=\"#\" id=\"YTbutton\" data-toggle=\"YT{watch}\">[youtube]</a><iframe id=\"YT{watch}\" width=\"425\" style=\"display:none;\" height=\"344\" src=\"https://www.youtube-nocookie.com/embed/{watch}\" frameborder=\"0\" allowfullscreen></iframe>"
|
||||
)
|
||||
}
|
||||
|
||||
fn xxx(url: &str) -> String {
|
||||
static NUM: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\d+)").unwrap());
|
||||
if !url.to_ascii_lowercase().contains("xvideo") {
|
||||
return String::new();
|
||||
}
|
||||
let Some(watch) = NUM.captures(url).map(|c| c[1].to_string()) else { return String::new() };
|
||||
format!(
|
||||
"<a href=\"#\" id=\"Pornbutton\" data-toggle=\"Porn{watch}\">[xxx]</a><iframe id=\"Porn{watch}\" width=\"425\" height=\"344\" style=\"display:none;\" src=\"https://www.xvideos.com/embedframe/{watch}\" frameborder=\"0\"></iframe>"
|
||||
)
|
||||
}
|
||||
|
||||
fn link(text: &str) -> String {
|
||||
// (?<!\w)((https?://)|(www\.))([^\s<>]+) - lookbehind emulated with group 1.
|
||||
static URL: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)(^|[^\w])(?:(https?://)|(www\.))([^\s<>\x22]+)").unwrap());
|
||||
if text.is_empty() {
|
||||
return "Link-Fail".into();
|
||||
}
|
||||
URL.replace_all(text, |c: &Captures| {
|
||||
let scheme = c.get(2).map_or("http://", |m| m.as_str());
|
||||
let www = c.get(3).map_or("", |m| m.as_str());
|
||||
format!("{}<a target=\"_blank\" href=\"{scheme}{www}{}\">{text}</a>", &c[1], &c[4])
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_tags() {
|
||||
assert_eq!(render("[b]hi[/b] [i]x[/i]"), "<strong>hi</strong> <em>x</em>");
|
||||
assert_eq!(render("a\nb"), "a<br />\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_html() {
|
||||
assert_eq!(render("<script>"), "<script>");
|
||||
assert!(!render("[img]javascript:alert(1)[/img]").contains("<img"));
|
||||
assert!(!render("[img]http://x\" onerror=\"alert(1)[/img]").contains("onerror=\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media() {
|
||||
assert!(render("[img]https://e.org/a.png[/img]").contains("src=\"https://e.org/a.png\""));
|
||||
assert!(render("[youtube]https://www.youtube.com/watch?v=dQw4w9WgXcQ[/youtube]").contains("embed/dQw4w9WgXcQ"));
|
||||
assert!(render("[link]see www.example.org[/link]").contains("href=\"http://www.example.org\""));
|
||||
assert_eq!(render("[link][/link]"), "Link-Fail");
|
||||
}
|
||||
}
|
||||
+571
@@ -0,0 +1,571 @@
|
||||
//! Browse pages (lib/imagebrowser.php + templates/browse.tpl.php), the Hall of
|
||||
//! Ducks, the user list, random.js and the 404 page.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{StatusCode, header},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
};
|
||||
use rand::seq::IndexedRandom;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
app::{AppResult, AppState, Ctx, Inner},
|
||||
config::{GRID_SIZE, THUMB_CLASSES},
|
||||
grid::{self, Thumb},
|
||||
layout::Layout,
|
||||
util,
|
||||
};
|
||||
|
||||
pub fn render(t: impl Template) -> AppResult {
|
||||
Ok(Html(t.render()?).into_response())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 404
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "404.html")]
|
||||
struct NotFoundPage {
|
||||
l: Layout,
|
||||
}
|
||||
|
||||
pub async fn not_found(st: &Inner, ctx: &Ctx) -> AppResult {
|
||||
let l = Layout::new(st, ctx).await?;
|
||||
let html = NotFoundPage { l }.render()?;
|
||||
Ok((StatusCode::NOT_FOUND, Html(html)).into_response())
|
||||
}
|
||||
|
||||
pub async fn fallback(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
not_found(&st, &ctx).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- browse
|
||||
|
||||
pub enum Mode {
|
||||
All,
|
||||
User(String),
|
||||
Search(String),
|
||||
Comments { reset: bool },
|
||||
Unread { reset: bool },
|
||||
Favorites,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ProfileUser {
|
||||
id: i64,
|
||||
name: String,
|
||||
images: i64,
|
||||
avatar: String,
|
||||
website: String,
|
||||
color: String,
|
||||
registered: i64,
|
||||
}
|
||||
|
||||
struct UserBlock {
|
||||
id: i64,
|
||||
name: String,
|
||||
name_enc: String,
|
||||
style: String,
|
||||
rank: &'static str,
|
||||
website: Option<String>,
|
||||
avatar: String,
|
||||
days: i64,
|
||||
images: i64,
|
||||
comments: i64,
|
||||
favs: i64,
|
||||
}
|
||||
|
||||
struct GridItem {
|
||||
class: &'static str,
|
||||
left: i64,
|
||||
top: i64,
|
||||
alert: bool,
|
||||
href: String,
|
||||
src: String,
|
||||
title: String,
|
||||
alt: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "browse.html")]
|
||||
struct BrowsePage {
|
||||
l: Layout,
|
||||
user_block: Option<UserBlock>,
|
||||
kind: &'static str,
|
||||
term: String,
|
||||
page_current: i64,
|
||||
page_total: i64,
|
||||
prev_href: Option<String>,
|
||||
next_href: Option<String>,
|
||||
unread_new: i64,
|
||||
height: i64,
|
||||
items: Vec<GridItem>,
|
||||
}
|
||||
|
||||
/// Browse rank symbol by user id (browse.tpl.php).
|
||||
fn browse_rank(id: i64) -> &'static str {
|
||||
if id > 1100 {
|
||||
"☪"
|
||||
} else if id < 500 {
|
||||
"ϟϟ"
|
||||
} else if id > 939 {
|
||||
"✡"
|
||||
} else {
|
||||
"☭"
|
||||
}
|
||||
}
|
||||
|
||||
const THUMB_COLS: &str = "i.logged, i.keyword, i.thumb, i.score, i.votes, COALESCE(u.name, '') AS user_name, i.user AS user_id";
|
||||
|
||||
pub async fn blocked_ids(st: &Inner, uid: i64) -> anyhow::Result<HashSet<i64>> {
|
||||
if uid == 0 {
|
||||
return Ok(HashSet::new());
|
||||
}
|
||||
let ids: Vec<i64> = sqlx::query_scalar("SELECT blocked_id FROM blocked WHERE user_id = ?")
|
||||
.bind(uid)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
Ok(ids.into_iter().collect())
|
||||
}
|
||||
|
||||
fn parse_color(term: &str) -> Option<(i64, i64, i64)> {
|
||||
let t = term.trim();
|
||||
let hex = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X"))?;
|
||||
if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| i64::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0);
|
||||
Some((p(0), p(2), p(4)))
|
||||
}
|
||||
|
||||
pub async fn browse(st: &Inner, ctx: &Ctx, mode: Mode, page: i64, upload_errors: Vec<String>) -> AppResult {
|
||||
let uid = ctx.uid();
|
||||
let mut per_page = st.cfg.thumbs_per_page;
|
||||
let page = if page > 0 { page - 1 } else { 0 };
|
||||
let offset = page * per_page;
|
||||
|
||||
if matches!(mode, Mode::Comments { .. } | Mode::Unread { .. } | Mode::Favorites) && uid == 0 {
|
||||
return Ok(Redirect::to("/login").into_response());
|
||||
}
|
||||
|
||||
let mut base_path = "all/".to_string();
|
||||
let mut kind = "all";
|
||||
let mut term = String::new();
|
||||
let mut profile: Option<ProfileUser> = None;
|
||||
let (mut thumbs, total): (Vec<Thumb>, i64) = match &mode {
|
||||
Mode::All | Mode::User(_) => {
|
||||
let mut filter_uid = 0;
|
||||
if let Mode::User(name) = &mode {
|
||||
profile = sqlx::query_as(
|
||||
"SELECT id, name, images, avatar, website, color, registered FROM users WHERE valid = 1 AND name = ?",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
// Unknown user: the PHP version fell back to listing everything.
|
||||
if let Some(p) = &profile {
|
||||
filter_uid = p.id;
|
||||
base_path = format!("user/{}/", util::path_enc(&p.name));
|
||||
kind = "user";
|
||||
}
|
||||
}
|
||||
let thumbs: Vec<Thumb> = sqlx::query_as(&format!(
|
||||
"SELECT {THUMB_COLS} FROM images i LEFT JOIN users u ON u.id = i.user \
|
||||
WHERE (?1 = 0 OR i.user = ?1) ORDER BY i.id DESC LIMIT ?2 OFFSET ?3"
|
||||
))
|
||||
.bind(filter_uid)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let total = sqlx::query_scalar("SELECT COUNT(*) FROM images WHERE (?1 = 0 OR user = ?1)")
|
||||
.bind(filter_uid)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
(thumbs, total)
|
||||
}
|
||||
Mode::Search(t) => {
|
||||
term = t.clone();
|
||||
kind = "search";
|
||||
base_path = format!("search/{}/", util::path_enc(t));
|
||||
if let Some((r, g, b)) = parse_color(t) {
|
||||
let dev = st.cfg.color_search_dev;
|
||||
let where_ = "ic.r BETWEEN ?1 - ?4 AND ?1 + ?4 AND ic.g BETWEEN ?2 - ?4 AND ?2 + ?4 AND ic.b BETWEEN ?3 - ?4 AND ?3 + ?4";
|
||||
let thumbs: Vec<Thumb> = sqlx::query_as(&format!(
|
||||
"SELECT {THUMB_COLS} FROM images i LEFT JOIN users u ON u.id = i.user \
|
||||
JOIN imagecolors ic ON ic.imageId = i.id WHERE {where_} GROUP BY i.id \
|
||||
ORDER BY MIN(ABS(ic.r - ?1) + ABS(ic.g - ?2) + ABS(ic.b - ?3)), i.id DESC LIMIT ?5 OFFSET ?6"
|
||||
))
|
||||
.bind(r).bind(g).bind(b).bind(dev).bind(per_page).bind(offset)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let total = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(DISTINCT ic.imageId) FROM imagecolors ic WHERE {where_}"
|
||||
))
|
||||
.bind(r).bind(g).bind(b).bind(dev)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
(thumbs, total)
|
||||
} else {
|
||||
// `i.image LIKE %term% OR MATCH(tags) AGAINST(+word ...)`
|
||||
let like = |s: &str| format!("%{}%", s.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
|
||||
let words: Vec<String> = t.split_whitespace().take(10).map(like).collect();
|
||||
let mut cond = String::from("(i.image LIKE ? ESCAPE '\\'");
|
||||
if !words.is_empty() {
|
||||
cond.push_str(" OR (");
|
||||
cond.push_str(&vec!["i.tags LIKE ? ESCAPE '\\'"; words.len()].join(" AND "));
|
||||
cond.push(')');
|
||||
}
|
||||
cond.push(')');
|
||||
let sql = format!(
|
||||
"SELECT {THUMB_COLS} FROM images i LEFT JOIN users u ON u.id = i.user WHERE {cond} ORDER BY i.id DESC LIMIT ? OFFSET ?"
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, Thumb>(&sql).bind(like(t));
|
||||
for w in &words {
|
||||
q = q.bind(w);
|
||||
}
|
||||
let thumbs = q.bind(per_page).bind(offset).fetch_all(&st.db).await?;
|
||||
let count_sql = format!("SELECT COUNT(*) FROM images i WHERE {cond}");
|
||||
let mut cq = sqlx::query_scalar::<_, i64>(&count_sql).bind(like(t));
|
||||
for w in &words {
|
||||
cq = cq.bind(w);
|
||||
}
|
||||
(thumbs, cq.fetch_one(&st.db).await?)
|
||||
}
|
||||
}
|
||||
Mode::Comments { reset } | Mode::Unread { reset } => {
|
||||
if *reset {
|
||||
sqlx::query("DELETE FROM unread WHERE userid = ?").bind(uid).execute(&st.db).await?;
|
||||
}
|
||||
let unread = matches!(mode, Mode::Unread { .. });
|
||||
if unread {
|
||||
let c: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM unread WHERE userid = ?")
|
||||
.bind(uid)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
if c == 0 {
|
||||
return Ok(Redirect::to("/comments").into_response());
|
||||
}
|
||||
}
|
||||
kind = "comments";
|
||||
base_path = "comments/".into();
|
||||
let sub = if unread {
|
||||
"SELECT imageid FROM unread WHERE userid = ?1"
|
||||
} else {
|
||||
"SELECT imageId FROM comments WHERE userId = ?1 AND c_like = 0"
|
||||
};
|
||||
let thumbs: Vec<Thumb> = sqlx::query_as(&format!(
|
||||
"SELECT {THUMB_COLS} FROM images i LEFT JOIN users u ON u.id = i.user \
|
||||
WHERE i.id IN ({sub}) ORDER BY i.logged DESC LIMIT ?2 OFFSET ?3"
|
||||
))
|
||||
.bind(uid).bind(per_page).bind(offset)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let total = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM images i WHERE i.id IN ({sub})"))
|
||||
.bind(uid)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
if unread && (thumbs.len() as i64) < per_page {
|
||||
per_page = (thumbs.len() as i64).max(1);
|
||||
}
|
||||
(thumbs, total)
|
||||
}
|
||||
Mode::Favorites => {
|
||||
kind = "favorites";
|
||||
base_path = "favorites/".into();
|
||||
let sub = "SELECT imageId FROM fav WHERE user = ?1";
|
||||
let thumbs: Vec<Thumb> = sqlx::query_as(&format!(
|
||||
"SELECT {THUMB_COLS} FROM images i LEFT JOIN users u ON u.id = i.user \
|
||||
WHERE i.id IN ({sub}) ORDER BY i.logged DESC LIMIT ?2 OFFSET ?3"
|
||||
))
|
||||
.bind(uid).bind(per_page).bind(offset)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let total = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM images i WHERE i.id IN ({sub})"))
|
||||
.bind(uid)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
(thumbs, total)
|
||||
}
|
||||
};
|
||||
|
||||
if thumbs.is_empty() {
|
||||
if matches!(mode, Mode::Unread { .. }) {
|
||||
sqlx::query("DELETE FROM unread WHERE userid = ?").bind(uid).execute(&st.db).await?;
|
||||
return Ok(Redirect::to("/comments").into_response());
|
||||
}
|
||||
return not_found(st, ctx).await;
|
||||
}
|
||||
|
||||
// The newest image is always big, the next two medium, old ones small.
|
||||
if matches!(mode, Mode::All | Mode::User(_)) && thumbs.len() > 3 {
|
||||
thumbs[0].score = 100.0;
|
||||
thumbs[1].score = 10.0;
|
||||
thumbs[2].score = 10.0;
|
||||
let n = thumbs.len();
|
||||
if n > 50 {
|
||||
for t in &mut thumbs[n - 40..] {
|
||||
t.score = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let height = grid::solve(&mut thumbs);
|
||||
|
||||
let page_total = (total + per_page - 1) / per_page;
|
||||
let prev_href = (page > 0).then(|| format!("/{base_path}page/{page}"));
|
||||
let next_href = (total > per_page * page + per_page).then(|| format!("/{base_path}page/{}", page + 2));
|
||||
|
||||
let img_base = if matches!(mode, Mode::All | Mode::User(_)) { base_path.clone() } else { "all/".to_string() };
|
||||
let blocked = blocked_ids(st, uid).await?;
|
||||
let unread_kw: HashSet<String> = if uid != 0 {
|
||||
sqlx::query_scalar("SELECT keyword FROM unread WHERE userid = ?")
|
||||
.bind(uid)
|
||||
.fetch_all(&st.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect()
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
let items: Vec<GridItem> = {
|
||||
let mut rng = rand::rng();
|
||||
thumbs
|
||||
.iter()
|
||||
.filter(|t| !blocked.contains(&t.user_id))
|
||||
.map(|t| {
|
||||
let dir = THUMB_CLASSES.iter().find(|c| c.class == t.class).map_or("64x64", |c| c.dir);
|
||||
let src = if uid == 0 {
|
||||
// Guests only get the teaser pictures.
|
||||
st.teasers.choose(&mut rng).map_or_else(String::new, |f| format!("/images/rs/{f}"))
|
||||
} else {
|
||||
util::thumb_url(t.logged, dir, &t.thumb)
|
||||
};
|
||||
GridItem {
|
||||
class: t.class,
|
||||
left: t.left,
|
||||
top: t.top,
|
||||
alert: unread_kw.contains(&t.keyword),
|
||||
href: format!("/{img_base}view/{}", t.keyword),
|
||||
src,
|
||||
title: format!("{} - {}", t.user_name, util::date("d. M Y H:i", t.logged)),
|
||||
alt: t.keyword.clone(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let user_block = match &profile {
|
||||
Some(p) => {
|
||||
let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM comments WHERE userId = ? AND c_like = 0")
|
||||
.bind(p.id)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
let favs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM fav WHERE user = ?")
|
||||
.bind(p.id)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
Some(UserBlock {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
name_enc: util::path_enc(&p.name),
|
||||
style: util::color_style(&p.color),
|
||||
rank: browse_rank(p.id),
|
||||
website: util::safe_website(&p.website),
|
||||
avatar: p.avatar.clone(),
|
||||
days: (util::now() - p.registered).max(0) / 86400,
|
||||
images: p.images,
|
||||
comments,
|
||||
favs,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let unread_new = if kind == "comments" {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM unread u JOIN images i ON i.id = u.imageid WHERE u.userid = ?")
|
||||
.bind(uid)
|
||||
.fetch_one(&st.db)
|
||||
.await?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut l = Layout::new(st, ctx).await?.with_title(profile.as_ref().map(|p| p.name.as_str()), None, &st.cfg.site_title);
|
||||
l.upload_errors = upload_errors;
|
||||
render(BrowsePage {
|
||||
l,
|
||||
user_block,
|
||||
kind,
|
||||
term,
|
||||
page_current: page + 1,
|
||||
page_total,
|
||||
prev_href,
|
||||
next_href,
|
||||
unread_new,
|
||||
height: height * GRID_SIZE,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TokenQuery {
|
||||
#[serde(default)]
|
||||
pub t: String,
|
||||
}
|
||||
|
||||
pub async fn index(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
browse(&st, &ctx, Mode::All, 1, vec![]).await
|
||||
}
|
||||
|
||||
pub async fn all_page(State(st): State<AppState>, ctx: Ctx, Path(n): Path<String>) -> AppResult {
|
||||
browse(&st, &ctx, Mode::All, n.parse().unwrap_or(1), vec![]).await
|
||||
}
|
||||
|
||||
pub async fn user(State(st): State<AppState>, ctx: Ctx, Path(name): Path<String>) -> AppResult {
|
||||
browse(&st, &ctx, Mode::User(name), 1, vec![]).await
|
||||
}
|
||||
|
||||
pub async fn user_page(State(st): State<AppState>, ctx: Ctx, Path((name, n)): Path<(String, String)>) -> AppResult {
|
||||
browse(&st, &ctx, Mode::User(name), n.parse().unwrap_or(1), vec![]).await
|
||||
}
|
||||
|
||||
pub async fn favorites(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
browse(&st, &ctx, Mode::Favorites, 1, vec![]).await
|
||||
}
|
||||
|
||||
pub async fn favorites_page(State(st): State<AppState>, ctx: Ctx, Path(n): Path<String>) -> AppResult {
|
||||
browse(&st, &ctx, Mode::Favorites, n.parse().unwrap_or(1), vec![]).await
|
||||
}
|
||||
|
||||
/// /comments, /comments/reset, /comments/page/2 (`$r[1]` action, `$r[2]` page)
|
||||
pub async fn comments(State(st): State<AppState>, ctx: Ctx, Query(q): Query<TokenQuery>, path: Option<Path<Vec<String>>>) -> AppResult {
|
||||
let segs = path.map(|p| p.0).unwrap_or_default();
|
||||
let reset = segs.first().is_some_and(|s| s == "reset");
|
||||
if reset {
|
||||
ctx.check_csrf(&q.t)?;
|
||||
}
|
||||
let page = segs.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
|
||||
browse(&st, &ctx, Mode::Comments { reset }, page, vec![]).await
|
||||
}
|
||||
|
||||
pub async fn unread(State(st): State<AppState>, ctx: Ctx, Query(q): Query<TokenQuery>, path: Option<Path<Vec<String>>>) -> AppResult {
|
||||
let segs = path.map(|p| p.0).unwrap_or_default();
|
||||
let reset = segs.first().is_some_and(|s| s == "reset");
|
||||
if reset {
|
||||
ctx.check_csrf(&q.t)?;
|
||||
}
|
||||
let page = segs.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
|
||||
browse(&st, &ctx, Mode::Unread { reset }, page, vec![]).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- fame
|
||||
|
||||
struct FameItem {
|
||||
keyword: String,
|
||||
thumb: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "fame.html")]
|
||||
struct FamePage {
|
||||
l: Layout,
|
||||
items: Vec<FameItem>,
|
||||
}
|
||||
|
||||
pub async fn fame(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
let rows: Vec<(String, String, i64)> =
|
||||
sqlx::query_as("SELECT keyword, thumb, logged FROM images WHERE fame = 1 ORDER BY logged DESC")
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|(keyword, thumb, logged)| FameItem { keyword, thumb: util::thumb_url(logged, "192x192", &thumb) })
|
||||
.collect();
|
||||
render(FamePage { l: Layout::new(&st, &ctx).await?, items })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- users
|
||||
|
||||
struct ListUser {
|
||||
name: String,
|
||||
name_enc: String,
|
||||
images: i64,
|
||||
avatar: String,
|
||||
website: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "userlist.html")]
|
||||
struct UserListPage {
|
||||
l: Layout,
|
||||
users: Vec<ListUser>,
|
||||
border: &'static str,
|
||||
current: i64,
|
||||
total: i64,
|
||||
prev: Option<i64>,
|
||||
next: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn users(State(st): State<AppState>, ctx: Ctx, page: Option<Path<String>>) -> AppResult {
|
||||
let page: i64 = page.and_then(|p| p.0.parse().ok()).unwrap_or(1);
|
||||
let page = if page > 0 { page - 1 } else { 0 };
|
||||
let per = st.cfg.users_per_page;
|
||||
let rows: Vec<(String, i64, String, String)> = sqlx::query_as(
|
||||
"SELECT name, images, avatar, website FROM users WHERE valid = 1 ORDER BY id ASC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(per)
|
||||
.bind(page * per)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let total_rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE valid = 1").fetch_one(&st.db).await?;
|
||||
let users = rows
|
||||
.into_iter()
|
||||
.map(|(name, images, avatar, website)| ListUser {
|
||||
name_enc: util::path_enc(&name),
|
||||
name,
|
||||
images,
|
||||
avatar,
|
||||
website: util::safe_website(&website),
|
||||
})
|
||||
.collect();
|
||||
let white = ctx.white();
|
||||
render(UserListPage {
|
||||
l: Layout::new(&st, &ctx).await?,
|
||||
users,
|
||||
border: if white { " border:1px solid #d2d9e7; " } else { "border:1px solid #333;" },
|
||||
current: page + 1,
|
||||
total: (total_rows + per - 1) / per,
|
||||
prev: (page > 0).then_some(page),
|
||||
next: (total_rows > per * page + per).then_some(page + 2),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- random.js
|
||||
|
||||
/// `/random/<count>/<size>/` - JS snippet with random links (sidebar + embeds).
|
||||
pub async fn random_js(State(st): State<AppState>, Path(params): Path<String>) -> AppResult {
|
||||
let count = params
|
||||
.split('/')
|
||||
.next()
|
||||
.and_then(|c| c.parse::<i64>().ok())
|
||||
.unwrap_or(1)
|
||||
.clamp(1, st.cfg.max_random_thumbs);
|
||||
let keywords: Vec<String> =
|
||||
sqlx::query_scalar("SELECT keyword FROM images WHERE score >= ? ORDER BY RANDOM() LIMIT ?")
|
||||
.bind(st.cfg.min_random_score)
|
||||
.bind(count)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let mut js = String::new();
|
||||
for kw in keywords {
|
||||
let html = format!(
|
||||
"<div id=\"commentbar\"><a href=\"/all/view/{}\"><div id=\"commentimg\"><img width=\"28\" height=\"28\" src=\"/media/random.png\" alt=\"Random\"></div><div id=\"commenttext\"><strong>Random: </strong><br />Quark bitte!</div></a></div>",
|
||||
util::esc(&kw)
|
||||
);
|
||||
js.push_str(&format!("document.write({});\n", serde_json::to_string(&html)?.replace("</", "<\\/")));
|
||||
}
|
||||
Ok(([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], js).into_response())
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Self-hosted captcha (replaces the dead reCAPTCHA v1 widget on /register).
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use ab_glyph::{FontArc, PxScale};
|
||||
use image::{ImageFormat, Rgb, RgbImage};
|
||||
use imageproc::drawing::{draw_line_segment_mut, draw_text_mut};
|
||||
use rand::Rng;
|
||||
|
||||
use crate::{app::Inner, util};
|
||||
|
||||
const CHARSET: &[u8] = b"ABCDEFGHJKLMNPRSTUVWXYZ23456789";
|
||||
const LIFETIME: i64 = 15 * 60;
|
||||
|
||||
/// Creates a challenge and returns its id (used in the image URL and the form).
|
||||
pub async fn create(st: &Inner) -> anyhow::Result<String> {
|
||||
let answer: String = {
|
||||
let mut rng = rand::rng();
|
||||
(0..5).map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char).collect()
|
||||
};
|
||||
let id = util::random_token(16);
|
||||
let now = util::now();
|
||||
sqlx::query("DELETE FROM captchas WHERE expires < ?").bind(now).execute(&st.db).await?;
|
||||
sqlx::query("INSERT INTO captchas (id, answer, expires) VALUES (?, ?, ?)")
|
||||
.bind(&id)
|
||||
.bind(&answer)
|
||||
.bind(now + LIFETIME)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Single use: the challenge is deleted whether the answer was right or not.
|
||||
pub async fn verify(st: &Inner, id: &str, answer: &str) -> anyhow::Result<bool> {
|
||||
let row: Option<String> = sqlx::query_scalar("DELETE FROM captchas WHERE id = ? AND expires > ? RETURNING answer")
|
||||
.bind(id)
|
||||
.bind(util::now())
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
Ok(row.is_some_and(|a| a.eq_ignore_ascii_case(answer.trim())))
|
||||
}
|
||||
|
||||
pub async fn image(st: &Inner, id: &str) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
let answer: Option<String> = sqlx::query_scalar("SELECT answer FROM captchas WHERE id = ? AND expires > ?")
|
||||
.bind(id)
|
||||
.bind(util::now())
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
let Some(answer) = answer else { return Ok(None) };
|
||||
Ok(Some(render(&st.font, &answer)?))
|
||||
}
|
||||
|
||||
fn render(font: &FontArc, text: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let (w, h) = (180u32, 56u32);
|
||||
let mut rng = rand::rng();
|
||||
let mut img = RgbImage::from_pixel(w, h, Rgb([0x17, 0x17, 0x17]));
|
||||
for _ in 0..6 {
|
||||
let c = rng.random_range(60..130u8);
|
||||
draw_line_segment_mut(
|
||||
&mut img,
|
||||
(rng.random_range(0.0..w as f32), rng.random_range(0.0..h as f32)),
|
||||
(rng.random_range(0.0..w as f32), rng.random_range(0.0..h as f32)),
|
||||
Rgb([c, c, c]),
|
||||
);
|
||||
}
|
||||
for (i, ch) in text.chars().enumerate() {
|
||||
let scale = PxScale::from(rng.random_range(30.0..40.0));
|
||||
let x = 12 + i as i32 * 32 + rng.random_range(-4..5);
|
||||
let y = rng.random_range(4..16);
|
||||
let shade = rng.random_range(180..255u8);
|
||||
draw_text_mut(&mut img, Rgb([shade, shade, shade]), x, y, scale, font, &ch.to_string());
|
||||
}
|
||||
for _ in 0..250 {
|
||||
let (x, y) = (rng.random_range(0..w), rng.random_range(0..h));
|
||||
let c = rng.random_range(80..200u8);
|
||||
img.put_pixel(x, y, Rgb([c, c, c]));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
|
||||
Ok(out)
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
//! All config settings. Mirrors legacy/lib/config.php; every value has the
|
||||
//! original default and can be overridden in `config.toml`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
// Server
|
||||
pub bind: String,
|
||||
pub data_dir: PathBuf,
|
||||
pub database: PathBuf,
|
||||
/// Set when served over HTTPS, adds the `Secure` flag to cookies.
|
||||
pub secure_cookies: bool,
|
||||
/// Take the client IP from `X-Forwarded-For` (only behind a trusted reverse proxy).
|
||||
pub trust_proxy: bool,
|
||||
|
||||
// Names and URLs for your installation
|
||||
pub frontend_path: String,
|
||||
pub site_name: String,
|
||||
pub site_title: String,
|
||||
|
||||
// Spam protection
|
||||
pub upload_lock_time: i64,
|
||||
pub max_num_uploads: i64,
|
||||
|
||||
// User score
|
||||
pub post_score: i64,
|
||||
pub tag_score_per_char: f64,
|
||||
pub tag_score_max: f64,
|
||||
|
||||
// Misc settings
|
||||
pub users_per_page: i64,
|
||||
pub thumbs_per_page: i64,
|
||||
pub color_search_dev: i64,
|
||||
pub max_random_thumbs: i64,
|
||||
pub min_random_score: f64,
|
||||
pub max_download: usize,
|
||||
pub jpeg_quality: u8,
|
||||
|
||||
/// User ids that see the HoF/Ban admin links (was hardcoded `$user->id == "1"`).
|
||||
pub superadmins: Vec<i64>,
|
||||
/// User ids that see the "Unblock" link on /manage (was `id == 11 || id == 1`).
|
||||
pub unblockers: Vec<i64>,
|
||||
|
||||
/// Optional iframe targets for /chat, /forum and /shop. Empty = placeholder.
|
||||
pub chat_url: String,
|
||||
pub forum_url: String,
|
||||
pub shop_url: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind: "127.0.0.1:8080".into(),
|
||||
data_dir: "data".into(),
|
||||
database: "data/4ducky.db".into(),
|
||||
secure_cookies: false,
|
||||
trust_proxy: false,
|
||||
frontend_path: "http://www.4ducky.com/".into(),
|
||||
site_name: "4ducky".into(),
|
||||
site_title: "4ducky.com - fol witzik".into(),
|
||||
upload_lock_time: 7200,
|
||||
max_num_uploads: 50,
|
||||
post_score: 10,
|
||||
tag_score_per_char: 0.5,
|
||||
tag_score_max: 20.0,
|
||||
users_per_page: 100,
|
||||
thumbs_per_page: 125,
|
||||
color_search_dev: 50,
|
||||
max_random_thumbs: 10,
|
||||
min_random_score: 10.0,
|
||||
max_download: 8_097_152,
|
||||
jpeg_quality: 100,
|
||||
superadmins: vec![1],
|
||||
unblockers: vec![1, 11],
|
||||
chat_url: String::new(),
|
||||
forum_url: String::new(),
|
||||
shop_url: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||
let mut cfg: Config = if path.exists() {
|
||||
toml::from_str(&std::fs::read_to_string(path)?)?
|
||||
} else {
|
||||
Config::default()
|
||||
};
|
||||
if !cfg.frontend_path.ends_with('/') {
|
||||
cfg.frontend_path.push('/');
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
pub fn is_superadmin(&self, id: i64) -> bool {
|
||||
self.superadmins.contains(&id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid solver settings (`Config::$gridView`). Not configurable, the CSS depends on them.
|
||||
pub const GRID_SIZE: i64 = 64;
|
||||
pub const GRID_WIDTH: usize = 12;
|
||||
pub const BORDER_WIDTH: u32 = 2;
|
||||
|
||||
pub struct ThumbClass {
|
||||
pub class: &'static str,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub percentage: f64,
|
||||
pub dir: &'static str,
|
||||
}
|
||||
|
||||
/// Definition of all Thumbnail classes, in the original order.
|
||||
pub const THUMB_CLASSES: [ThumbClass; 3] = [
|
||||
ThumbClass { class: "b2", width: 1, height: 1, percentage: 0.6, dir: "64x64" },
|
||||
ThumbClass { class: "b1", width: 2, height: 2, percentage: 0.35, dir: "128x128" },
|
||||
ThumbClass { class: "b0", width: 3, height: 3, percentage: 0.05, dir: "192x192" },
|
||||
];
|
||||
|
||||
pub fn thumb_class(class: &str) -> &'static ThumbClass {
|
||||
THUMB_CLASSES.iter().find(|c| c.class == class).unwrap_or(&THUMB_CLASSES[0])
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
//! Remote image download for "Upload from URL", comment URL images and the
|
||||
//! bookmarklet. The PHP version did `file_get_contents($url)` on anything; this
|
||||
//! one refuses to talk to non-public addresses (SSRF protection).
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
|
||||
pub fn is_public_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => is_public_v4(v4),
|
||||
IpAddr::V6(v6) => {
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return is_public_v4(v4);
|
||||
}
|
||||
let s = v6.segments();
|
||||
// NAT64 well-known prefix embeds an IPv4 address.
|
||||
if s[0] == 0x64 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
|
||||
let v4 = Ipv4Addr::new((s[6] >> 8) as u8, s[6] as u8, (s[7] >> 8) as u8, s[7] as u8);
|
||||
return is_public_v4(v4);
|
||||
}
|
||||
!(v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_multicast()
|
||||
|| (s[0] & 0xfe00) == 0xfc00 // unique local
|
||||
|| (s[0] & 0xffc0) == 0xfe80 // link local
|
||||
|| (s[0] == 0x2001 && s[1] == 0x0db8) // documentation
|
||||
|| s[0] == 0x2002 // 6to4
|
||||
|| (s[0] == 0x2001 && s[1] == 0)) // teredo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_public_v4(ip: Ipv4Addr) -> bool {
|
||||
let o = ip.octets();
|
||||
!(ip.is_private()
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_broadcast()
|
||||
|| ip.is_documentation()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| o[0] == 0
|
||||
|| (o[0] == 100 && (o[1] & 0xc0) == 64) // CGNAT 100.64/10
|
||||
|| (o[0] == 192 && o[1] == 0 && o[2] == 0) // IETF protocol assignments
|
||||
|| (o[0] == 198 && (o[1] & 0xfe) == 18) // benchmarking 198.18/15
|
||||
|| o[0] >= 240) // reserved
|
||||
}
|
||||
|
||||
/// DNS resolver that drops every non-public address.
|
||||
struct PublicOnlyResolver;
|
||||
|
||||
impl Resolve for PublicOnlyResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
Box::pin(async move {
|
||||
let addrs: Vec<SocketAddr> = tokio::net::lookup_host((name.as_str(), 0))
|
||||
.await?
|
||||
.filter(|a| is_public_ip(a.ip()))
|
||||
.collect();
|
||||
if addrs.is_empty() {
|
||||
return Err("host resolves to a non-public address".into());
|
||||
}
|
||||
Ok(Box::new(addrs.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// IP literals in URLs bypass the resolver, so they are checked here.
|
||||
fn url_allowed(u: &url::Url) -> bool {
|
||||
if !matches!(u.scheme(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
match u.host() {
|
||||
Some(url::Host::Ipv4(ip)) => is_public_v4(ip),
|
||||
Some(url::Host::Ipv6(ip)) => is_public_ip(IpAddr::V6(ip)),
|
||||
Some(url::Host::Domain(d)) => !d.eq_ignore_ascii_case("localhost") && !d.ends_with(".localhost"),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client() -> anyhow::Result<reqwest::Client> {
|
||||
let redirect = reqwest::redirect::Policy::custom(|attempt| {
|
||||
if attempt.previous().len() >= 3 {
|
||||
attempt.error("too many redirects")
|
||||
} else if !url_allowed(attempt.url()) {
|
||||
attempt.error("redirect target not allowed")
|
||||
} else {
|
||||
attempt.follow()
|
||||
}
|
||||
});
|
||||
Ok(reqwest::Client::builder()
|
||||
.dns_resolver(std::sync::Arc::new(PublicOnlyResolver))
|
||||
.no_proxy()
|
||||
.redirect(redirect)
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(20))
|
||||
.user_agent("Mozilla/5.0 (compatible; 4ducky-fetch)")
|
||||
.build()?)
|
||||
}
|
||||
|
||||
pub async fn download(client: &reqwest::Client, raw: &str, max: usize) -> anyhow::Result<Vec<u8>> {
|
||||
let u = url::Url::parse(raw)?;
|
||||
anyhow::ensure!(url_allowed(&u), "url not allowed");
|
||||
let resp = client.get(u).send().await?.error_for_status()?;
|
||||
if let Some(len) = resp.content_length() {
|
||||
anyhow::ensure!(len as usize <= max, "too large");
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
let mut stream = resp.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
anyhow::ensure!(body.len() + chunk.len() <= max, "too large");
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_internal_addresses() {
|
||||
for ip in ["127.0.0.1", "10.1.2.3", "192.168.0.1", "169.254.169.254", "100.64.0.1", "::1", "fe80::1", "fd00::1", "::ffff:127.0.0.1", "0.0.0.0"] {
|
||||
assert!(!is_public_ip(ip.parse().unwrap()), "{ip} should be blocked");
|
||||
}
|
||||
assert!(is_public_ip("93.184.216.34".parse().unwrap()));
|
||||
assert!(is_public_ip("2606:4700::1111".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_rules() {
|
||||
assert!(!url_allowed(&url::Url::parse("file:///etc/passwd").unwrap()));
|
||||
assert!(!url_allowed(&url::Url::parse("http://127.0.0.1/a.png").unwrap()));
|
||||
assert!(!url_allowed(&url::Url::parse("http://localhost/a.png").unwrap()));
|
||||
assert!(url_allowed(&url::Url::parse("https://example.org/a.png").unwrap()));
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
//! Port of lib/gridview.php: assigns thumbnail size classes by score and packs
|
||||
//! them into a 12 column grid. The client re-solves for the real window width.
|
||||
|
||||
use crate::config::{GRID_SIZE, GRID_WIDTH, THUMB_CLASSES, thumb_class};
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
pub struct Thumb {
|
||||
pub logged: i64,
|
||||
pub keyword: String,
|
||||
pub thumb: String,
|
||||
pub score: f64,
|
||||
pub votes: i64,
|
||||
pub user_name: String,
|
||||
pub user_id: i64,
|
||||
#[sqlx(skip)]
|
||||
pub class: &'static str,
|
||||
#[sqlx(skip)]
|
||||
pub left: i64,
|
||||
#[sqlx(skip)]
|
||||
pub top: i64,
|
||||
}
|
||||
|
||||
/// Returns the grid height in grid units.
|
||||
pub fn solve(thumbs: &mut [Thumb]) -> i64 {
|
||||
let total = thumbs.len();
|
||||
if total == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Order by importance (score, then votes), keeping the original positions.
|
||||
let mut order: Vec<usize> = (0..total).collect();
|
||||
order.sort_by(|&a, &b| {
|
||||
thumbs[a]
|
||||
.score
|
||||
.partial_cmp(&thumbs[b].score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(thumbs[a].votes.cmp(&thumbs[b].votes))
|
||||
});
|
||||
|
||||
// How many thumbs get which class? The highest scored ones get the biggest box.
|
||||
let mut counts = Vec::new();
|
||||
let mut current = 0.0;
|
||||
for c in &THUMB_CLASSES {
|
||||
current += c.percentage * total as f64;
|
||||
counts.push((c.class, (current - 1e-9).ceil() as usize));
|
||||
}
|
||||
let mut it = counts.into_iter();
|
||||
let (mut class, mut max) = ("b2", 0usize);
|
||||
let mut first = true;
|
||||
for (j, &i) in order.iter().enumerate() {
|
||||
if first || j >= max {
|
||||
if let Some((c, m)) = it.next() {
|
||||
class = c;
|
||||
max = m;
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
thumbs[i].class = class;
|
||||
}
|
||||
|
||||
let mut grid = [0i64; GRID_WIDTH];
|
||||
for t in thumbs.iter_mut() {
|
||||
let tc = thumb_class(t.class);
|
||||
let (x, y) = insert(&mut grid, tc.width, tc.height as i64);
|
||||
t.left = x as i64 * GRID_SIZE;
|
||||
t.top = y * GRID_SIZE;
|
||||
}
|
||||
grid.iter().copied().max().unwrap_or(0)
|
||||
}
|
||||
|
||||
struct Spot {
|
||||
width: usize,
|
||||
left: usize,
|
||||
height: i64,
|
||||
}
|
||||
|
||||
fn insert(grid: &mut [i64; GRID_WIDTH], box_w: usize, box_h: i64) -> (usize, i64) {
|
||||
let mut max_height = 0;
|
||||
let mut current_height = grid[0];
|
||||
let mut spot_width = 0;
|
||||
let mut spot_left = 0;
|
||||
let mut spots = Vec::new();
|
||||
|
||||
for i in 0..GRID_WIDTH {
|
||||
if current_height == grid[i] {
|
||||
spot_width += 1;
|
||||
}
|
||||
if (current_height != grid[i] || i + 1 == GRID_WIDTH) && spot_width > 0 {
|
||||
spots.push(Spot { width: spot_width, left: spot_left, height: current_height });
|
||||
spot_width = 1;
|
||||
spot_left = i;
|
||||
if current_height != grid[i] && i + 1 == GRID_WIDTH {
|
||||
spots.push(Spot { width: spot_width, left: spot_left, height: grid[i] });
|
||||
}
|
||||
}
|
||||
current_height = grid[i];
|
||||
max_height = max_height.max(grid[i]);
|
||||
}
|
||||
|
||||
// Default spot (left border) if we don't find a better one.
|
||||
let mut target_height = grid[..box_w].iter().copied().max().unwrap_or(0);
|
||||
let mut target_left = 0;
|
||||
let mut best = -1.0;
|
||||
for s in &spots {
|
||||
let height_score = (max_height - s.height) as f64;
|
||||
let width_score = box_w as f64 / s.width as f64;
|
||||
let score = height_score * height_score + width_score * 2.0;
|
||||
if s.width >= box_w && score > best {
|
||||
target_height = s.height;
|
||||
target_left = s.left;
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
|
||||
let new_height = target_height + box_h;
|
||||
for j in 0..box_w {
|
||||
grid[target_left + j] = new_height;
|
||||
}
|
||||
(target_left, target_height)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn t(score: f64) -> Thumb {
|
||||
Thumb {
|
||||
logged: 0, keyword: String::new(), thumb: String::new(), score, votes: 0,
|
||||
user_name: String::new(), user_id: 0, class: "", left: 0, top: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classes_by_score() {
|
||||
let mut thumbs: Vec<Thumb> = (0..20).map(|i| t(i as f64)).collect();
|
||||
thumbs[0].score = 100.0;
|
||||
let h = solve(&mut thumbs);
|
||||
assert!(h > 0);
|
||||
assert_eq!(thumbs[0].class, "b0");
|
||||
assert_eq!(thumbs.iter().filter(|t| t.class == "b2").count(), 12);
|
||||
assert_eq!(thumbs.iter().filter(|t| t.class == "b1").count(), 7);
|
||||
// no overlapping boxes
|
||||
for (i, a) in thumbs.iter().enumerate() {
|
||||
let ca = thumb_class(a.class);
|
||||
for b in &thumbs[i + 1..] {
|
||||
let cb = thumb_class(b.class);
|
||||
let overlap = a.left < b.left + cb.width as i64 * 64
|
||||
&& b.left < a.left + ca.width as i64 * 64
|
||||
&& a.top < b.top + cb.height as i64 * 64
|
||||
&& b.top < a.top + ca.height as i64 * 64;
|
||||
assert!(!overlap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
//! Thumbnail creation, watermarking, color extraction and the spam image diff.
|
||||
//! Ports lib/images.php, the watermark() function, img_class.php and lib/keyword.php.
|
||||
|
||||
use std::{io::Cursor, path::Path};
|
||||
|
||||
use image::{
|
||||
DynamicImage, GenericImageView, ImageFormat, ImageReader, Limits, Rgba, imageops::FilterType,
|
||||
};
|
||||
|
||||
/// Only these formats are accepted (`getimagesize` + IMAGETYPE_* checks).
|
||||
pub fn detect_format(bytes: &[u8]) -> Option<(ImageFormat, &'static str)> {
|
||||
match image::guess_format(bytes).ok()? {
|
||||
ImageFormat::Jpeg => Some((ImageFormat::Jpeg, "jpg")),
|
||||
ImageFormat::Png => Some((ImageFormat::Png, "png")),
|
||||
ImageFormat::Gif => Some((ImageFormat::Gif, "gif")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes with hard limits: max 10000x10000 like createThumb() and bounded memory.
|
||||
pub fn decode(bytes: &[u8]) -> anyhow::Result<DynamicImage> {
|
||||
let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format()?;
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(10_000);
|
||||
limits.max_image_height = Some(10_000);
|
||||
limits.max_alloc = Some(512 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode()?;
|
||||
let (w, h) = img.dimensions();
|
||||
anyhow::ensure!(w >= 1 && h >= 1, "empty image");
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Center-crops to the target aspect ratio and resamples (`Image::createThumb`).
|
||||
pub fn make_thumb(img: &DynamicImage, tw: u32, th: u32) -> DynamicImage {
|
||||
let (sw, sh) = img.dimensions();
|
||||
let (sw_f, sh_f) = (sw as f64, sh as f64);
|
||||
let target = tw as f64 / th as f64;
|
||||
let (mut x, mut y, mut w, mut h) = (0.0, 0.0, sw_f, sh_f);
|
||||
if sw_f / sh_f > target {
|
||||
let zoom = (sw_f / sh_f) / target;
|
||||
w = sw_f / zoom;
|
||||
x = (sw_f - w) / 2.0;
|
||||
} else {
|
||||
let zoom = target / (sw_f / sh_f);
|
||||
h = sh_f / zoom;
|
||||
y = (sh_f - h) / 2.0;
|
||||
}
|
||||
let cropped = img.crop_imm(x as u32, y as u32, (w as u32).max(1), (h as u32).max(1));
|
||||
cropped.resize_exact(tw, th, FilterType::CatmullRom)
|
||||
}
|
||||
|
||||
pub fn save_jpeg(img: &DynamicImage, path: &Path, quality: u8) -> anyhow::Result<()> {
|
||||
let rgb = img.to_rgb8();
|
||||
let mut out = std::fs::File::create(path)?;
|
||||
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality);
|
||||
enc.encode_image(&rgb)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copies the watermark into the bottom right corner and re-encodes the image
|
||||
/// in its own format. GIFs are left alone (keeps animations), as before.
|
||||
pub fn watermark(bytes: &[u8], format: ImageFormat, mark: &DynamicImage, quality: u8) -> anyhow::Result<Vec<u8>> {
|
||||
if format == ImageFormat::Gif {
|
||||
return Ok(bytes.to_vec());
|
||||
}
|
||||
let mut img = decode(bytes)?.to_rgba8();
|
||||
let (iw, ih) = img.dimensions();
|
||||
let (mw, mh) = mark.dimensions();
|
||||
let x = iw as i64 - mw as i64;
|
||||
let y = ih as i64 - mh as i64;
|
||||
// imagecopy() with alpha blending disabled on the mark: overwrite pixels
|
||||
// incl. alpha, then flatten. Matches GD output for opaque targets.
|
||||
let mark = mark.to_rgba8();
|
||||
image::imageops::overlay(&mut img, &mark, x, y);
|
||||
let out_img = DynamicImage::ImageRgba8(img);
|
||||
let mut out = Vec::new();
|
||||
match format {
|
||||
ImageFormat::Jpeg => {
|
||||
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality);
|
||||
enc.encode_image(&out_img.to_rgb8())?;
|
||||
}
|
||||
_ => out_img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?,
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `Image::getColors($path, 4)` - 16 sample colors for the color search.
|
||||
pub fn colors(img: &DynamicImage) -> Vec<(u8, u8, u8)> {
|
||||
let small = img.resize_exact(4, 4, FilterType::Triangle).to_rgb8();
|
||||
small.pixels().map(|p| (p[0], p[1], p[2])).collect()
|
||||
}
|
||||
|
||||
/// `imagediff::imagehex()` - 20x20 luminance grid, quantized relative to the average.
|
||||
fn image_hex(img: &DynamicImage) -> Vec<u32> {
|
||||
let small = img.resize_exact(20, 20, FilterType::Nearest).to_rgba8();
|
||||
let mut map = [[0f64; 20]; 20];
|
||||
let mut average = 0.0;
|
||||
for x in 0..20 {
|
||||
for y in 0..20 {
|
||||
let Rgba([r, g, b, _]) = *small.get_pixel(x, y);
|
||||
let l = 0.212671 * r as f64 + 0.715160 * g as f64 + 0.072169 * b as f64;
|
||||
map[x as usize][y as usize] = l;
|
||||
average += l;
|
||||
}
|
||||
}
|
||||
average /= 400.0;
|
||||
let mut out = Vec::with_capacity(400);
|
||||
for row in &map {
|
||||
for &l in row {
|
||||
out.push(if average > 0.0 { (2.0 * l / average).round() as u32 } else { 0 });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `imagediff::diff()` - share of grid cells with the same quantized luminance.
|
||||
pub fn similarity(a: &DynamicImage, b: &DynamicImage) -> f64 {
|
||||
let (ha, hb) = (image_hex(a), image_hex(b));
|
||||
let same = ha.iter().zip(&hb).filter(|(x, y)| x == y).count();
|
||||
same as f64 / 400.0
|
||||
}
|
||||
|
||||
/// `Keyword::get()` - file name to URL slug.
|
||||
pub fn keyword(name: &str) -> String {
|
||||
let mut s = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
match c {
|
||||
'Ä' => s.push_str("Ae"),
|
||||
'Ö' => s.push_str("Oe"),
|
||||
'Ü' => s.push_str("Ue"),
|
||||
'ä' => s.push_str("ae"),
|
||||
'ö' => s.push_str("oe"),
|
||||
'ü' => s.push_str("ue"),
|
||||
'ß' => s.push_str("ss"),
|
||||
'\'' => {}
|
||||
c if c.is_ascii_alphanumeric() => s.push(c),
|
||||
_ => s.push('#'),
|
||||
}
|
||||
}
|
||||
let parts: Vec<&str> = s.split('#').filter(|p| !p.is_empty()).collect();
|
||||
let kw = parts.join("-");
|
||||
// Keep slugs (and file names) a sane length.
|
||||
let kw: String = kw.chars().take(100).collect();
|
||||
if kw.is_empty() { "untitled".into() } else { kw.to_lowercase() }
|
||||
}
|
||||
|
||||
/// `Keyword::iterate()` - append -2, -3, ... while taken.
|
||||
pub fn iterate_keyword(kw: &str, taken: &[String]) -> String {
|
||||
let lower = kw.to_lowercase();
|
||||
if !taken.contains(&lower) {
|
||||
return kw.to_string();
|
||||
}
|
||||
let mut i = 2;
|
||||
loop {
|
||||
let cand = format!("{kw}-{i}");
|
||||
if !taken.contains(&cand.to_lowercase()) {
|
||||
return cand;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keywords() {
|
||||
assert_eq!(keyword("Hällo Wörld's Bild!!"), "haello-woerlds-bild");
|
||||
assert_eq!(keyword("___"), "untitled");
|
||||
assert_eq!(keyword("../../etc/passwd"), "etc-passwd");
|
||||
let taken = vec!["dolan".to_string(), "dolan-2".to_string()];
|
||||
assert_eq!(iterate_keyword("dolan", &taken), "dolan-3");
|
||||
assert_eq!(iterate_keyword("gooby", &taken), "gooby");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_images_are_similar() {
|
||||
let img = DynamicImage::new_rgb8(50, 40);
|
||||
assert_eq!(similarity(&img, &img), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumb_dimensions() {
|
||||
let img = DynamicImage::new_rgb8(300, 100);
|
||||
let t = make_thumb(&img, 62, 62);
|
||||
assert_eq!(t.dimensions(), (62, 62));
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
//! Private messages (templates/inbox.tpl.php + inboxuser.tpl.php).
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
app::{AppResult, AppState, Ctx, Inner},
|
||||
browse::{blocked_ids, render},
|
||||
layout::Layout,
|
||||
util,
|
||||
};
|
||||
|
||||
struct Conversation {
|
||||
id: i64,
|
||||
name: String,
|
||||
name_enc: String,
|
||||
avatar: String,
|
||||
images: i64,
|
||||
website: Option<String>,
|
||||
title: String,
|
||||
border: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "inbox.html")]
|
||||
struct InboxPage {
|
||||
l: Layout,
|
||||
user_not_found: bool,
|
||||
user_blocked: bool,
|
||||
convos: Vec<Conversation>,
|
||||
}
|
||||
|
||||
struct Message {
|
||||
from_name: String,
|
||||
from_enc: String,
|
||||
date: String,
|
||||
body: String,
|
||||
border: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "inboxuser.html")]
|
||||
struct ConversationPage {
|
||||
l: Layout,
|
||||
border: &'static str,
|
||||
id: i64,
|
||||
name: String,
|
||||
name_enc: String,
|
||||
avatar: String,
|
||||
images: i64,
|
||||
website: Option<String>,
|
||||
messages: Vec<Message>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ConvoRow {
|
||||
id: i64,
|
||||
name: String,
|
||||
avatar: String,
|
||||
images: i64,
|
||||
website: String,
|
||||
title: Option<String>,
|
||||
unread: i64,
|
||||
}
|
||||
|
||||
async fn inbox_page(st: &Inner, ctx: &Ctx, user_not_found: bool, user_blocked: bool) -> AppResult {
|
||||
let uid = ctx.uid();
|
||||
let rows: Vec<ConvoRow> = sqlx::query_as(
|
||||
"SELECT u.id, u.name, u.avatar, u.images, u.website, \
|
||||
(SELECT title_m FROM messages WHERE from_m = m.from_m AND to_m = ?1 ORDER BY created DESC, id DESC LIMIT 1) AS title, \
|
||||
(SELECT COUNT(*) FROM messages WHERE from_m = m.from_m AND to_m = ?1 AND read_m = 0) AS unread \
|
||||
FROM messages m JOIN users u ON u.id = m.from_m WHERE m.to_m = ?1 \
|
||||
GROUP BY m.from_m ORDER BY MAX(m.created) DESC",
|
||||
)
|
||||
.bind(uid)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let blocked = blocked_ids(st, uid).await?;
|
||||
let read_border = if ctx.white() { " border:1px solid #d2d9e7; " } else { "border:1px solid #333;" };
|
||||
let convos = rows
|
||||
.into_iter()
|
||||
.filter(|r| !blocked.contains(&r.id))
|
||||
.map(|r| {
|
||||
let title = r.title.unwrap_or_default();
|
||||
Conversation {
|
||||
id: r.id,
|
||||
name_enc: util::path_enc(&r.name),
|
||||
name: r.name,
|
||||
avatar: r.avatar,
|
||||
images: r.images,
|
||||
website: util::safe_website(&r.website),
|
||||
title: if title.chars().count() > 1 { util::autolink_text(&util::strip_tags(&title)) } else { String::new() },
|
||||
border: if r.unread > 0 { " border:1px solid #FFD600; " } else { read_border },
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
render(InboxPage { l: Layout::new(st, ctx).await?, user_not_found, user_blocked, convos })
|
||||
}
|
||||
|
||||
pub async fn inbox(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
if ctx.user.is_none() {
|
||||
return Ok(Redirect::to("/login").into_response());
|
||||
}
|
||||
inbox_page(&st, &ctx, false, false).await
|
||||
}
|
||||
|
||||
pub async fn conversation(State(st): State<AppState>, ctx: Ctx, Path(name): Path<String>) -> AppResult {
|
||||
let Some(me) = &ctx.user else { return Ok(Redirect::to("/login").into_response()) };
|
||||
if !util::valid_username(&name) {
|
||||
return Ok(Redirect::to("/").into_response());
|
||||
}
|
||||
let other: Option<(i64, String, String, i64, String)> =
|
||||
sqlx::query_as("SELECT id, name, avatar, images, website FROM users WHERE name = ?")
|
||||
.bind(&name)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
let Some((oid, oname, avatar, images, website)) = other else { return Ok(Redirect::to("/").into_response()) };
|
||||
|
||||
let rows: Vec<(i64, i64, String, i64, i64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT m.from_m, m.to_m, m.message_m, m.read_m, m.created, u.name FROM messages m LEFT JOIN users u ON u.id = m.from_m \
|
||||
WHERE (m.to_m = ?1 AND m.from_m = ?2) OR (m.to_m = ?2 AND m.from_m = ?1) ORDER BY m.created DESC, m.id DESC",
|
||||
)
|
||||
.bind(oid)
|
||||
.bind(me.id)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE messages SET read_m = 1 WHERE to_m = ? AND from_m = ?")
|
||||
.bind(me.id)
|
||||
.bind(oid)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
|
||||
let border = if ctx.white() { " border:1px solid #d2d9e7; " } else { "border:1px dotted #333;" };
|
||||
let messages = rows
|
||||
.into_iter()
|
||||
.map(|(_, to, body, read, created, from_name)| {
|
||||
let from_name = from_name.unwrap_or_default();
|
||||
Message {
|
||||
from_enc: util::path_enc(&from_name),
|
||||
from_name,
|
||||
date: util::date("d. M Y H:i", created),
|
||||
body: util::autolink_text(&body),
|
||||
border: if read == 0 && to == me.id { "border:1px solid #FFD600".into() } else { border.into() },
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
render(ConversationPage {
|
||||
l: Layout::new(&st, &ctx).await?,
|
||||
border,
|
||||
id: oid,
|
||||
name_enc: util::path_enc(&oname),
|
||||
name: oname,
|
||||
avatar,
|
||||
images,
|
||||
website: util::safe_website(&website),
|
||||
messages,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct MessageForm {
|
||||
title: Option<String>,
|
||||
content: Option<String>,
|
||||
#[serde(rename = "_csrf")]
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
pub async fn send(State(st): State<AppState>, ctx: Ctx, Path(name): Path<String>, axum::Form(f): axum::Form<MessageForm>) -> AppResult {
|
||||
let Some(me) = ctx.user.clone() else { return Ok(Redirect::to("/login").into_response()) };
|
||||
ctx.check_csrf(&f.csrf)?;
|
||||
if !util::valid_username(&name) {
|
||||
return Ok(Redirect::to("/").into_response());
|
||||
}
|
||||
let target: Option<(i64, String)> = sqlx::query_as("SELECT id, name FROM users WHERE name = ?")
|
||||
.bind(&name)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
let Some((tid, tname)) = target else { return inbox_page(&st, &ctx, true, false).await };
|
||||
let blocked_me: Option<i64> = sqlx::query_scalar("SELECT 1 FROM blocked WHERE user_id = ? AND blocked_id = ?")
|
||||
.bind(tid)
|
||||
.bind(me.id)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
if blocked_me.is_some() {
|
||||
return inbox_page(&st, &ctx, false, true).await;
|
||||
}
|
||||
let content = f.content.unwrap_or_default();
|
||||
let title = f.title.unwrap_or_default();
|
||||
if !content.trim().is_empty() || !title.trim().is_empty() {
|
||||
st.limiter.check("message", &me.id.to_string(), 20, 60)?;
|
||||
sqlx::query("INSERT INTO messages (from_m, to_m, created, message_m, title_m) VALUES (?, ?, ?, ?, ?)")
|
||||
.bind(me.id)
|
||||
.bind(tid)
|
||||
.bind(util::now())
|
||||
.bind(content.chars().take(20_000).collect::<String>())
|
||||
.bind(title.chars().take(500).collect::<String>())
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
}
|
||||
Ok(Redirect::to(&format!("/inbox/{}", util::path_enc(&tname))).into_response())
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
//! Data for templates/header.html + footer.html (header.tpl.php / footer.tpl.php).
|
||||
|
||||
use crate::{
|
||||
app::{Ctx, Inner},
|
||||
util,
|
||||
};
|
||||
|
||||
pub struct UnreadItem {
|
||||
pub keyword: String,
|
||||
pub thumb: String,
|
||||
pub short: String,
|
||||
}
|
||||
|
||||
pub struct LayoutUser {
|
||||
pub name: String,
|
||||
pub name_enc: String,
|
||||
pub admin: bool,
|
||||
pub avatar: String,
|
||||
pub unread_msgs: i64,
|
||||
pub unread_count: i64,
|
||||
pub unread: Vec<UnreadItem>,
|
||||
}
|
||||
|
||||
pub struct Layout {
|
||||
pub title: String,
|
||||
pub white: bool,
|
||||
/// Icon suffix: "" for the white style, "_b" for black.
|
||||
pub b: &'static str,
|
||||
pub jscolor: bool,
|
||||
pub csrf: String,
|
||||
pub user: Option<LayoutUser>,
|
||||
/// Trusted HTML lines; non-empty opens the upload popup with errors.
|
||||
pub upload_errors: Vec<String>,
|
||||
/// pl_settings.message - admin controlled HTML, shown raw like before.
|
||||
pub message: String,
|
||||
/// Image whose comments the footer script loads (0 = none).
|
||||
pub view_image: i64,
|
||||
pub site_name: String,
|
||||
pub frontend_path: String,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub async fn new(st: &Inner, ctx: &Ctx) -> anyhow::Result<Self> {
|
||||
let white = ctx.white();
|
||||
let mut message = String::new();
|
||||
let user = match &ctx.user {
|
||||
None => None,
|
||||
Some(u) => {
|
||||
message = sqlx::query_scalar("SELECT message FROM settings WHERE id = 1").fetch_one(&st.db).await?;
|
||||
let unread_msgs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE to_m = ? AND read_m = 0")
|
||||
.bind(u.id)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
let unread_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM unread u JOIN images i ON i.id = u.imageid WHERE u.userid = ?",
|
||||
)
|
||||
.bind(u.id)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
let rows: Vec<(String, String, i64)> = if unread_count > 0 {
|
||||
sqlx::query_as(
|
||||
"SELECT i.keyword, i.thumb, i.logged FROM unread u JOIN images i ON i.id = u.imageid \
|
||||
WHERE u.userid = ? ORDER BY i.logged DESC LIMIT 5",
|
||||
)
|
||||
.bind(u.id)
|
||||
.fetch_all(&st.db)
|
||||
.await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Some(LayoutUser {
|
||||
name: u.name.clone(),
|
||||
name_enc: util::path_enc(&u.name),
|
||||
admin: u.is_admin(),
|
||||
avatar: u.avatar.clone(),
|
||||
unread_msgs,
|
||||
unread_count,
|
||||
unread: rows
|
||||
.into_iter()
|
||||
.map(|(keyword, thumb, logged)| UnreadItem {
|
||||
thumb: util::thumb_url(logged, "64x64", &thumb),
|
||||
short: util::shorter(&keyword, 20),
|
||||
keyword,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
};
|
||||
Ok(Layout {
|
||||
title: st.cfg.site_title.clone(),
|
||||
white,
|
||||
b: if white { "" } else { "_b" },
|
||||
jscolor: false,
|
||||
csrf: ctx.csrf.clone(),
|
||||
user,
|
||||
upload_errors: Vec::new(),
|
||||
message,
|
||||
view_image: 0,
|
||||
site_name: st.cfg.site_name.clone(),
|
||||
frontend_path: st.cfg.frontend_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `<title>`: [browsed user | ][image name | ]site title
|
||||
pub fn with_title(mut self, user: Option<&str>, pname: Option<&str>, site_title: &str) -> Self {
|
||||
let mut t = String::new();
|
||||
if let Some(u) = user {
|
||||
t.push_str(u);
|
||||
t.push_str(" | ");
|
||||
}
|
||||
if let Some(p) = pname.filter(|p| !p.is_empty()) {
|
||||
t.push_str(p);
|
||||
t.push_str(" | ");
|
||||
}
|
||||
t.push_str(site_title);
|
||||
self.title = t;
|
||||
self
|
||||
}
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
//! 4ducky.com - Rust port of the original PHP image board.
|
||||
|
||||
// Row tuples mirror the SQL selects; the grid solver is a literal port of the PHP/JS one.
|
||||
#![allow(clippy::type_complexity, clippy::needless_range_loop)]
|
||||
|
||||
mod account;
|
||||
mod app;
|
||||
mod auth;
|
||||
mod bbcode;
|
||||
mod browse;
|
||||
mod captcha;
|
||||
mod config;
|
||||
mod fetch;
|
||||
mod grid;
|
||||
mod imaging;
|
||||
mod inbox;
|
||||
mod layout;
|
||||
mod misc;
|
||||
mod uploader;
|
||||
mod util;
|
||||
mod view;
|
||||
|
||||
use std::{net::SocketAddr, path::PathBuf, str::FromStr, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
use clap::{Parser, Subcommand};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
use crate::{
|
||||
app::{AppState, Inner, RateLimiter},
|
||||
config::Config,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "4ducky.com image board")]
|
||||
struct Cli {
|
||||
/// Path to the config file (missing file = defaults).
|
||||
#[arg(long, default_value = "config.toml")]
|
||||
config: PathBuf,
|
||||
#[command(subcommand)]
|
||||
cmd: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Run the web server (default).
|
||||
Serve,
|
||||
/// Grant (or revoke) admin rights.
|
||||
Admin {
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
revoke: bool,
|
||||
},
|
||||
/// Change the site settings (pl_settings).
|
||||
Settings {
|
||||
/// Registration open: on/off
|
||||
#[arg(long)]
|
||||
register: Option<OnOff>,
|
||||
/// Uploads enabled: on/off
|
||||
#[arg(long)]
|
||||
upload: Option<OnOff>,
|
||||
/// The "Dolan" message shown to logged in users (HTML).
|
||||
#[arg(long)]
|
||||
message: Option<String>,
|
||||
},
|
||||
/// Create invite codes for a user (runfav.php).
|
||||
Invites {
|
||||
name: String,
|
||||
#[arg(long, default_value_t = 5)]
|
||||
count: u32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||
enum OnOff {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
async fn open_db(cfg: &Config) -> anyhow::Result<sqlx::SqlitePool> {
|
||||
if let Some(dir) = cfg.database.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", cfg.database.display()))?
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.busy_timeout(Duration::from_secs(10));
|
||||
let pool = SqlitePoolOptions::new().max_connections(8).connect_with(opts).await?;
|
||||
sqlx::migrate!("./migrations").run(&pool).await.context("running migrations")?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "fourducky=info,tower_http=warn".into()))
|
||||
.init();
|
||||
let cli = Cli::parse();
|
||||
let cfg = Config::load(&cli.config)?;
|
||||
let db = open_db(&cfg).await?;
|
||||
|
||||
match cli.cmd.unwrap_or(Cmd::Serve) {
|
||||
Cmd::Serve => serve(cfg, db).await,
|
||||
Cmd::Admin { name, revoke } => {
|
||||
let r = sqlx::query("UPDATE users SET admin = ? WHERE name = ?").bind(!revoke as i64).bind(&name).execute(&db).await?;
|
||||
anyhow::ensure!(r.rows_affected() == 1, "no user named {name}");
|
||||
println!("{name} is {}an admin now", if revoke { "no longer " } else { "" });
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Settings { register, upload, message } => {
|
||||
let flag = |v: OnOff| matches!(v, OnOff::On) as i64;
|
||||
if let Some(v) = register {
|
||||
sqlx::query("UPDATE settings SET register = ? WHERE id = 1").bind(flag(v)).execute(&db).await?;
|
||||
}
|
||||
if let Some(v) = upload {
|
||||
sqlx::query("UPDATE settings SET upload = ? WHERE id = 1").bind(flag(v)).execute(&db).await?;
|
||||
}
|
||||
if let Some(m) = message {
|
||||
sqlx::query("UPDATE settings SET message = ? WHERE id = 1").bind(m).execute(&db).await?;
|
||||
}
|
||||
let (r, u, m): (i64, i64, String) = sqlx::query_as("SELECT register, upload, message FROM settings WHERE id = 1").fetch_one(&db).await?;
|
||||
println!("register={r} upload={u} message={m}");
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Invites { name, count } => {
|
||||
let uid: i64 = sqlx::query_scalar("SELECT id FROM users WHERE name = ?")
|
||||
.bind(&name)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.with_context(|| format!("no user named {name}"))?;
|
||||
for _ in 0..count {
|
||||
let code = util::random_token(16);
|
||||
sqlx::query("INSERT INTO invites (owner, code, used, usedby) VALUES (?, ?, 0, 0)").bind(uid).bind(&code).execute(&db).await?;
|
||||
println!("{}invites/{code}", cfg.frontend_path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn origin(u: &str) -> Option<String> {
|
||||
let u = url::Url::parse(u).ok()?;
|
||||
Some(u.origin().ascii_serialization())
|
||||
}
|
||||
|
||||
async fn serve(cfg: Config, db: sqlx::SqlitePool) -> anyhow::Result<()> {
|
||||
// Directories for user content; the default avatar ships with the repo.
|
||||
for d in ["images", "thumbs", "avatars"] {
|
||||
std::fs::create_dir_all(cfg.data_dir.join(d))?;
|
||||
}
|
||||
let default_avatar = cfg.data_dir.join("avatars/default.png");
|
||||
if !default_avatar.exists() {
|
||||
std::fs::copy("data/avatars/default.png", &default_avatar).context("copying default avatar")?;
|
||||
}
|
||||
|
||||
let mut teasers: Vec<String> = std::fs::read_dir("images/rs")
|
||||
.context("reading images/rs")?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.ends_with(".jpg") || n.ends_with(".png"))
|
||||
.collect();
|
||||
teasers.sort();
|
||||
|
||||
let font = ab_glyph::FontArc::try_from_slice(include_bytes!("../media/luci.ttf"))?;
|
||||
let watermark = image::open("media/watermark.png").context("loading media/watermark.png")?;
|
||||
let embed_origins = [&cfg.chat_url, &cfg.forum_url, &cfg.shop_url]
|
||||
.iter()
|
||||
.filter_map(|u| origin(u))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let bind: SocketAddr = cfg.bind.parse().context("invalid bind address")?;
|
||||
let body_limit = cfg.max_download + 1024 * 1024;
|
||||
let data_dir = cfg.data_dir.clone();
|
||||
let st = AppState(Arc::new(Inner {
|
||||
db,
|
||||
http: fetch::client()?,
|
||||
font,
|
||||
watermark,
|
||||
embed_origins,
|
||||
teasers,
|
||||
limiter: RateLimiter::default(),
|
||||
cfg,
|
||||
}));
|
||||
|
||||
let pages: Router<AppState> = Router::new()
|
||||
.route("/", get(browse::index).post(view::search_post))
|
||||
.route("/all", get(browse::index))
|
||||
.route("/all/page/{n}", get(browse::all_page))
|
||||
.route("/all/view/{y}/{m}/{s}", get(view::view_all).post(view::view_post))
|
||||
.route("/user/{name}", get(browse::user))
|
||||
.route("/user/{name}/page/{n}", get(browse::user_page))
|
||||
.route("/user/{name}/view/{y}/{m}/{s}", get(view::view_user).post(view::view_post))
|
||||
.route("/search/{*rest}", get(view::search).post(view::view_post))
|
||||
.route("/comments", get(browse::comments))
|
||||
.route("/comments/{a}", get(browse::comments))
|
||||
.route("/comments/{a}/{n}", get(browse::comments))
|
||||
.route("/unread", get(browse::unread))
|
||||
.route("/unread/{a}", get(browse::unread))
|
||||
.route("/unread/{a}/{n}", get(browse::unread))
|
||||
.route("/favorites", get(browse::favorites))
|
||||
.route("/favorites/page/{n}", get(browse::favorites_page))
|
||||
.route("/fame", get(browse::fame))
|
||||
.route("/users", get(browse::users))
|
||||
.route("/users/page/{n}", get(browse::users))
|
||||
.route("/random/{*params}", get(browse::random_js))
|
||||
.route("/login", get(account::login_get).post(account::login_post))
|
||||
.route("/logout", get(account::logout))
|
||||
.route("/register", get(account::register_get).post(account::register_post))
|
||||
.route("/captcha/{file}", get(account::captcha_image))
|
||||
.route("/profile", get(account::profile_get).post(account::profile_post))
|
||||
.route("/invites", get(account::invites))
|
||||
.route("/invites/{code}", get(account::invite_code))
|
||||
.route("/inbox", get(inbox::inbox))
|
||||
.route("/inbox/{name}", get(inbox::conversation).post(inbox::send))
|
||||
.route("/manage", get(misc::manage))
|
||||
.route("/manage/{action}/{id}", get(misc::manage_action))
|
||||
.route("/comments.php", get(misc::admin_comments))
|
||||
.route("/abuse", post(misc::abuse))
|
||||
.route("/abuseform", get(misc::abuseform))
|
||||
.route("/impressum", get(misc::impressum))
|
||||
.route("/chat", get(misc::chat))
|
||||
.route("/truhe", get(misc::truhe))
|
||||
.route("/forum", get(misc::forum))
|
||||
.route("/shop", get(misc::shop))
|
||||
.route("/upload", get(view::upload_get).post(view::upload))
|
||||
.route("/quicktags", get(misc::quicktags))
|
||||
.route("/static/bookmarklet", get(misc::bookmarklet))
|
||||
.route("/ajax_getdata.php", post(view::ajax))
|
||||
.route("/json.php", post(view::json))
|
||||
.route("/remotepost.php", get(misc::remotepost_get).post(misc::remotepost_post))
|
||||
.route("/media/post.js.php", get(misc::post_js));
|
||||
|
||||
let app = pages
|
||||
.nest_service("/media", ServeDir::new("media"))
|
||||
.nest_service("/images", ServeDir::new("images"))
|
||||
.nest_service("/data/images", ServeDir::new(data_dir.join("images")))
|
||||
.nest_service("/data/thumbs", ServeDir::new(data_dir.join("thumbs")))
|
||||
.nest_service("/data/avatars", ServeDir::new(data_dir.join("avatars")))
|
||||
.route_service("/favicon.ico", ServeFile::new("favicon.ico"))
|
||||
.fallback(browse::fallback)
|
||||
.layer(DefaultBodyLimit::max(body_limit))
|
||||
.layer(middleware::from_fn_with_state(st.clone(), app::context_mw))
|
||||
.layer(middleware::from_fn_with_state(st.clone(), app::headers_mw))
|
||||
.with_state(st);
|
||||
|
||||
tracing::info!("4ducky listening on http://{bind}");
|
||||
let listener = tokio::net::TcpListener::bind(bind).await?;
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
//! Static-ish pages, abuse reports, admin pages, quicktagger and the bookmarklet.
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, header},
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
app::{AppResult, AppState, Ctx, Inner},
|
||||
auth::{self, LoginResult},
|
||||
browse::{TokenQuery, render},
|
||||
layout::Layout,
|
||||
uploader::{self, Target},
|
||||
util,
|
||||
};
|
||||
|
||||
macro_rules! simple_page {
|
||||
($fn:ident, $ty:ident, $path:literal) => {
|
||||
#[derive(Template)]
|
||||
#[template(path = $path)]
|
||||
struct $ty {
|
||||
l: Layout,
|
||||
}
|
||||
pub async fn $fn(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
render($ty { l: Layout::new(&st, &ctx).await? })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
simple_page!(impressum, ImpressumPage, "impressum.html");
|
||||
simple_page!(abuseform, AbuseFormPage, "abuseform.html");
|
||||
simple_page!(bookmarklet, BookmarkletPage, "bookmarklet.html");
|
||||
|
||||
// ---------------------------------------------------------------- chat / forum / shop
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "embed.html")]
|
||||
struct EmbedPage {
|
||||
l: Layout,
|
||||
kind: &'static str,
|
||||
url: String,
|
||||
}
|
||||
|
||||
async fn embed(st: &Inner, ctx: &Ctx, kind: &'static str, url: &str) -> AppResult {
|
||||
render(EmbedPage { l: Layout::new(st, ctx).await?, kind, url: url.to_string() })
|
||||
}
|
||||
|
||||
pub async fn chat(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
embed(&st, &ctx, "chat", &st.cfg.chat_url).await
|
||||
}
|
||||
|
||||
pub async fn forum(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
embed(&st, &ctx, "forum", &st.cfg.forum_url).await
|
||||
}
|
||||
|
||||
pub async fn shop(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
embed(&st, &ctx, "shop", &st.cfg.shop_url).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- truhe
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "truhe.html")]
|
||||
struct TruhePage {
|
||||
l: Layout,
|
||||
img: i64,
|
||||
duckys: i64,
|
||||
banned: i64,
|
||||
comm: i64,
|
||||
fav: i64,
|
||||
}
|
||||
|
||||
impl TruhePage {
|
||||
fn per_user(&self, v: &i64) -> f64 {
|
||||
if self.duckys == 0 { 0.0 } else { util::round2(*v as f64 / self.duckys as f64) }
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn truhe(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
let count = |sql: &'static str| sqlx::query_scalar::<_, i64>(sql).fetch_one(&st.db);
|
||||
render(TruhePage {
|
||||
l: Layout::new(&st, &ctx).await?,
|
||||
duckys: count("SELECT COUNT(*) FROM users").await?,
|
||||
banned: count("SELECT COUNT(*) FROM banned").await?,
|
||||
comm: count("SELECT COUNT(*) FROM comments").await?,
|
||||
img: count("SELECT COUNT(*) FROM images").await?,
|
||||
fav: count("SELECT COUNT(*) FROM fav").await?,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- abuse
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "abuse.html")]
|
||||
struct AbusePage {
|
||||
l: Layout,
|
||||
ok: bool,
|
||||
referer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct AbuseForm {
|
||||
rsn: String,
|
||||
mail: String,
|
||||
imglink: String,
|
||||
image: String,
|
||||
#[serde(rename = "_csrf")]
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
/// Only same-site referers become the "back" link.
|
||||
fn local_referer(headers: &HeaderMap, st: &Inner) -> Option<String> {
|
||||
let r = headers.get(header::REFERER)?.to_str().ok()?;
|
||||
let u = url::Url::parse(r).ok()?;
|
||||
let host = headers.get(header::HOST)?.to_str().ok()?;
|
||||
let own = url::Url::parse(&st.cfg.frontend_path).ok();
|
||||
let same = u.host_str().is_some_and(|h| host.split(':').next() == Some(h) || own.as_ref().and_then(|o| o.host_str()) == Some(h));
|
||||
same.then(|| format!("{}{}", u.path(), u.query().map(|q| format!("?{q}")).unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub async fn abuse(State(st): State<AppState>, ctx: Ctx, headers: HeaderMap, axum::Form(f): axum::Form<AbuseForm>) -> AppResult {
|
||||
ctx.check_csrf(&f.csrf)?;
|
||||
st.limiter.check("abuse", &ctx.ip, 10, 600)?;
|
||||
let referer = local_referer(&headers, &st);
|
||||
let cut = |s: &str, n: usize| s.chars().take(n).collect::<String>();
|
||||
let ok = !f.rsn.trim().is_empty() && !f.mail.trim().is_empty();
|
||||
if ok {
|
||||
sqlx::query("INSERT INTO abuse (reason, email, read, logged, imagelink, imageid, user) VALUES (?, ?, 0, ?, ?, ?, ?)")
|
||||
.bind(cut(&f.rsn, 2000))
|
||||
.bind(cut(&f.mail, 254))
|
||||
.bind(util::now())
|
||||
.bind(cut(&f.imglink, 2000))
|
||||
.bind(if f.imglink.is_empty() { f.image.parse::<i64>().unwrap_or(0) } else { 0 })
|
||||
.bind(ctx.uid())
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
}
|
||||
render(AbusePage { l: Layout::new(&st, &ctx).await?, ok, referer })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- manage (admin)
|
||||
|
||||
struct Spammer {
|
||||
id: i64,
|
||||
name: String,
|
||||
name_enc: String,
|
||||
avatar: String,
|
||||
images: i64,
|
||||
website: Option<String>,
|
||||
until: Option<String>,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
struct AbuseItem {
|
||||
id: i64,
|
||||
reason: String,
|
||||
email: String,
|
||||
thumb: Option<String>,
|
||||
keyword: String,
|
||||
user_name: Option<String>,
|
||||
imagelink: String,
|
||||
/// Only set for http(s) links - reporters control this value.
|
||||
imagelink_href: Option<String>,
|
||||
}
|
||||
|
||||
struct AdminComment {
|
||||
id: i64,
|
||||
content: String,
|
||||
name: String,
|
||||
name_enc: String,
|
||||
avatar: String,
|
||||
date: String,
|
||||
keyword: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "manage.html")]
|
||||
struct ManagePage {
|
||||
l: Layout,
|
||||
spammers: Vec<Spammer>,
|
||||
abuses: Vec<AbuseItem>,
|
||||
can_unblock: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "admin_comments.html")]
|
||||
struct AdminCommentsPage {
|
||||
l: Layout,
|
||||
comments: Vec<AdminComment>,
|
||||
spammers: Vec<Spammer>,
|
||||
abuses: Vec<AbuseItem>,
|
||||
}
|
||||
|
||||
async fn load_spammers(st: &Inner) -> anyhow::Result<Vec<Spammer>> {
|
||||
let rows: Vec<(i64, String, String, i64, String, i64, String)> = sqlx::query_as(
|
||||
"SELECT id, name, avatar, images, website, time, spam_reason FROM users WHERE spamblocked = 1 ORDER BY id",
|
||||
)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, avatar, images, website, time, reason)| Spammer {
|
||||
id,
|
||||
name_enc: util::path_enc(&name),
|
||||
name,
|
||||
avatar,
|
||||
images,
|
||||
website: util::safe_website(&website),
|
||||
until: (time > 0).then(|| util::date("Y-m-d H:i:s", time)),
|
||||
reason,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_abuses(st: &Inner) -> anyhow::Result<Vec<AbuseItem>> {
|
||||
let rows: Vec<(i64, String, String, i64, String, Option<String>, Option<String>, Option<i64>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT a.id, a.reason, a.email, a.imageid, a.imagelink, i.keyword, i.thumb, i.logged, u.name \
|
||||
FROM abuse a LEFT JOIN images i ON i.id = a.imageid LEFT JOIN users u ON u.id = a.user AND a.user != 0 \
|
||||
WHERE a.read = 0 ORDER BY a.id",
|
||||
)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
// Reports about images that are gone are hidden, like before.
|
||||
.filter(|r| r.3 == 0 || r.5.is_some())
|
||||
.map(|(id, reason, email, _imageid, imagelink, keyword, thumb, logged, user_name)| AbuseItem {
|
||||
id,
|
||||
reason,
|
||||
email,
|
||||
thumb: thumb.zip(logged).map(|(t, lg)| util::thumb_url(lg, "128x128", &t)),
|
||||
keyword: keyword.unwrap_or_default(),
|
||||
user_name,
|
||||
imagelink_href: util::safe_website(&imagelink),
|
||||
imagelink,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn manage(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
if !ctx.is_admin() {
|
||||
return Ok(Redirect::to("/").into_response());
|
||||
}
|
||||
render(ManagePage {
|
||||
spammers: load_spammers(&st).await?,
|
||||
abuses: load_abuses(&st).await?,
|
||||
can_unblock: st.cfg.unblockers.contains(&ctx.uid()),
|
||||
l: Layout::new(&st, &ctx).await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn manage_action(
|
||||
State(st): State<AppState>,
|
||||
ctx: Ctx,
|
||||
Path((action, id)): Path<(String, i64)>,
|
||||
Query(q): Query<TokenQuery>,
|
||||
) -> AppResult {
|
||||
if !ctx.is_admin() {
|
||||
return Ok(Redirect::to("/").into_response());
|
||||
}
|
||||
ctx.check_csrf(&q.t)?;
|
||||
match action.as_str() {
|
||||
"del" => {
|
||||
sqlx::query("DELETE FROM abuse WHERE id = ?").bind(id).execute(&st.db).await?;
|
||||
}
|
||||
"free" => {
|
||||
sqlx::query("UPDATE users SET spamblocked = 0, time = 0 WHERE id = ?").bind(id).execute(&st.db).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Redirect::to("/manage").into_response())
|
||||
}
|
||||
|
||||
/// comments.php - newest comments for admins.
|
||||
pub async fn admin_comments(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
if !ctx.is_admin() {
|
||||
return Ok("You need to be logged in as an admin user!".into_response());
|
||||
}
|
||||
let rows: Vec<(i64, String, Option<String>, Option<String>, i64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT c.id, c.content, u.name, u.avatar, c.created, i.keyword FROM comments c \
|
||||
LEFT JOIN users u ON u.id = c.userId LEFT JOIN images i ON i.id = c.imageId ORDER BY c.created DESC, c.id DESC LIMIT 100",
|
||||
)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let comments = rows
|
||||
.into_iter()
|
||||
.map(|(id, content, name, avatar, created, keyword)| {
|
||||
let name = name.unwrap_or_default();
|
||||
AdminComment {
|
||||
id,
|
||||
content,
|
||||
name_enc: util::path_enc(&name),
|
||||
name,
|
||||
avatar: avatar.unwrap_or_default(),
|
||||
date: util::date("d. M Y H:i", created),
|
||||
keyword: keyword.unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
render(AdminCommentsPage {
|
||||
comments,
|
||||
spammers: load_spammers(&st).await?,
|
||||
abuses: load_abuses(&st).await?,
|
||||
l: Layout::new(&st, &ctx).await?,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- quicktagger
|
||||
|
||||
simple_page!(quicktags_page, QuicktagsPage, "quicktags.html");
|
||||
|
||||
pub async fn quicktags(state: State<AppState>, ctx: Ctx) -> AppResult {
|
||||
if ctx.user.is_none() {
|
||||
return Ok(Redirect::to("/login").into_response());
|
||||
}
|
||||
quicktags_page(state, ctx).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- bookmarklet
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "remotepost.html")]
|
||||
struct RemotePostPage {
|
||||
site_title: String,
|
||||
site_name: String,
|
||||
white: bool,
|
||||
/// login | locked | ready | confirm | failed | posted
|
||||
status: &'static str,
|
||||
url: String,
|
||||
name: String,
|
||||
errors: Vec<String>,
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct RemoteQuery {
|
||||
url: String,
|
||||
frame: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct RemoteForm {
|
||||
url: String,
|
||||
name: String,
|
||||
pass: String,
|
||||
login: String,
|
||||
#[serde(rename = "_csrf")]
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
fn remote_page(st: &Inner, ctx: &Ctx, status: &'static str, url: String, errors: Vec<String>) -> AppResult {
|
||||
render(RemotePostPage {
|
||||
site_title: st.cfg.site_title.clone(),
|
||||
site_name: st.cfg.site_name.clone(),
|
||||
white: ctx.white(),
|
||||
status,
|
||||
url,
|
||||
name: String::new(),
|
||||
errors,
|
||||
csrf: ctx.csrf.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Shown inside the bookmarklet dialog iframe (instructions only, no cookies
|
||||
/// there) and in the popup window that does the actual posting.
|
||||
pub async fn remotepost_get(State(st): State<AppState>, ctx: Ctx, Query(q): Query<RemoteQuery>) -> AppResult {
|
||||
if !q.frame.is_empty() {
|
||||
return remote_page(&st, &ctx, "ready", String::new(), vec![]);
|
||||
}
|
||||
let Some(user) = &ctx.user else { return remote_page(&st, &ctx, "login", q.url, vec![]) };
|
||||
if q.url.is_empty() {
|
||||
return remote_page(&st, &ctx, "ready", String::new(), vec![]);
|
||||
}
|
||||
let errors = uploader::upload_errors(&st, user.id, &ctx.ip, false).await?;
|
||||
if !errors.is_empty() {
|
||||
return remote_page(&st, &ctx, "failed", q.url, errors);
|
||||
}
|
||||
remote_page(&st, &ctx, "confirm", q.url, vec![])
|
||||
}
|
||||
|
||||
pub async fn remotepost_post(State(st): State<AppState>, ctx: Ctx, axum::Form(f): axum::Form<RemoteForm>) -> AppResult {
|
||||
ctx.check_csrf(&f.csrf)?;
|
||||
let back = format!("/remotepost.php?url={}", percent_encoding::utf8_percent_encode(&f.url, percent_encoding::NON_ALPHANUMERIC));
|
||||
if !f.login.is_empty() {
|
||||
st.limiter.check("login", &ctx.ip, 10, 300)?;
|
||||
return match auth::login(&st, &f.name, &f.pass, true, &ctx.ip).await? {
|
||||
LoginResult::Ok { token, remember } => Ok(auth::login_redirect(&st, &token, remember, &back)),
|
||||
_ => remote_page(&st, &ctx, "login", f.url, vec![]),
|
||||
};
|
||||
}
|
||||
let Some(user) = ctx.user.clone() else { return remote_page(&st, &ctx, "login", f.url, vec![]) };
|
||||
let mut errors = uploader::upload_errors(&st, user.id, &ctx.ip, false).await?;
|
||||
if errors.is_empty() {
|
||||
match uploader::copy_from_url(&st, user.id, &f.url, Target::Image { tags: String::new(), source: String::new() }).await {
|
||||
Ok(_) => {
|
||||
uploader::log_upload(&st, user.id, &ctx.ip).await?;
|
||||
return remote_page(&st, &ctx, "posted", f.url, vec![]);
|
||||
}
|
||||
Err(e) => errors = e,
|
||||
}
|
||||
}
|
||||
remote_page(&st, &ctx, "failed", f.url, errors)
|
||||
}
|
||||
|
||||
/// media/post.js.php - the script the bookmarklet injects into other pages.
|
||||
pub async fn post_js(State(st): State<AppState>) -> AppResult {
|
||||
let js = include_str!("../assets/post.js")
|
||||
.replace("%POST_URL%", &serde_json::to_string(&format!("{}remotepost.php", st.cfg.frontend_path))?)
|
||||
.replace("%STYLESHEET%", &serde_json::to_string(&format!("{}media/remotepost.css", st.cfg.frontend_path))?);
|
||||
Ok(([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], js).into_response())
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
//! Port of lib/imageuploader.php plus the spam checks from lib/users.php
|
||||
//! (isSpamer, isSpamming, isSpamLocked, logUpload).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{
|
||||
app::Inner,
|
||||
config::{BORDER_WIDTH, GRID_SIZE, THUMB_CLASSES},
|
||||
fetch, imaging,
|
||||
util::{self, esc},
|
||||
};
|
||||
|
||||
pub enum Target {
|
||||
Image { tags: String, source: String },
|
||||
/// Image attached to the comment with this id.
|
||||
Comment(i64),
|
||||
}
|
||||
|
||||
/// Messages are trusted HTML (dynamic parts escaped), shown in the upload popup.
|
||||
pub type Errors = Vec<String>;
|
||||
|
||||
async fn settings_upload_enabled(st: &Inner) -> anyhow::Result<bool> {
|
||||
Ok(sqlx::query_scalar::<_, i64>("SELECT upload FROM settings WHERE id = 1").fetch_one(&st.db).await? == 1)
|
||||
}
|
||||
|
||||
/// `ImageUploader::copyFromUrl`
|
||||
pub async fn copy_from_url(st: &Inner, uid: i64, raw_url: &str, target: Target) -> Result<i64, Errors> {
|
||||
if !settings_upload_enabled(st).await.unwrap_or(false) {
|
||||
return Err(vec!["Upload disabled.".into()]);
|
||||
}
|
||||
let failed = || vec!["The Image could not be downloaded!".to_string()];
|
||||
let mut raw = raw_url.trim().to_string();
|
||||
if !raw.to_ascii_lowercase().starts_with("http://") && !raw.to_ascii_lowercase().starts_with("https://") {
|
||||
raw = format!("http://{raw}");
|
||||
}
|
||||
let Ok(u) = url::Url::parse(&raw) else { return Err(failed()) };
|
||||
// no recursion, no non-images
|
||||
let own_host = url::Url::parse(&st.cfg.frontend_path).ok().and_then(|f| f.host_str().map(str::to_string));
|
||||
if own_host.is_some() && u.host_str().map(str::to_string) == own_host {
|
||||
return Err(failed());
|
||||
}
|
||||
let file = u.path().rsplit('/').next().unwrap_or("").to_string();
|
||||
let Some((stem, ext)) = file.rsplit_once('.') else { return Err(failed()) };
|
||||
let ext = ext.to_ascii_lowercase();
|
||||
if !matches!(ext.as_str(), "gif" | "jpg" | "jpeg" | "png") {
|
||||
return Err(failed());
|
||||
}
|
||||
// use the domain name as part of the keyword
|
||||
let mut name = stem.to_string();
|
||||
if let Some(host) = u.host_str() {
|
||||
let labels: Vec<&str> = host.split('.').collect();
|
||||
if labels.len() >= 2 {
|
||||
name = format!("{}-{name}", labels[labels.len() - 2].to_lowercase());
|
||||
}
|
||||
}
|
||||
let name = format!("{}.{ext}", imaging::keyword(&name));
|
||||
|
||||
let bytes = match fetch::download(&st.http, u.as_str(), st.cfg.max_download).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::info!("download of {u} failed: {e:#}");
|
||||
return Err(failed());
|
||||
}
|
||||
};
|
||||
let target = match target {
|
||||
Target::Image { tags, .. } => Target::Image { tags, source: u.to_string() },
|
||||
t => t,
|
||||
};
|
||||
process(st, uid, &name, bytes, target).await
|
||||
}
|
||||
|
||||
/// `ImageUploader::process`
|
||||
pub async fn process(st: &Inner, uid: i64, name: &str, bytes: Vec<u8>, target: Target) -> Result<i64, Errors> {
|
||||
match process_inner(st, uid, name, bytes, target).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!("upload failed: {e:#}");
|
||||
Err(vec!["Error generating thumbnails!".into()])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_inner(st: &Inner, uid: i64, name: &str, bytes: Vec<u8>, target: Target) -> anyhow::Result<Result<i64, Errors>> {
|
||||
if !settings_upload_enabled(st).await? {
|
||||
return Ok(Err(vec!["Upload disabled.".into()]));
|
||||
}
|
||||
if uid == 0 {
|
||||
return Ok(Err(vec!["You are not logged in!".into()]));
|
||||
}
|
||||
let wrong_ext = || vec!["Your file has not the right extension (gif, jpg or png)!".to_string()];
|
||||
let Some((stem, ext)) = name.rsplit_once('.') else { return Ok(Err(wrong_ext())) };
|
||||
if !matches!(ext.to_ascii_lowercase().as_str(), "gif" | "jpg" | "jpeg" | "png") {
|
||||
return Ok(Err(wrong_ext()));
|
||||
}
|
||||
let Some((format, extension)) = imaging::detect_format(&bytes) else { return Ok(Err(wrong_ext())) };
|
||||
|
||||
let time = util::now();
|
||||
let ym = util::ym(time);
|
||||
let image_dir = st.data_path(&format!("data/images/{ym}"));
|
||||
let thumb_dir = st.data_path(&format!("data/thumbs/{ym}"));
|
||||
tokio::fs::create_dir_all(&image_dir).await?;
|
||||
|
||||
// Iterate over all keywords, if this one is already taken
|
||||
let base_kw = imaging::keyword(stem);
|
||||
let taken: Vec<String> = sqlx::query_scalar("SELECT keyword FROM images WHERE keyword LIKE ? ESCAPE '\\'")
|
||||
.bind(format!("{ym}/{}%", base_kw.replace('%', "\\%").replace('_', "\\_")))
|
||||
.fetch_all(&st.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|k: String| k.get(8..).unwrap_or_default().to_lowercase())
|
||||
.collect();
|
||||
let mut taken = taken;
|
||||
let keyword = loop {
|
||||
let kw = imaging::iterate_keyword(&base_kw, &taken);
|
||||
// Comment images don't have rows in `images`, so also check the disk.
|
||||
if tokio::fs::try_exists(image_dir.join(format!("{kw}.{extension}"))).await? {
|
||||
taken.push(kw.to_lowercase());
|
||||
continue;
|
||||
}
|
||||
break kw;
|
||||
};
|
||||
let file_name = format!("{keyword}.{extension}");
|
||||
let thumb_name = format!("{keyword}.jpg"); // all thumbs are jpegs!
|
||||
let image_path: PathBuf = image_dir.join(&file_name);
|
||||
|
||||
let hash = util::sha256_hex(&bytes);
|
||||
if matches!(target, Target::Image { .. }) {
|
||||
let dup: Option<String> = sqlx::query_scalar("SELECT keyword FROM images WHERE hash = ?")
|
||||
.bind(&hash)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
if let Some(kw) = dup {
|
||||
return Ok(Err(vec![format!(
|
||||
"This image is already in our database!<br /><a href='/all/view/{}'>Click <u>here</u> to comment it.</a>",
|
||||
esc(&kw)
|
||||
)]));
|
||||
}
|
||||
let banned: Option<i64> = sqlx::query_scalar("SELECT id FROM bannedimages WHERE hash = ?")
|
||||
.bind(&hash)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
if banned.is_some() {
|
||||
return Ok(Err(vec!["This image is already in our database!".into()]));
|
||||
}
|
||||
}
|
||||
|
||||
// Decode, make thumbs, watermark: CPU bound.
|
||||
let quality = st.cfg.jpeg_quality;
|
||||
let mark = st.watermark.clone();
|
||||
let thumb_dir2 = thumb_dir.clone();
|
||||
let thumb_name2 = thumb_name.clone();
|
||||
let image_path2 = image_path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<Option<Vec<(u8, u8, u8)>>> {
|
||||
let Ok(img) = imaging::decode(&bytes) else { return Ok(None) };
|
||||
for c in &THUMB_CLASSES {
|
||||
let dir = thumb_dir2.join(c.dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let tw = c.width as u32 * GRID_SIZE as u32 - BORDER_WIDTH;
|
||||
let th = c.height as u32 * GRID_SIZE as u32 - BORDER_WIDTH;
|
||||
imaging::save_jpeg(&imaging::make_thumb(&img, tw, th), &dir.join(&thumb_name2), quality)?;
|
||||
}
|
||||
let stored = imaging::watermark(&bytes, format, &mark, quality)?;
|
||||
std::fs::write(&image_path2, stored)?;
|
||||
Ok(Some(imaging::colors(&img)))
|
||||
})
|
||||
.await?;
|
||||
let colors = match result {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => return Ok(Err(vec!["Error generating thumbnails!".into()])),
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(&image_path).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
sqlx::query("UPDATE users SET score = score + ? WHERE id = ?")
|
||||
.bind(st.cfg.post_score)
|
||||
.bind(uid)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
|
||||
match target {
|
||||
Target::Comment(cid) => {
|
||||
sqlx::query("INSERT INTO commentuploads (c_id, image_link, thumb_link) VALUES (?, ?, ?)")
|
||||
.bind(cid)
|
||||
.bind(format!("data/images/{ym}/{file_name}"))
|
||||
.bind(format!("data/thumbs/{ym}/64x64/{thumb_name}"))
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
Ok(Ok(0))
|
||||
}
|
||||
Target::Image { tags, source } => {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO images (logged, user, score, votes, keyword, tags, image, thumb, hash, source) VALUES (?, ?, 1, 0, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(time)
|
||||
.bind(uid)
|
||||
.bind(format!("{ym}/{keyword}"))
|
||||
.bind(tags)
|
||||
.bind(&file_name)
|
||||
.bind(&thumb_name)
|
||||
.bind(&hash)
|
||||
.bind(source)
|
||||
.execute(&st.db)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
sqlx::query("UPDATE users SET images = (SELECT COUNT(*) FROM images WHERE user = ?) WHERE id = ?")
|
||||
.bind(uid)
|
||||
.bind(uid)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
for (r, g, b) in colors {
|
||||
sqlx::query("INSERT INTO imagecolors (imageId, r, g, b) VALUES (?, ?, ?, ?)")
|
||||
.bind(id)
|
||||
.bind(r as i64)
|
||||
.bind(g as i64)
|
||||
.bind(b as i64)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
}
|
||||
Ok(Ok(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SpamState {
|
||||
Ok,
|
||||
/// Blocked until the given timestamp.
|
||||
Until { until: i64, reason: String },
|
||||
/// Blocked without end date.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
/// `$user->isSpamming()`. With `check_diff` it also runs the image-spam
|
||||
/// detection (3 near identical uploads within a minute = 7 day block).
|
||||
pub async fn spam_state(st: &Inner, uid: i64, check_diff: bool) -> anyhow::Result<SpamState> {
|
||||
let (blocked, time, reason): (i64, i64, String) =
|
||||
sqlx::query_as("SELECT spamblocked, time, spam_reason FROM users WHERE id = ?")
|
||||
.bind(uid)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
if blocked == 1 {
|
||||
if time == 0 {
|
||||
return Ok(SpamState::Blocked);
|
||||
}
|
||||
if util::now() > time {
|
||||
sqlx::query("UPDATE users SET time = 0, spamblocked = 0 WHERE id = ?").bind(uid).execute(&st.db).await?;
|
||||
return Ok(SpamState::Ok);
|
||||
}
|
||||
return Ok(SpamState::Until { until: time, reason });
|
||||
}
|
||||
if !check_diff {
|
||||
return Ok(SpamState::Ok);
|
||||
}
|
||||
|
||||
// The last three uploads, newest first.
|
||||
let last: Vec<(i64, i64, String)> =
|
||||
sqlx::query_as("SELECT id, logged, image FROM images WHERE user = ? ORDER BY id DESC LIMIT 3")
|
||||
.bind(uid)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
if last.len() < 3 {
|
||||
return Ok(SpamState::Ok);
|
||||
}
|
||||
let newest = &last[0];
|
||||
let older: Vec<&(i64, i64, String)> = last[1..].iter().filter(|c| newest.1 - c.1 < 60).collect();
|
||||
if older.len() < 2 {
|
||||
return Ok(SpamState::Ok);
|
||||
}
|
||||
let path = |row: &(i64, i64, String)| st.data_path(&format!("data/images/{}/{}", util::ym(row.1), row.2));
|
||||
let newest_path = path(newest);
|
||||
let older_paths: Vec<PathBuf> = older.iter().map(|r| path(r)).collect();
|
||||
let hits = tokio::task::spawn_blocking(move || {
|
||||
let load = |p: &PathBuf| std::fs::read(p).ok().and_then(|b| imaging::decode(&b).ok());
|
||||
let Some(a) = load(&newest_path) else { return 0 };
|
||||
older_paths.iter().filter_map(load).filter(|b| imaging::similarity(&a, b) > 0.92).count()
|
||||
})
|
||||
.await?;
|
||||
if hits >= 2 {
|
||||
for row in &older {
|
||||
sqlx::query("DELETE FROM images WHERE id = ?").bind(row.0).execute(&st.db).await?;
|
||||
}
|
||||
sqlx::query("UPDATE users SET spamblocked = 1, spam_reason = 'Imagespam', time = ? WHERE id = ?")
|
||||
.bind(util::now() + 7 * 24 * 60 * 60)
|
||||
.bind(uid)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
return Ok(SpamState::Blocked);
|
||||
}
|
||||
Ok(SpamState::Ok)
|
||||
}
|
||||
|
||||
/// The block message lines shown in the upload popup and instead of the comment form.
|
||||
pub fn block_lines(until: i64, reason: &str) -> Vec<String> {
|
||||
let left = (until - util::now()) as f64 / 60.0;
|
||||
vec![
|
||||
format!("Upload gesperrt bis: {}", util::date("Y-m-d H:i:s", until)),
|
||||
format!("Grund: {}", esc(reason)),
|
||||
format!("Noch: {} Minuten\t", util::round2(left)),
|
||||
]
|
||||
}
|
||||
|
||||
/// All checks done before an upload (`index.php` upload branch / ajax `q=upload`).
|
||||
pub async fn upload_errors(st: &Inner, uid: i64, ip: &str, ajax: bool) -> anyhow::Result<Errors> {
|
||||
let mut errors = Vec::new();
|
||||
// isSpamer: last upload less than 10 seconds ago
|
||||
let last: Option<i64> = sqlx::query_scalar("SELECT MAX(logged) FROM images WHERE user = ?")
|
||||
.bind(uid)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
if last.is_some_and(|t| util::now() - t < 10) {
|
||||
errors.push(if ajax { "You have to wait 10 seconds." } else { "You have to wait 10 Seconds." }.into());
|
||||
return Ok(errors);
|
||||
}
|
||||
match spam_state(st, uid, true).await? {
|
||||
SpamState::Until { until, reason } => errors.extend(block_lines(until, &reason)),
|
||||
SpamState::Blocked => errors.push(format!(
|
||||
"Spamming ist nicht erlaubt. {} an einen Administrator <br> <br> Mann: <a href=\"/inbox/Mann\"><img src=\"/media/mail.png\"></a>",
|
||||
if ajax { "Wennde dich" } else { "Wende dich" }
|
||||
)),
|
||||
SpamState::Ok => {
|
||||
// isSpamLocked: too many uploads from this IP
|
||||
sqlx::query("DELETE FROM uploadlock WHERE ts < ?")
|
||||
.bind(util::now() - st.cfg.upload_lock_time)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
let c: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM uploadlock WHERE ip = ?")
|
||||
.bind(ip)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
if c > st.cfg.max_num_uploads {
|
||||
errors.push("No more than 50 images in 2 hours!".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(errors)
|
||||
}
|
||||
|
||||
pub async fn log_upload(st: &Inner, uid: i64, ip: &str) -> anyhow::Result<()> {
|
||||
sqlx::query("INSERT INTO uploadlock (ip, ts, user) VALUES (?, ?, ?)")
|
||||
.bind(ip)
|
||||
.bind(util::now())
|
||||
.bind(uid)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
//! Small helpers ported from the PHP templates (shorter, howlong, strip_tags, ...).
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use chrono::{Local, TimeZone};
|
||||
use rand::RngCore;
|
||||
use regex::Regex;
|
||||
|
||||
pub fn now() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
|
||||
/// Random URL-safe hex token with `bytes` bytes of entropy.
|
||||
pub fn random_token(bytes: usize) -> String {
|
||||
let mut buf = vec![0u8; bytes];
|
||||
rand::rng().fill_bytes(&mut buf);
|
||||
hex::encode(buf)
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: impl AsRef<[u8]>) -> String {
|
||||
use sha2::Digest;
|
||||
hex::encode(sha2::Sha256::digest(data.as_ref()))
|
||||
}
|
||||
|
||||
/// htmlspecialchars()
|
||||
pub fn esc(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 8);
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// nl2br() on already escaped text.
|
||||
pub fn nl2br(s: &str) -> String {
|
||||
s.replace("\r\n", "<br />\n").replace('\n', "<br />\n")
|
||||
}
|
||||
|
||||
static TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]*>?").unwrap());
|
||||
|
||||
/// strip_tags() - result is still unescaped text.
|
||||
pub fn strip_tags(s: &str) -> String {
|
||||
TAG_RE.replace_all(s, "").into_owned()
|
||||
}
|
||||
|
||||
/// `shorter($input, $length)`: cut at the last space before `length` chars and add "...".
|
||||
pub fn shorter(input: &str, length: usize) -> String {
|
||||
if input.chars().count() <= length {
|
||||
return input.to_string();
|
||||
}
|
||||
let cut: String = input.chars().take(length).collect();
|
||||
let trimmed = match cut.rfind(' ') {
|
||||
Some(pos) if pos > 0 => &cut[..pos],
|
||||
_ => cut.as_str(),
|
||||
};
|
||||
format!("{trimmed}...")
|
||||
}
|
||||
|
||||
/// `howlong()` from imageviewer.php ("sec"/"min" short form).
|
||||
pub fn howlong(a: i64, b: i64) -> String {
|
||||
howlong_impl(a, b, false)
|
||||
}
|
||||
|
||||
/// `howlong()` from ajax_getdata.php (long unit names, used for comments).
|
||||
pub fn howlong_long(a: i64, b: i64) -> String {
|
||||
howlong_impl(a, b, true)
|
||||
}
|
||||
|
||||
fn howlong_impl(a: i64, b: i64, long: bool) -> String {
|
||||
let (sec, secs, min, mins) = if long {
|
||||
("second", "seconds", "minute", "minutes")
|
||||
} else {
|
||||
("sec", "sec", "min", "min")
|
||||
};
|
||||
let a = if a == 0 { now() } else { a };
|
||||
let b = if b == 0 { now() } else { b };
|
||||
if a == b {
|
||||
return "at the same time".into();
|
||||
}
|
||||
let (a, b, q) = if a > b { (b, a, "away") } else { (a, b, "ago") };
|
||||
let mut value = (b - a) as f64;
|
||||
if value == 1.0 {
|
||||
return format!("1 {sec} {q}");
|
||||
}
|
||||
if value < 60.0 {
|
||||
return format!("{value} {secs} {q}");
|
||||
}
|
||||
value /= 60.0;
|
||||
if value.round() == 1.0 {
|
||||
return format!("1 {min} {q}");
|
||||
}
|
||||
if value < 60.0 {
|
||||
return format!("{} {mins} {q}", value.round());
|
||||
}
|
||||
value /= 60.0;
|
||||
if value.round() == 1.0 {
|
||||
return format!("1 hour {q}");
|
||||
}
|
||||
if value < 24.0 {
|
||||
return format!("{} hours {q}", value.round());
|
||||
}
|
||||
value /= 24.0;
|
||||
if value.round() == 1.0 {
|
||||
return if q == "ago" { "yesterday".into() } else { "tomorrow".into() };
|
||||
}
|
||||
if value < 31.0 {
|
||||
return format!("{} days {q}", value.round());
|
||||
}
|
||||
value /= 31.0;
|
||||
if value.round() == 1.0 {
|
||||
return format!("1 month {q}");
|
||||
}
|
||||
if value < 12.0 {
|
||||
return format!("{} months {q}", value.round());
|
||||
}
|
||||
value /= 12.0;
|
||||
if value.round() == 1.0 {
|
||||
return format!("1 year {q}");
|
||||
}
|
||||
format!("{} years {q}", value.round())
|
||||
}
|
||||
|
||||
/// PHP `date($fmt, $ts)` for the handful of formats the site uses.
|
||||
pub fn date(fmt: &str, ts: i64) -> String {
|
||||
let dt = Local.timestamp_opt(ts, 0).single().unwrap_or_else(Local::now);
|
||||
let f = match fmt {
|
||||
"d. M Y H:i" => "%d. %b %Y %H:%M",
|
||||
"Y-m-d H:i:s" => "%Y-%m-%d %H:%M:%S",
|
||||
"Y/m" => "%Y/%m",
|
||||
"Y/m/" => "%Y/%m/",
|
||||
"D M j G:i:s T Y" => "%a %b %-d %-H:%M:%S %Z %Y",
|
||||
other => other,
|
||||
};
|
||||
dt.format(f).to_string()
|
||||
}
|
||||
|
||||
static URL_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)(^|[^\w])((?:https?://)|(?:www\.))([^\s<>&]+(?:&[^\s<>&]+)*)").unwrap());
|
||||
|
||||
/// The inbox auto-linker: `nl2br(htmlspecialchars($s))` plus clickable links.
|
||||
pub fn autolink_text(s: &str) -> String {
|
||||
let escaped = nl2br(&esc(s));
|
||||
URL_RE
|
||||
.replace_all(&escaped, |c: ®ex::Captures| {
|
||||
let prefix = &c[2];
|
||||
let rest = &c[3];
|
||||
let href = if prefix.to_ascii_lowercase().starts_with("http") {
|
||||
format!("{prefix}{rest}")
|
||||
} else {
|
||||
format!("http://{prefix}{rest}")
|
||||
};
|
||||
let label = if prefix.to_ascii_lowercase().starts_with("http") { rest.to_string() } else { format!("{prefix}{rest}") };
|
||||
format!("{}<a href=\"{href}\">{label}</a>", &c[1])
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// `^\w{2,20}$` with PHP's (non-unicode) `\w`.
|
||||
pub fn valid_username(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(2..=20).contains(&len) && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
}
|
||||
|
||||
/// `/^[\.\w\-]{1,}@[\.\w\-]{2,}\.[\w]{2,}$/`
|
||||
pub fn valid_email(s: &str) -> bool {
|
||||
static RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[.\w\-]+@[.\w\-]{2,}\.\w{2,}$").unwrap());
|
||||
s.len() <= 254 && s.is_ascii() && RE.is_match(s)
|
||||
}
|
||||
|
||||
/// Only http(s) URLs may become links (the PHP version allowed `javascript:`).
|
||||
pub fn safe_website(s: &str) -> Option<String> {
|
||||
let s = s.trim();
|
||||
let lower = s.to_ascii_lowercase();
|
||||
if (lower.starts_with("http://") || lower.starts_with("https://")) && url::Url::parse(s).is_ok() {
|
||||
Some(s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `style="color:#xxxxxx"` for a user color, or empty. Only valid hex colors pass.
|
||||
pub fn color_style(color: &str) -> String {
|
||||
if color.len() == 6 && color.bytes().all(|b| b.is_ascii_hexdigit()) && color != "000000" {
|
||||
format!("color:#{color}")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// URL path segment encoding (for search terms and user names in links).
|
||||
pub fn path_enc(s: &str) -> String {
|
||||
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
|
||||
const SEG: &AsciiSet = &CONTROLS
|
||||
.add(b' ').add(b'"').add(b'#').add(b'<').add(b'>').add(b'?').add(b'`')
|
||||
.add(b'{').add(b'}').add(b'/').add(b'%').add(b'\'').add(b'\\');
|
||||
utf8_percent_encode(s, SEG).to_string()
|
||||
}
|
||||
|
||||
/// "YYYY/MM" directory for a timestamp (`date('Y/m', $ts)`).
|
||||
pub fn ym(ts: i64) -> String {
|
||||
date("Y/m", ts)
|
||||
}
|
||||
|
||||
pub fn thumb_url(logged: i64, size_dir: &str, thumb: &str) -> String {
|
||||
format!("/data/thumbs/{}/{}/{}", ym(logged), size_dir, thumb)
|
||||
}
|
||||
|
||||
/// Image file name without extension (`$pname` in the templates).
|
||||
pub fn pname(image: &str) -> String {
|
||||
image.split('.').next().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
pub fn round2(v: f64) -> f64 {
|
||||
(v * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shorter_cuts_at_space() {
|
||||
assert_eq!(shorter("hello world foo", 12), "hello world...");
|
||||
assert_eq!(shorter("short", 10), "short");
|
||||
assert_eq!(shorter("abcdefghijkl", 5), "abcde...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn howlong_units() {
|
||||
assert_eq!(howlong(100, 100), "at the same time");
|
||||
assert_eq!(howlong(100, 130), "30 sec ago");
|
||||
assert_eq!(howlong_long(0 + 1, 3601 + 1), "1 hour ago");
|
||||
assert_eq!(howlong(1000, 1000 + 86400 * 3), "3 days ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autolink_escapes() {
|
||||
let out = autolink_text("see www.x.com <b>");
|
||||
assert!(out.contains("<a href=\"http://www.x.com\">www.x.com</a>"));
|
||||
assert!(out.contains("<b>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn website_rejects_js() {
|
||||
assert!(safe_website("javascript:alert(1)").is_none());
|
||||
assert!(safe_website("https://example.org").is_some());
|
||||
}
|
||||
}
|
||||
+843
@@ -0,0 +1,843 @@
|
||||
//! Single image view (lib/imageviewer.php + templates/view.tpl.php), comments,
|
||||
//! favorites, ajax_getdata.php and json.php.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Multipart, Path, RawQuery, State},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Redirect},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
app::{AppError, AppResult, AppState, Ctx, Inner},
|
||||
bbcode,
|
||||
browse::{self, Mode, blocked_ids, render},
|
||||
config::THUMB_CLASSES,
|
||||
layout::Layout,
|
||||
uploader::{self, SpamState, Target},
|
||||
util,
|
||||
};
|
||||
|
||||
pub enum ViewCtx {
|
||||
All,
|
||||
User(String),
|
||||
Search(String),
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct ImageRow {
|
||||
pub id: i64,
|
||||
pub logged: i64,
|
||||
pub keyword: String,
|
||||
pub image: String,
|
||||
pub score: f64,
|
||||
pub user: i64,
|
||||
pub comment: i64,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
/// User title by id (view.tpl.php).
|
||||
fn view_rank(id: i64) -> &'static str {
|
||||
match id {
|
||||
180 => "ϟϟ xxx Stellvertreter",
|
||||
191 => "aka. katzenschnupfen :3",
|
||||
807 => "Beliebteste Neuente",
|
||||
29 => "RGR",
|
||||
74 => "ϟϟ Arschfodse",
|
||||
238 => "Musikente",
|
||||
297 => "DarkPsyPolizei",
|
||||
75 => "DJ",
|
||||
12 => "König der ✡ und ewiger Mod",
|
||||
id if id > 250 => "☪",
|
||||
id if id < 89 => "ϟϟ",
|
||||
id if id > 89 => "✡",
|
||||
_ => "☭",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "view.html")]
|
||||
struct ViewPage {
|
||||
l: Layout,
|
||||
ctx_user: Option<(String, String)>,
|
||||
pname: String,
|
||||
howlong: String,
|
||||
date: String,
|
||||
prev_href: Option<String>,
|
||||
next_href: Option<String>,
|
||||
logged_in: bool,
|
||||
image_src: String,
|
||||
image_id: i64,
|
||||
score: f64,
|
||||
up_id: i64,
|
||||
up_name: String,
|
||||
up_enc: String,
|
||||
up_images: i64,
|
||||
up_avatar: String,
|
||||
up_style: String,
|
||||
rank: &'static str,
|
||||
admin: bool,
|
||||
superadmin: bool,
|
||||
share_link: String,
|
||||
forum_code: String,
|
||||
html_code: String,
|
||||
search_link: String,
|
||||
edit_link: String,
|
||||
comment_open: bool,
|
||||
/// Some(lines) = comment form replaced by the spam block message.
|
||||
spam: Option<Vec<String>>,
|
||||
form_action: String,
|
||||
my_avatar: String,
|
||||
}
|
||||
|
||||
pub async fn load_image(st: &Inner, keyword: &str, user_id: i64) -> anyhow::Result<Option<ImageRow>> {
|
||||
Ok(sqlx::query_as(
|
||||
"SELECT i.id, i.logged, i.keyword, i.image, i.score, i.user, i.comment, COALESCE(u.color, '') AS color \
|
||||
FROM images i LEFT JOIN users u ON u.id = i.user WHERE i.keyword = ?1 AND (?2 = 0 OR i.user = ?2)",
|
||||
)
|
||||
.bind(keyword)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&st.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn view(st: &Inner, ctx: &Ctx, keyword: &str, vctx: ViewCtx) -> AppResult {
|
||||
let mut user_filter = 0;
|
||||
let mut base_path = "all/".to_string();
|
||||
let mut ctx_user = None;
|
||||
match &vctx {
|
||||
ViewCtx::All => {}
|
||||
ViewCtx::User(name) => {
|
||||
let u: Option<(i64, String)> = sqlx::query_as("SELECT id, name FROM users WHERE valid = 1 AND name = ?")
|
||||
.bind(name)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
if let Some((id, name)) = u {
|
||||
user_filter = id;
|
||||
base_path = format!("user/{}/", util::path_enc(&name));
|
||||
ctx_user = Some((name.clone(), util::path_enc(&name)));
|
||||
}
|
||||
}
|
||||
ViewCtx::Search(term) => base_path = format!("search/{}/", util::path_enc(term)),
|
||||
}
|
||||
|
||||
let Some(img) = load_image(st, keyword, user_filter).await? else {
|
||||
return browse::not_found(st, ctx).await;
|
||||
};
|
||||
let up: (i64, String, i64, String) = sqlx::query_as("SELECT id, name, images, avatar FROM users WHERE id = ?")
|
||||
.bind(img.user)
|
||||
.fetch_optional(&st.db)
|
||||
.await?
|
||||
.unwrap_or((img.user, String::new(), 0, "data/avatars/default.png".into()));
|
||||
|
||||
let next: Option<String> = sqlx::query_scalar(
|
||||
"SELECT keyword FROM images WHERE id < ?1 AND (?2 = 0 OR user = ?2) ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(img.id)
|
||||
.bind(user_filter)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
let prev: Option<String> = sqlx::query_scalar(
|
||||
"SELECT keyword FROM images WHERE id > ?1 AND (?2 = 0 OR user = ?2) ORDER BY id ASC LIMIT 1",
|
||||
)
|
||||
.bind(img.id)
|
||||
.bind(user_filter)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
|
||||
let uid = ctx.uid();
|
||||
if uid != 0 {
|
||||
sqlx::query("DELETE FROM unread WHERE userid = ? AND imageid = ?")
|
||||
.bind(uid)
|
||||
.bind(img.id)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let spam = if uid != 0 {
|
||||
match uploader::spam_state(st, uid, false).await? {
|
||||
SpamState::Ok => None,
|
||||
SpamState::Until { until, reason } => Some(uploader::block_lines(until, &reason)),
|
||||
SpamState::Blocked => Some(vec!["Spamblocked".into()]),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let path = format!("{}/{}", util::ym(img.logged), img.image);
|
||||
let fp = &st.cfg.frontend_path;
|
||||
let full = format!("{fp}data/images/{path}");
|
||||
let enc = |s: &str| percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string();
|
||||
let pname = util::pname(&img.image);
|
||||
let mut l = Layout::new(st, ctx).await?.with_title(None, Some(&pname), &st.cfg.site_title);
|
||||
l.view_image = img.id;
|
||||
render(ViewPage {
|
||||
ctx_user,
|
||||
howlong: util::howlong(img.logged, util::now()),
|
||||
date: util::date("d. M Y H:i", img.logged),
|
||||
prev_href: prev.map(|k| format!("/{base_path}view/{k}")),
|
||||
next_href: next.map(|k| format!("/{base_path}view/{k}")),
|
||||
logged_in: uid != 0,
|
||||
image_src: format!("/data/images/{path}"),
|
||||
image_id: img.id,
|
||||
score: img.score,
|
||||
up_id: up.0,
|
||||
up_enc: util::path_enc(&up.1),
|
||||
up_name: up.1,
|
||||
up_images: up.2,
|
||||
up_avatar: up.3,
|
||||
up_style: util::color_style(&img.color),
|
||||
rank: view_rank(up.0),
|
||||
admin: ctx.is_admin(),
|
||||
superadmin: ctx.is_admin() && st.cfg.is_superadmin(uid),
|
||||
share_link: format!("{fp}all/view/{}", img.keyword),
|
||||
forum_code: format!("[URL={fp}][IMG]{full}[/IMG][/URL]"),
|
||||
html_code: format!("<a target='_blank' href='{fp}'><img alt='4ducky.com' src='{full}'/></a>"),
|
||||
search_link: format!("https://www.google.com/searchbyimage?hl=en&safe=off&site=search&image_url={}", enc(&full)),
|
||||
edit_link: format!("http://imgops.com/{full}"),
|
||||
comment_open: img.comment == 0,
|
||||
spam,
|
||||
form_action: format!("/{base_path}view/{}", img.keyword),
|
||||
my_avatar: ctx.user.as_ref().map(|u| u.avatar.clone()).unwrap_or_default(),
|
||||
pname,
|
||||
l,
|
||||
})
|
||||
}
|
||||
|
||||
fn keyword_of(y: &str, m: &str, slug: &str) -> String {
|
||||
format!("{y}/{m}/{slug}")
|
||||
}
|
||||
|
||||
pub async fn view_all(State(st): State<AppState>, ctx: Ctx, Path((y, m, s)): Path<(String, String, String)>) -> AppResult {
|
||||
view(&st, &ctx, &keyword_of(&y, &m, &s), ViewCtx::All).await
|
||||
}
|
||||
|
||||
pub async fn view_user(
|
||||
State(st): State<AppState>,
|
||||
ctx: Ctx,
|
||||
Path((name, y, m, s)): Path<(String, String, String, String)>,
|
||||
) -> AppResult {
|
||||
view(&st, &ctx, &keyword_of(&y, &m, &s), ViewCtx::User(name)).await
|
||||
}
|
||||
|
||||
/// /search/<term>[/page/N] and /search/<term>/view/Y/m/slug
|
||||
pub async fn search(State(st): State<AppState>, ctx: Ctx, Path(rest): Path<String>) -> AppResult {
|
||||
if let Some((term, kw)) = rest.split_once("/view/") {
|
||||
return view(&st, &ctx, kw, ViewCtx::Search(term.to_string())).await;
|
||||
}
|
||||
let (term, page) = match rest.split_once("/page/") {
|
||||
Some((t, p)) => (t, p.parse().unwrap_or(1)),
|
||||
None => (rest.trim_end_matches('/'), 1),
|
||||
};
|
||||
browse::browse(&st, &ctx, Mode::Search(term.to_string()), page, vec![]).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchForm {
|
||||
#[serde(default)]
|
||||
q: String,
|
||||
}
|
||||
|
||||
/// No JS - redirect POSTed search to the GET search url.
|
||||
pub async fn search_post(axum::Form(f): axum::Form<SearchForm>) -> AppResult {
|
||||
Ok(Redirect::to(&format!("/search/{}", util::path_enc(&f.q))).into_response())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- comments
|
||||
|
||||
/// `ImageViewer::addComment`. Returns the new comment id.
|
||||
pub async fn add_comment(st: &Inner, image_id: i64, uid: i64, content: &str, has_image: bool) -> anyhow::Result<Option<i64>> {
|
||||
let content = content.trim_end();
|
||||
if uid == 0 || content.trim().is_empty() || content.chars().count() > 10_000 {
|
||||
return Ok(None);
|
||||
}
|
||||
let img: Option<(i64, String, i64)> = sqlx::query_as("SELECT user, keyword, comment FROM images WHERE id = ?")
|
||||
.bind(image_id)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
let Some((owner, keyword, closed)) = img else { return Ok(None) };
|
||||
if closed != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !matches!(uploader::spam_state(st, uid, false).await?, SpamState::Ok) {
|
||||
return Ok(None);
|
||||
}
|
||||
let now = util::now();
|
||||
let recent: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM comments WHERE created > ? AND userId = ?")
|
||||
.bind(now - 10)
|
||||
.bind(uid)
|
||||
.fetch_one(&st.db)
|
||||
.await?;
|
||||
if recent > 2 {
|
||||
sqlx::query("UPDATE users SET spamblocked = 1, spam_reason = 'Commentspam', time = ? WHERE id = ?")
|
||||
.bind(now + 1800)
|
||||
.bind(uid)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let cid = sqlx::query("INSERT INTO comments (imageId, userId, created, content, thumb, c_like) VALUES (?, ?, ?, ?, ?, 0)")
|
||||
.bind(image_id)
|
||||
.bind(uid)
|
||||
.bind(now)
|
||||
.bind(content)
|
||||
.bind(has_image as i64)
|
||||
.execute(&st.db)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
if owner != uid {
|
||||
sqlx::query("UPDATE images SET score = score + 1 WHERE id = ?").bind(image_id).execute(&st.db).await?;
|
||||
}
|
||||
// Everybody who commented on this image and the uploader get an unread marker.
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO unread (imageid, userid, keyword, createdby) \
|
||||
SELECT ?1, u, ?2, ?3 FROM (SELECT DISTINCT userId AS u FROM comments WHERE imageId = ?1 UNION SELECT ?4) WHERE u != ?3",
|
||||
)
|
||||
.bind(image_id)
|
||||
.bind(&keyword)
|
||||
.bind(uid)
|
||||
.bind(owner)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
Ok(Some(cid))
|
||||
}
|
||||
|
||||
/// Comment form POST with an image file (the AJAX path handles text-only comments).
|
||||
pub async fn view_post(
|
||||
State(st): State<AppState>,
|
||||
ctx: Ctx,
|
||||
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
|
||||
mut mp: Multipart,
|
||||
) -> AppResult {
|
||||
let back = uri.path().to_string();
|
||||
let mut csrf = String::new();
|
||||
let mut content = String::new();
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
let mut url = String::new();
|
||||
while let Some(field) = mp.next_field().await.map_err(|e| AppError::BadRequest(e.to_string()))? {
|
||||
match field.name().unwrap_or("") {
|
||||
"_csrf" => csrf = field.text().await.unwrap_or_default(),
|
||||
"content" => content = field.text().await.unwrap_or_default(),
|
||||
"url_comment" => url = field.text().await.unwrap_or_default(),
|
||||
"image_comment" => {
|
||||
let name = field.file_name().unwrap_or("").to_string();
|
||||
let data = field.bytes().await.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
if !name.is_empty() && !data.is_empty() {
|
||||
file = Some((name, data.to_vec()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ctx.check_csrf(&csrf)?;
|
||||
let uid = ctx.uid();
|
||||
let keyword = back.split("/view/").nth(1).unwrap_or("").to_string();
|
||||
if let Some(img) = load_image(&st, &keyword, 0).await?
|
||||
&& let Some(cid) = add_comment(&st, img.id, uid, &content, file.is_some()).await? {
|
||||
if let Some((name, data)) = file {
|
||||
let _ = uploader::process(&st, uid, &name, data, Target::Comment(cid)).await;
|
||||
} else if is_real_url(&url) {
|
||||
let _ = uploader::copy_from_url(&st, uid, &url, Target::Comment(cid)).await;
|
||||
}
|
||||
}
|
||||
Ok(Redirect::to(&back).into_response())
|
||||
}
|
||||
|
||||
/// The hidden comment URL field defaults to "http//URL Upload".
|
||||
fn is_real_url(u: &str) -> bool {
|
||||
let l = u.trim().to_ascii_lowercase();
|
||||
(l.starts_with("http://") || l.starts_with("https://")) && l.len() > 10
|
||||
}
|
||||
|
||||
pub async fn toggle_fav(st: &Inner, uid: i64, image_id: i64) -> anyhow::Result<Option<bool>> {
|
||||
let exists: Option<i64> = sqlx::query_scalar("SELECT id FROM images WHERE id = ?")
|
||||
.bind(image_id)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let removed = sqlx::query("DELETE FROM fav WHERE user = ? AND imageId = ?")
|
||||
.bind(uid)
|
||||
.bind(image_id)
|
||||
.execute(&st.db)
|
||||
.await?
|
||||
.rows_affected()
|
||||
> 0;
|
||||
if removed {
|
||||
sqlx::query("DELETE FROM comments WHERE userId = ? AND imageId = ? AND c_like = 1")
|
||||
.bind(uid)
|
||||
.bind(image_id)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE images SET score = score - 5 WHERE id = ?").bind(image_id).execute(&st.db).await?;
|
||||
Ok(Some(false))
|
||||
} else {
|
||||
sqlx::query("INSERT INTO fav (imageId, user) VALUES (?, ?)").bind(image_id).bind(uid).execute(&st.db).await?;
|
||||
sqlx::query("UPDATE images SET score = score + 5 WHERE id = ?").bind(image_id).execute(&st.db).await?;
|
||||
sqlx::query("INSERT INTO comments (imageId, userId, created, content, thumb, c_like) VALUES (?, ?, ?, 'i like', 0, 1)")
|
||||
.bind(image_id)
|
||||
.bind(uid)
|
||||
.bind(util::now())
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
Ok(Some(true))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ajax_getdata.php
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AjaxForm {
|
||||
#[serde(default)]
|
||||
q: String,
|
||||
#[serde(default, rename = "imageId")]
|
||||
image_id: String,
|
||||
#[serde(default)]
|
||||
content: String,
|
||||
#[serde(default, rename = "urlUpload")]
|
||||
url_upload: String,
|
||||
}
|
||||
|
||||
struct SideComment {
|
||||
keyword: String,
|
||||
avatar: String,
|
||||
name: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "ajax/lastcomments.html")]
|
||||
struct LastComments {
|
||||
items: Vec<SideComment>,
|
||||
}
|
||||
|
||||
struct Liker {
|
||||
user_id: i64,
|
||||
name_enc: String,
|
||||
avatar: String,
|
||||
}
|
||||
|
||||
struct CommentView {
|
||||
id: i64,
|
||||
user_id: i64,
|
||||
name: String,
|
||||
name_enc: String,
|
||||
avatar: String,
|
||||
style: String,
|
||||
howlong: String,
|
||||
html: String,
|
||||
text: String,
|
||||
blocked: bool,
|
||||
upload: Option<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "ajax/comments.html")]
|
||||
struct CommentsFrag {
|
||||
likes: Vec<Liker>,
|
||||
comments: Vec<CommentView>,
|
||||
admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "ajax/upload.html")]
|
||||
struct UploadFrag {
|
||||
errors: Vec<String>,
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "ajax/addon.html")]
|
||||
struct AddonFrag {
|
||||
frontend_path: String,
|
||||
site_name: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CommentRow {
|
||||
id: i64,
|
||||
c_like: i64,
|
||||
#[sqlx(rename = "userId")]
|
||||
user_id: i64,
|
||||
content: String,
|
||||
name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
color: Option<String>,
|
||||
created: i64,
|
||||
image_link: Option<String>,
|
||||
thumb_link: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn ajax(State(st): State<AppState>, ctx: Ctx, headers: HeaderMap, axum::Form(f): axum::Form<AjaxForm>) -> AppResult {
|
||||
ctx.check_csrf_header(&headers)?;
|
||||
let uid = ctx.uid();
|
||||
let image_id: i64 = f.image_id.parse().unwrap_or(0);
|
||||
let blocked = blocked_ids(&st, uid).await?;
|
||||
match f.q.as_str() {
|
||||
"lastcomments" => {
|
||||
let rows: Vec<(String, i64, Option<String>, Option<String>, String)> = sqlx::query_as(
|
||||
"SELECT i.keyword, c.userId, u.name, u.avatar, c.content FROM comments c \
|
||||
JOIN images i ON i.id = c.imageId LEFT JOIN users u ON u.id = c.userId \
|
||||
WHERE c.c_like = 0 ORDER BY c.created DESC, c.id DESC LIMIT 30",
|
||||
)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.filter(|r| !blocked.contains(&r.1))
|
||||
.map(|(keyword, _, name, avatar, content)| SideComment {
|
||||
keyword,
|
||||
avatar: avatar.unwrap_or_default(),
|
||||
name: util::shorter(&util::strip_tags(&name.unwrap_or_default()), 10),
|
||||
content: util::shorter(&util::strip_tags(&content), 50),
|
||||
})
|
||||
.collect();
|
||||
render(LastComments { items })
|
||||
}
|
||||
"comments" => {
|
||||
let rows: Vec<CommentRow> = sqlx::query_as(
|
||||
"SELECT c.id, c.c_like, c.userId, c.content, u.name, u.avatar, u.color, c.created, cu.image_link, cu.thumb_link \
|
||||
FROM comments c LEFT JOIN users u ON u.id = c.userId LEFT JOIN commentuploads cu ON cu.c_id = c.id \
|
||||
WHERE c.imageId = ? ORDER BY c.c_like DESC, c.created, c.id",
|
||||
)
|
||||
.bind(image_id)
|
||||
.fetch_all(&st.db)
|
||||
.await?;
|
||||
let now = util::now();
|
||||
let mut likes = Vec::new();
|
||||
let mut comments = Vec::new();
|
||||
for r in rows {
|
||||
let name = r.name.unwrap_or_default();
|
||||
let avatar = r.avatar.unwrap_or_else(|| "data/avatars/default.png".into());
|
||||
if r.c_like == 1 {
|
||||
likes.push(Liker { user_id: r.user_id, name_enc: util::path_enc(&name), avatar });
|
||||
continue;
|
||||
}
|
||||
comments.push(CommentView {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
name_enc: util::path_enc(&name),
|
||||
style: util::color_style(r.color.as_deref().unwrap_or("")),
|
||||
howlong: util::howlong_long(r.created, now),
|
||||
html: bbcode::render(&r.content),
|
||||
text: r.content.clone(),
|
||||
blocked: blocked.contains(&r.user_id),
|
||||
upload: r.image_link.zip(r.thumb_link),
|
||||
name,
|
||||
avatar,
|
||||
});
|
||||
}
|
||||
render(CommentsFrag { likes, comments, admin: ctx.is_admin() })
|
||||
}
|
||||
"add" => {
|
||||
if uid == 0 {
|
||||
return Ok("".into_response());
|
||||
}
|
||||
let with_url = is_real_url(&f.url_upload);
|
||||
match add_comment(&st, image_id, uid, &f.content, with_url).await? {
|
||||
Some(cid) if with_url => match uploader::copy_from_url(&st, uid, &f.url_upload, Target::Comment(cid)).await {
|
||||
Ok(_) => Ok("".into_response()),
|
||||
Err(_) => Ok("notok-image".into_response()),
|
||||
},
|
||||
Some(_) => Ok("".into_response()),
|
||||
None => Ok("notok-comment".into_response()),
|
||||
}
|
||||
}
|
||||
"fav" => {
|
||||
if uid == 0 {
|
||||
return Ok("".into_response());
|
||||
}
|
||||
Ok(match toggle_fav(&st, uid, image_id).await? {
|
||||
Some(true) => "Added to your favorites",
|
||||
Some(false) => "Removed from your favorites",
|
||||
None => "",
|
||||
}
|
||||
.into_response())
|
||||
}
|
||||
"upload" => {
|
||||
let errors = if uid != 0 {
|
||||
uploader::upload_errors(&st, uid, &ctx.ip, true).await?
|
||||
} else {
|
||||
vec!["Log in.".into()]
|
||||
};
|
||||
render(UploadFrag { errors, csrf: ctx.csrf.clone() })
|
||||
}
|
||||
"addon" => render(AddonFrag { frontend_path: st.cfg.frontend_path.clone(), site_name: st.cfg.site_name.clone() }),
|
||||
_ => Ok("Error".into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- upload
|
||||
|
||||
/// POST /upload - the form from the upload popup.
|
||||
pub async fn upload(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 fields: HashMap<String, String> = HashMap::new();
|
||||
let mut file: Option<(String, 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 == "image" {
|
||||
let fname = field.file_name().unwrap_or("").to_string();
|
||||
let data = field.bytes().await.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
if !fname.is_empty() && !data.is_empty() {
|
||||
file = Some((fname, data.to_vec()));
|
||||
}
|
||||
} else {
|
||||
fields.insert(name, field.text().await.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
let get = |k: &str| fields.get(k).cloned().unwrap_or_default();
|
||||
ctx.check_csrf(&get("_csrf"))?;
|
||||
|
||||
let mut errors = uploader::upload_errors(&st, user.id, &ctx.ip, false).await?;
|
||||
if errors.is_empty() {
|
||||
let tags = get("tags");
|
||||
let result = if !get("url").trim().is_empty() {
|
||||
Some(uploader::copy_from_url(&st, user.id, &get("url"), Target::Image { tags, source: String::new() }).await)
|
||||
} else if let Some((name, data)) = file {
|
||||
Some(uploader::process(&st, user.id, &name, data, Target::Image { tags, source: String::new() }).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match result {
|
||||
Some(Ok(image_id)) => {
|
||||
let comment = get("ucomment");
|
||||
if !comment.trim().is_empty() {
|
||||
sqlx::query("INSERT INTO comments (imageId, userId, created, content, thumb, c_like) VALUES (?, ?, ?, ?, 0, 0)")
|
||||
.bind(image_id)
|
||||
.bind(user.id)
|
||||
.bind(util::now())
|
||||
.bind(comment.trim_end())
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
}
|
||||
uploader::log_upload(&st, user.id, &ctx.ip).await?;
|
||||
return Ok(Redirect::to("/").into_response());
|
||||
}
|
||||
Some(Err(e)) => errors = e,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
browse::browse(&st, &ctx, Mode::All, 1, errors).await
|
||||
}
|
||||
|
||||
pub async fn upload_get(State(st): State<AppState>, ctx: Ctx) -> AppResult {
|
||||
if ctx.user.is_none() {
|
||||
return Ok(Redirect::to("/login").into_response());
|
||||
}
|
||||
browse::browse(&st, &ctx, Mode::All, 1, vec![]).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- json.php
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct JsonForm {
|
||||
id: String,
|
||||
rsn: String,
|
||||
hours: String,
|
||||
tags: String,
|
||||
#[serde(rename = "logId")]
|
||||
log_id: String,
|
||||
}
|
||||
|
||||
async fn remove_comment_upload(st: &Inner, cid: i64) -> anyhow::Result<()> {
|
||||
let row: Option<(String, String)> = sqlx::query_as("SELECT image_link, thumb_link FROM commentuploads WHERE c_id = ?")
|
||||
.bind(cid)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
if let Some((image, thumb)) = row {
|
||||
let _ = tokio::fs::remove_file(st.data_path(&image)).await;
|
||||
for c in &THUMB_CLASSES {
|
||||
let _ = tokio::fs::remove_file(st.data_path(&thumb.replace("64x64", c.dir))).await;
|
||||
}
|
||||
sqlx::query("DELETE FROM commentuploads WHERE c_id = ?").bind(cid).execute(&st.db).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_image(st: &Inner, id: i64) -> anyhow::Result<()> {
|
||||
let row: Option<(i64, i64, String, String, String)> =
|
||||
sqlx::query_as("SELECT user, logged, image, thumb, hash FROM images WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
let Some((owner, logged, image, thumb, hash)) = row else { return Ok(()) };
|
||||
if !hash.is_empty() {
|
||||
sqlx::query("INSERT OR IGNORE INTO bannedimages (hash) VALUES (?)").bind(&hash).execute(&st.db).await?;
|
||||
}
|
||||
let cids: Vec<i64> = sqlx::query_scalar("SELECT id FROM comments WHERE imageId = ?").bind(id).fetch_all(&st.db).await?;
|
||||
for cid in cids {
|
||||
remove_comment_upload(st, cid).await?;
|
||||
}
|
||||
for sql in [
|
||||
"DELETE FROM comments WHERE imageId = ?",
|
||||
"DELETE FROM images WHERE id = ?",
|
||||
"DELETE FROM imagecolors WHERE imageId = ?",
|
||||
"DELETE FROM abuse WHERE imageid = ?",
|
||||
"DELETE FROM unread WHERE imageid = ?",
|
||||
"DELETE FROM fav WHERE imageId = ?",
|
||||
"DELETE FROM taglog WHERE imageId = ?",
|
||||
] {
|
||||
sqlx::query(sql).bind(id).execute(&st.db).await?;
|
||||
}
|
||||
let ym = util::ym(logged);
|
||||
for c in &THUMB_CLASSES {
|
||||
let _ = tokio::fs::remove_file(st.data_path(&format!("data/thumbs/{ym}/{}/{thumb}", c.dir))).await;
|
||||
}
|
||||
let _ = tokio::fs::remove_file(st.data_path(&format!("data/images/{ym}/{image}"))).await;
|
||||
sqlx::query("UPDATE users SET images = images - 1, score = score - ? WHERE id = ?")
|
||||
.bind(st.cfg.post_score)
|
||||
.bind(owner)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn json(
|
||||
State(st): State<AppState>,
|
||||
ctx: Ctx,
|
||||
headers: HeaderMap,
|
||||
RawQuery(action): RawQuery,
|
||||
axum::Form(f): axum::Form<JsonForm>,
|
||||
) -> AppResult {
|
||||
ctx.check_csrf_header(&headers)?;
|
||||
let action = action.unwrap_or_default();
|
||||
let action = action.split(['&', '=']).next().unwrap_or("");
|
||||
let uid = ctx.uid();
|
||||
let admin = ctx.is_admin();
|
||||
let superadmin = admin && st.cfg.is_superadmin(uid);
|
||||
let id: i64 = f.id.parse().unwrap_or(0);
|
||||
let forbidden = || Err(AppError::Forbidden("You need to be logged in as an admin user!"));
|
||||
|
||||
match action {
|
||||
"delete" => {
|
||||
if !admin {
|
||||
return forbidden();
|
||||
}
|
||||
delete_image(&st, id).await?;
|
||||
}
|
||||
"deleteComment" => {
|
||||
if !admin {
|
||||
return forbidden();
|
||||
}
|
||||
remove_comment_upload(&st, id).await?;
|
||||
sqlx::query("DELETE FROM comments WHERE id = ?").bind(id).execute(&st.db).await?;
|
||||
}
|
||||
"banUser" => {
|
||||
if !superadmin {
|
||||
return forbidden();
|
||||
}
|
||||
sqlx::query("INSERT INTO banned (id, reason) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET reason = excluded.reason")
|
||||
.bind(id)
|
||||
.bind(&f.rsn)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM sessions WHERE user_id = ?").bind(id).execute(&st.db).await?;
|
||||
return Ok(format!("user banned, {id}").into_response());
|
||||
}
|
||||
"TimeUser" => {
|
||||
if !admin {
|
||||
return forbidden();
|
||||
}
|
||||
let hours: i64 = f.hours.trim().parse().unwrap_or(0).clamp(0, 24 * 365 * 10);
|
||||
sqlx::query("UPDATE users SET spamblocked = 1, spam_reason = ?, time = ? WHERE id = ?")
|
||||
.bind(&f.rsn)
|
||||
.bind(util::now() + 3600 * hours)
|
||||
.bind(id)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
}
|
||||
"fame" => {
|
||||
if !superadmin {
|
||||
return forbidden();
|
||||
}
|
||||
sqlx::query("UPDATE images SET fame = 1 WHERE id = ?").bind(id).execute(&st.db).await?;
|
||||
}
|
||||
"CloseThread" => {
|
||||
if !admin {
|
||||
return forbidden();
|
||||
}
|
||||
sqlx::query("UPDATE images SET comment = 1 WHERE id = ?").bind(id).execute(&st.db).await?;
|
||||
}
|
||||
"addTags" if uid != 0 && f.tags.trim().chars().count() >= 3 => {
|
||||
let row: Option<(i64, String)> = sqlx::query_as("SELECT id, tags FROM images WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
if let Some((iid, old)) = row {
|
||||
let tags = if admin { f.tags.trim().to_string() } else { format!("{} {}", old.trim(), f.tags.trim()).trim().to_string() };
|
||||
sqlx::query("UPDATE images SET tags = ? WHERE id = ?").bind(&tags).bind(iid).execute(&st.db).await?;
|
||||
add_tag_score(&st, uid, &f.tags).await?;
|
||||
return Ok(Json(json!({"id": iid.to_string(), "tags": tags})).into_response());
|
||||
}
|
||||
}
|
||||
"quickTag" if uid != 0 => return quick_tag(&st, uid, &f).await,
|
||||
_ => {}
|
||||
}
|
||||
Ok(Json(json!({})).into_response())
|
||||
}
|
||||
|
||||
async fn add_tag_score(st: &Inner, uid: i64, tags: &str) -> anyhow::Result<()> {
|
||||
let score = (tags.trim().chars().count() as f64 * st.cfg.tag_score_per_char).min(st.cfg.tag_score_max);
|
||||
sqlx::query("UPDATE users SET score = score + ? WHERE id = ?")
|
||||
.bind(score as i64)
|
||||
.bind(uid)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn quick_tag(st: &Inner, uid: i64, f: &JsonForm) -> AppResult {
|
||||
let now = util::now();
|
||||
let id: i64 = f.id.parse().unwrap_or(0);
|
||||
if id != 0 {
|
||||
// remove lock
|
||||
sqlx::query("UPDATE taglog SET locked = 0 WHERE id = ? AND userId = ?")
|
||||
.bind(f.log_id.parse::<i64>().unwrap_or(0))
|
||||
.bind(uid)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
if f.tags.trim().chars().count() >= 3 {
|
||||
sqlx::query("UPDATE images SET tags = TRIM(TRIM(tags) || ' ' || ?) WHERE id = ?")
|
||||
.bind(f.tags.trim())
|
||||
.bind(id)
|
||||
.execute(&st.db)
|
||||
.await?;
|
||||
add_tag_score(st, uid, &f.tags).await?;
|
||||
}
|
||||
}
|
||||
let next: Option<(i64, String, String, String, i64)> = sqlx::query_as(
|
||||
"SELECT i.id, i.keyword, i.tags, i.image, i.logged FROM images i WHERE NOT EXISTS ( \
|
||||
SELECT 1 FROM taglog t WHERE t.imageId = i.id AND ((t.userId = ?1 AND t.tagged > ?2) OR (t.locked = 1 AND t.tagged > ?3))) \
|
||||
ORDER BY LENGTH(i.tags), i.logged DESC LIMIT 1",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(now - 86400)
|
||||
.bind(now - 300)
|
||||
.fetch_optional(&st.db)
|
||||
.await?;
|
||||
let Some((iid, keyword, tags, image, logged)) = next else { return Ok(Json(json!({})).into_response()) };
|
||||
let log_id = sqlx::query("INSERT INTO taglog (tagged, userId, imageId, locked) VALUES (?, ?, ?, 1)")
|
||||
.bind(now)
|
||||
.bind(uid)
|
||||
.bind(iid)
|
||||
.execute(&st.db)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
Ok(Json(json!({
|
||||
"id": iid.to_string(),
|
||||
"tags": tags,
|
||||
"link": format!("/all/view/{keyword}"),
|
||||
"image": format!("/data/images/{}/{image}", util::ym(logged)),
|
||||
"logId": log_id.to_string(),
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% include "header.html" %}
|
||||
|
||||
|
||||
<div class="warn"><img style="float: left; margin: 100px;" alt="404" src="/media/404.jpg"/>Broken link - no image found!<br/>
|
||||
<a href="/">Go back to the main...</a></div>
|
||||
|
||||
|
||||
|
||||
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% include "header.html" %}
|
||||
{%- if ok %}
|
||||
<div class="success" style="text-align:center">
|
||||
Abused. Wait for response.<br><br>
|
||||
<strong>{% if let Some(r) = referer %}<a href="{{ r }}">» back</a>{% endif %}</strong>
|
||||
</div>
|
||||
{%- else %}
|
||||
<div class="warn" style="text-align:center">
|
||||
Abused failed.<br><br>
|
||||
<strong>{% if let Some(r) = referer %}<a href="{{ r }}">» back</a>{% endif %}</strong>
|
||||
</div>
|
||||
{%- endif %}
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% include "header.html" %}
|
||||
<div align="center">
|
||||
|
||||
<form action="/abuse" method="post">
|
||||
|
||||
<fieldset>
|
||||
<legend>Abuse // Contact: abuse@4ducky.com</legend>
|
||||
<p style="text-align:left;">Report Abuse or Request Deletion (Copyrighted Material, Pornography, Harassment) If requesting the removal of copyrighted materials, you must be the owner or legally represent the owner of said materials. Report it here:</p>
|
||||
<dl class="form">
|
||||
|
||||
<dt>Imagelink:</dt>
|
||||
<dd>
|
||||
<input type="text" name="imglink" value=""/>
|
||||
</dd>
|
||||
<dt>Reason:</dt>
|
||||
<dd>
|
||||
<input type="text" name="rsn" value=""/>
|
||||
</dd>
|
||||
|
||||
<dt>Email:</dt>
|
||||
<dd>
|
||||
<input type="text" name="mail" value="" />
|
||||
</dd>
|
||||
|
||||
<dd>
|
||||
<input type="submit" class="button login" name="abuse" value="Send" />
|
||||
<input type="hidden" name="image" value="">
|
||||
<input type="hidden" name="_csrf" value="{{ l.csrf }}">
|
||||
</dd>
|
||||
</dl>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,83 @@
|
||||
{% include "header.html" %}
|
||||
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<h2>Newest Comments:</h2>
|
||||
<div style="width:100%">
|
||||
{%- for c in comments %}
|
||||
<div class="comment">
|
||||
<div class="commentHead">
|
||||
<img class="avatarSmall" width="16" height="16" src="/{{ c.avatar }}"/>
|
||||
<a href="/user/{{ c.name_enc }}">{{ c.name }}</a>
|
||||
at {{ c.date }}
|
||||
[bild:<a href="/all/view/{{ c.keyword }}">{{ c.keyword }}</a>]
|
||||
<div style="float:right;" id="del">
|
||||
<a href="#" data-delcomment="{{ c.id }}">[x]</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ c.content }}
|
||||
</div>
|
||||
{%- endfor %}
|
||||
</div></td>
|
||||
|
||||
<td width="50%" valign="top">
|
||||
{%- if !spammers.is_empty() %}
|
||||
<h2 style="margin:5px;" >Newest Spammer:</h2>
|
||||
{%- for u in spammers %}
|
||||
|
||||
<div class="userInfo" style="margin:5px;">
|
||||
<img class="avatar" width="40" height="40" src="/{{ u.avatar }}"/>
|
||||
<div class="name">
|
||||
<strong>
|
||||
<a href="/user/{{ u.name_enc }}">{{ u.name }}</a>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="info">
|
||||
ID: <strong>{{ u.id }}</strong> /
|
||||
Images: <strong>{{ u.images }}</strong>
|
||||
{%- if let Some(w) = u.website %}/
|
||||
Website: <strong><a href="{{ w }}" target="_blank">
|
||||
{{ w }}
|
||||
</a></strong>
|
||||
{%- endif %}
|
||||
</div>
|
||||
<div style="clear:both;"></div>
|
||||
</div>
|
||||
|
||||
{%- endfor %}
|
||||
{%- else %}
|
||||
<h2 style="margin:5px;" >No Spammer</h2>
|
||||
{%- endif %}
|
||||
|
||||
{%- if !abuses.is_empty() %}
|
||||
<h2 style="margin:5px;" >Newest Abuses:</h2>
|
||||
{%- for a in abuses %}
|
||||
{%- if let Some(t) = a.thumb %}
|
||||
|
||||
<div class="userInfo" style="margin:5px;">
|
||||
<img style="float:left; margin:2px;" width="64" height="64" src="{{ t }}"/>
|
||||
|
||||
<div class="info">
|
||||
Reason: <strong>{{ a.reason }}</strong> /
|
||||
Email: <strong>{{ a.email }}</strong>
|
||||
</div>
|
||||
<div class="info">
|
||||
<strong><a href="/all/view/{{ a.keyword }}" > >> Thread</a> </strong>
|
||||
</div>
|
||||
<div style="clear:both;"></div>
|
||||
</div>
|
||||
|
||||
{%- else %}
|
||||
<div class="userInfo" style="padding:5px;">{% if let Some(h) = a.imagelink_href %}<a href="{{ h }}" rel="nofollow noopener">{{ a.imagelink }}</a>{% else %}{{ a.imagelink }}{% endif %}<br>Reason: <strong>{{ a.reason }}</strong> /
|
||||
Email: <strong>{{ a.email }}</strong></div>
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- else %}
|
||||
<h2 style="margin:5px;" >No Abuses</h2>
|
||||
{%- endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
<p>
|
||||
To use the remote upload just drag the following link somewhere to your bookmarks (or in Opera right click it
|
||||
and choose <em>Bookmark link</em>):
|
||||
» <a data-bookmarklet="1" href="javascript:void((function(){var%20e=document.createElement('script');e.setAttribute('type','text/javascript');e.setAttribute('src','{{ frontend_path }}media/post.js.php');document.body.appendChild(e)})());"><u>Post to {{ site_name }}</u></a> «
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Now, if you're on a website where you see an image you want to post, just click on your newly created bookmark
|
||||
and then on the image you want to post. Thats all. Really!
|
||||
</p>
|
||||
@@ -0,0 +1,31 @@
|
||||
{%- if !likes.is_empty() %}
|
||||
<div class="comment" style="border: 1px solid #FFD600;">
|
||||
<div style="color: #FFD600;font-size: 30px;width:32px;height:32px; float: left; padding-left: 7px;">♥</div>
|
||||
{%- for lk in likes %}
|
||||
<a title="ID: {{ lk.user_id }}" href="/user/{{ lk.name_enc }}"><img class="avatarSmall" width="32" height="32" src="/{{ lk.avatar }}"/></a>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
{%- for c in comments %}
|
||||
{%- if c.blocked %}
|
||||
<br/><a class="quote" style="margin-left: 5px;" href="#" data-toggle="comment{{ c.id }}"><img style="margin-right: 10px; vertical-align:middle;" height="28" width="28" src="/media/xd.png" alt="blocked"/>Comment blocked ({{ c.name }})</a>
|
||||
<div style="display: none;" id="comment{{ c.id }}">
|
||||
{%- endif %}
|
||||
<div id="comment{{ c.id }}" class="comment"{% if c.upload.is_some() %} style="min-height:86px;"{% endif %}>
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td width="40" valign="top"><a style="{{ c.style }}" title="ID: {{ c.user_id }}" href="/user/{{ c.name_enc }}"><img class="avatarSmall" width="32" height="32" src="/{{ c.avatar }}"/></a></td>
|
||||
<td valign="top" >
|
||||
<div class="info" id="unmark" style="float:right;padding-right:5px;"><a href="#content_comment" class="quote" data-requote="{{ c.id }}" data-name="{{ c.name }}">{{ c.howlong }}</a> {% if admin %}<a href="#" data-delcomment="{{ c.id }}">[x]</a>{% endif %}</div><div><a style="{{ c.style }}" title="ID: {{ c.user_id }}" href="/user/{{ c.name_enc }}">{{ c.name }}</a></div>
|
||||
{%- if let Some(up) = c.upload %}
|
||||
<div style="float:left;margin-right:10px;"><a href="/{{ up.0 }}" rel="lightbox[more]" title="{{ c.text }}"><img src="/{{ up.1 }}"/></a>
|
||||
</div>
|
||||
{%- endif %}
|
||||
<div id="{{ c.id }}">{{ c.html|safe }}</div></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{%- if c.blocked %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{%- for c in items %}
|
||||
|
||||
<div id="commentbar">
|
||||
|
||||
<a href="/all/view/{{ c.keyword }}">
|
||||
<div id="commentimg">
|
||||
<img width="28" height="28" src="/{{ c.avatar }}" />
|
||||
</div>
|
||||
<div id="commenttext">
|
||||
<strong>{{ c.name }}: </strong>{{ c.content }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{%- if !errors.is_empty() %}
|
||||
|
||||
<ul>
|
||||
{%- for e in errors %}
|
||||
<li>{{ e|safe }}</li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
|
||||
{%- else %}
|
||||
|
||||
<form id="UForm" action="/upload" method="post" enctype="multipart/form-data" >
|
||||
<input type="hidden" name="_csrf" value="{{ csrf }}" />
|
||||
<table width="80%" border="0" cellspacing="1px" cellpadding="0">
|
||||
<tr>
|
||||
<td valign="top"><strong>Rules:</strong></td>
|
||||
<td style="padding-left:3px;" valign="top">Gore, CP, Spam, Nazi, Porn = Ban <br /><br />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top"><strong>Image:</strong></td>
|
||||
<td valign="top"><input type="file" name="image" accept="image/jpeg,image/png,image/gif" style="color: #000; background-color: #fff;"/><br /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top"><strong>or Url:</strong></td>
|
||||
<td valign="top"><input type="text" name="url" value=""/><br /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top"><strong>Comment:</strong></td>
|
||||
<td valign="top"><textarea id="content_comment" name="ucomment" rows="3" cols="30"></textarea><br /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td align="right"><input type="submit" name="upload" value="Upload" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
<input type="hidden" id="q" value="upload" />
|
||||
</form>
|
||||
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% include "header.html" %}
|
||||
<h1>» Bookmarklet</h1>
|
||||
<div class="article">
|
||||
<h2>{{ l.site_name }} Remote Upload</h2>
|
||||
|
||||
|
||||
<p>
|
||||
To use the remote upload just drag the following link somewhere to your bookmarks (or in Opera right click it
|
||||
and choose <em>Bookmark link</em>):
|
||||
» <a data-bookmarklet="1" href="javascript:void((function(){var%20e=document.createElement('script');e.setAttribute('type','text/javascript');e.setAttribute('src','{{ l.frontend_path }}media/post.js.php');document.body.appendChild(e)})());">Post to {{ l.site_name }}</a> «
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Now, if you're on a website where you see an image you want to post, just click on your newly created bookmark
|
||||
and then on the image you want to post. Thats all. Really!
|
||||
</p>
|
||||
|
||||
</div>
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% include "header.html" %}
|
||||
{%- if let Some(ub) = user_block %}
|
||||
|
||||
<div style="margin-left: 5px; width: 95%;">
|
||||
|
||||
<div style="font-size: 20px; margin-bottom: 5px; font-weight: bold;">
|
||||
<a style="{{ ub.style }}" href="/user/{{ ub.name_enc }}">{{ ub.name }}</a> {{ ub.rank|safe }} {% if let Some(w) = ub.website %}- <a href="{{ w }}" target="_blank">www</a> -{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="max-width: 35em; float: left;"><img alt="{{ ub.name }}" src="/{{ ub.avatar }}"/></div>
|
||||
<div id="userstats"><a href="/inbox/{{ ub.name_enc }}"><img alt="mailme" src="/media/{% if l.white %}mailme_fb.png{% else %}mailme.png{% endif %}" width="64px" height="64px"/></a></div>
|
||||
<div id="userstats"><div>{{ ub.id }}</div>ID</div>
|
||||
<div id="userstats"><div>{{ ub.days }}</div>Days past</div>
|
||||
<div id="userstats"><div>{{ ub.images }}</div>Uploads</div>
|
||||
<div id="userstats"><div>{{ ub.comments }}</div>Comments</div>
|
||||
<div id="userstats"><div>{{ ub.favs }}</div>Favs</div>
|
||||
|
||||
</div>
|
||||
{%- endif %}
|
||||
|
||||
<div style="clear:both;"></div>
|
||||
|
||||
<h1>
|
||||
» Browsing
|
||||
{%- if let Some(ub) = user_block %}
|
||||
User: <a href="/user/{{ ub.name_enc }}">{{ ub.name }}</a>
|
||||
{%- else if kind == "search" %}
|
||||
Results for {{ term }},
|
||||
{%- else if kind == "comments" %}
|
||||
Comments ,
|
||||
{%- else if kind == "favorites" %}
|
||||
favorites ,
|
||||
{%- else %}
|
||||
All,
|
||||
{%- endif %}
|
||||
|
||||
Page: {{ page_current }} of {{ page_total }}
|
||||
{%- if kind == "comments" %}
|
||||
| <strong><a href="/comments/reset?t={{ l.csrf }}">» reset unread</a></strong>
|
||||
{%- if unread_new > 0 %}
|
||||
| <a href="/unread">» newest comments ({{ unread_new }})</a> | <a href="/comments">» Comments</a>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
</h1>
|
||||
|
||||
<div id="images" style="height:{{ height }}px">
|
||||
{%- if let Some(p) = prev_href %}
|
||||
<a href="{{ p }}" class="prev" title="Previous" id="prevBar">Previous</a>
|
||||
{%- else %}
|
||||
<div class="noPrev" id="prevBar">Previous</div>
|
||||
{%- endif %}
|
||||
|
||||
{%- if let Some(n) = next_href %}
|
||||
<a href="{{ n }}" class="next" title="Next" id="nextBar">Next</a>
|
||||
{%- else %}
|
||||
<div class="noNext" id="nextBar">Next</div>
|
||||
{%- endif %}
|
||||
|
||||
<noscript>
|
||||
<div class="noscr">Please enable Javascript.</div>
|
||||
</noscript>
|
||||
|
||||
<div id="imageGrid" style="height:{{ height }}px">
|
||||
{%- for t in items %}
|
||||
<div class="{{ t.class }}" style="left:{{ t.left }}px;top:{{ t.top }}px;">{% if t.alert %}<div class="inboxalert" style="padding:3px;" >!</div>{% endif %}<a {% if t.alert %}style="background-color:#96b2e4;" {% endif %}class="thumb" href="{{ t.href }}">{% if t.alert %}<div class="overlay"></div>{% endif %}<img src="{{ t.src }}" title="{{ t.title }}" alt="{{ t.alt }}"/></a></div>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% include "header.html" %}
|
||||
{%- if kind == "chat" %}
|
||||
<div align="center">
|
||||
{%- if url.is_empty() %}
|
||||
<div class="comment" style="width:800px;text-align:center;">The chat is currently offline.</div>
|
||||
{%- else %}
|
||||
<iframe src="{{ url }}" width="800" height="500"></iframe>
|
||||
{%- endif %}
|
||||
</div>
|
||||
{%- else if kind == "forum" %}
|
||||
<div align="center" style="margin-top: 2px; margin-bottom: 25px;">
|
||||
{%- if url.is_empty() %}
|
||||
<div class="comment" style="width:800px;text-align:center;">The forum is currently offline.</div>
|
||||
{%- else %}
|
||||
<iframe src="{{ url }}" width="848" height="1200" frameborder="0"></iframe>
|
||||
{%- endif %}
|
||||
</div>
|
||||
{%- else %}
|
||||
<div align="center" style="margin-top: 10px; margin-bottom: 25px;">
|
||||
{%- if url.is_empty() %}
|
||||
<div class="comment" style="width:800px;text-align:center;">The shop is currently closed.</div>
|
||||
{%- else %}
|
||||
<iframe height="1800" width="848" src="{{ url }}" name="Spreadshop" id="Spreadshop" frameborder="0"></iframe>
|
||||
{%- endif %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% include "header.html" %}
|
||||
|
||||
<br/>
|
||||
{%- for f in items %}
|
||||
<div align="center">
|
||||
|
||||
<a href="/all/view/{{ f.keyword }}"><img src="{{ f.thumb }}" align="top" style="float:left;margin-right:5px;"></a>
|
||||
|
||||
</div>
|
||||
{%- endfor %}
|
||||
<div style="clear:both;"></div>
|
||||
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,27 @@
|
||||
</div></div>
|
||||
<!--
|
||||
<div id="footer">© 4ducky.com - a Dolan Duck Production | <a href="/impressum">Contact us</a> | <a href="/abuseform">Abuse / Request Deletion</a></div> -->
|
||||
{%- if let Some(u) = l.user %}
|
||||
<div id="sidebar">
|
||||
<div id="top">{% if u.admin %}<a href="/manage"><img height="28" width="28" src="{% if l.white %}/media/manage-fb.png{% else %}/media/manage.png{% endif %}" alt="manage"/></a>{% endif %} <a href="#" id="loadcom"><img height="28" width="28" src="{% if l.white %}/media/refresh_fb.png{% else %}/media/refresh.png{% endif %}" alt="refresh"/></a> <a href="#" id="x"><img height="28" width="28" src="{% if l.white %}/media/xfb.png{% else %}/media/xd.png{% endif %}" alt="close"/></a></div>
|
||||
|
||||
<div id="rightpanel">
|
||||
<noscript><div class="noscr">Please enable Javascript.</div></noscript>
|
||||
<div align="center"><img alt="load" src="{% if l.white %}/media/load.gif{% else %}/media/load_b.gif{% endif %}" /></div>
|
||||
</div>
|
||||
|
||||
<div id="bottom">
|
||||
<div id="commentbar"><a href="/truhe"><div id="commentimg"><img width="28" height="28" src="/media/truhe.png" alt="Spenden"></div><div id="commenttext"><strong>4ducky.com </strong>Statistiken</div></a></div>
|
||||
|
||||
<script type="text/javascript" src="/random/1/128x128/"></script>
|
||||
|
||||
<div id="commentbar"><a target="_blank" href="http://goodius.com"><div id="commentimg"><img width="28" height="28" src="/media/forum.png" alt="Forum"></div><div id="commenttext"><strong>Goodius.com</strong> <br />für alle Hipster</div></a></div>
|
||||
|
||||
<div id="commentbar"><a href="http://www.facebook.com/goodius" target="_blank"><div id="commentimg"><img width="28" height="28" src="/media/fbicon.png" alt="Facebook"></div><div id="commenttext"><strong>Duckies: </strong><br /> auf Judenbuch</div></a></div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endif %}
|
||||
<div><a href="#" id="footer-back-to-top"></a></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>{{ l.title }}</title>
|
||||
<meta name="description" content="ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔ ENTE ✔"/>
|
||||
<meta name="csrf-token" content="{{ l.csrf }}"/>
|
||||
<link rel="icon" href="/favicon.ico"/>
|
||||
{%- if l.white %}
|
||||
<link rel="stylesheet" type="text/css" href="/media/styles_fb.css" />
|
||||
{%- else %}
|
||||
<link rel="stylesheet" type="text/css" href="/media/styles_b.css" />
|
||||
{%- endif %}
|
||||
<link rel="stylesheet" href="/media/lightbox/lightbox.css" type="text/css" />
|
||||
<link rel="stylesheet" href="/media/4ducky.css" type="text/css" />
|
||||
{%- if l.jscolor %}
|
||||
<script type="text/javascript" src="/media/jscolor.js"></script>
|
||||
{%- endif %}
|
||||
<script type="text/javascript" src="/media/4ducky.js" defer="defer"></script>
|
||||
</head>
|
||||
|
||||
<body{% if l.user.is_none() %} data-guest="1"{% endif %}{% if l.view_image > 0 %} data-image-id="{{ l.view_image }}"{% endif %}>
|
||||
<div id="site">
|
||||
<div id="menu"> <a href="/" id="logo"></a>
|
||||
{%- if let Some(u) = l.user %}
|
||||
<div id="MenuLoad"{% if !l.upload_errors.is_empty() %} data-error="1"{% endif %}>
|
||||
<div id="UploadD"><div style="float:right;margin:5px;"><img data-action="close-upload" src="/media/close.png" /></div><div id="UploadD_d">
|
||||
{%- if !l.upload_errors.is_empty() %}
|
||||
<ul>
|
||||
{%- for msg in l.upload_errors %}
|
||||
<li>{{ msg|safe }}</li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{%- else %}
|
||||
<div style="margin-top:12px;text-align:center;" id="UbLoad">
|
||||
<div align="center">
|
||||
<img src="/media/load_b.gif" />
|
||||
</div>
|
||||
</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="MenuRightBlock"></div>
|
||||
|
||||
<div class="menuItems">
|
||||
<a href="#" data-menu="upload">Upload</a> <a href="#" data-menu="addon">Addon</a> <a href="/truhe">Truhe</a> <a href="/fame">HoD</a>
|
||||
</div>
|
||||
|
||||
<div class="menuItems2"><a id="nigger" href="/logout?t={{ l.csrf }}" original-title="Hello World"><img width="28" height="28" src="/media/logout{{ l.b }}.png" /></a></div>
|
||||
<div class="menuItems2"><a href="/profile"><img width="28" height="28" src="/media/profile{{ l.b }}.png" /></a></div>
|
||||
<div class="menuItems2"><a href="/favorites"><img width="28" height="28" src="/media/p{{ l.b }}.png" /></a></div>
|
||||
<div class="menuItems2">
|
||||
{%- if u.unread_msgs > 0 %}
|
||||
<div class="inboxalert">{{ u.unread_msgs }}</div>
|
||||
{%- endif %}
|
||||
<div><a href="/inbox"><img width="28" height="28" src="/media/n{{ l.b }}.png" /></a></div>
|
||||
</div>
|
||||
<div class="menuItems2">
|
||||
{%- if u.unread_count > 0 %}
|
||||
<div class="inboxalert"> {{ u.unread_count }}</div>
|
||||
<a data-hover="Last5Div" href="/unread" ><img width="28" height="28" src="/media/w{{ l.b }}.png" /></a>
|
||||
{%- else %}
|
||||
<a href="/comments" ><img width="28" height="28" src="/media/w{{ l.b }}.png" /></a>
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
<div id="Last5Div" class="Last5Div" data-hover="Last5Div">
|
||||
{%- for c in u.unread %}
|
||||
<a href="/all/view/{{ c.keyword }}">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr height="45px">
|
||||
<td valign="middle" align="left" width="45px"><img width="40" height="40" src="{{ c.thumb }}" /></td>
|
||||
<td align="left"><strong>Image:</strong><br />{{ c.short }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</a>
|
||||
{%- endfor %}
|
||||
<div style="text-align:right;padding:2px;">
|
||||
<a href="/comments/reset?t={{ l.csrf }}">» reset unread</a>
|
||||
<a href="/unread">» watch all</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="menuItems2">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" valign="middle" ><a href="#" id="del_cookie"><img title="Reload page, activate sidebar" width="28" height="28" src="/{{ u.avatar }}" /></a></td>
|
||||
<td align="center" valign="middle"><a href="/user/{{ u.name_enc }}">{{ u.name }}</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{%- else %}
|
||||
<div class="menuItems2">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" valign="middle" ><a href="/impressum"><img title="contact" width="28" height="28" src="/media/mailme.png" alt="login"/></a></td>
|
||||
<td align="center" valign="middle"><a href="/impressum">Contact</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="menuItems2">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" valign="middle" ><a href="/register"><img title="Register" width="28" height="28" src="/media/register_fb.png" alt="register"/></a></td>
|
||||
<td align="center" valign="middle"><a href="/register">Register</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="menuItems2">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" valign="middle" ><a href="/login"><img title="Login" width="28" height="28" src="/media/login_fb.png" alt="login"/></a></td>
|
||||
<td align="center" valign="middle"><a href="/login">Login</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{%- endif %}
|
||||
<div style="clear:both;"></div>
|
||||
</div>
|
||||
<div id="content">
|
||||
<div class="alert" id="dolan">
|
||||
{%- if l.user.is_some() %}{{ l.message|safe }} <img style="float: right;" id="stfu" height="16" width="16" src="{% if l.white %}/media/xfb.png{% else %}/media/xd.png{% endif %}" alt="close"/>
|
||||
{%- else %}Dolan: "Hate annoying advertisements? <a href="/register">Register now for free</a> to have them removed!" Like us on <a href="http://www.facebook.com/fuehls">Facebook.com</a>
|
||||
{%- endif %}</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
{% include "header.html" %}
|
||||
<div align="center">
|
||||
<fieldset>
|
||||
<legend>About</legend>
|
||||
<img id="ownerboy" height="20%" width="20%" style="float: right;" src="/media/andrew.jpeg" alt="Admin"/>
|
||||
<table width="500" border="0" align="center" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td width="112" valign="top">Owner:</td>
|
||||
<td width="388"><strong>
|
||||
<p> </p>
|
||||
</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Email:</td>
|
||||
<td><a href="mailto:support@4ducky.com">support@4ducky.com</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rules </td>
|
||||
<td>1.) No underaged people / no cp / no illustration with naked kids</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td>2.) No gore / dead people / dead animals</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Saw something like this?</td>
|
||||
<td><a href="/abuseform">Please request deletion</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</fieldset>
|
||||
|
||||
</div>
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% include "header.html" %}
|
||||
|
||||
{%- if user_not_found %}
|
||||
<div class="warn">
|
||||
<ul>
|
||||
<li class="error">User not found.</li>
|
||||
</ul>
|
||||
</div>
|
||||
{%- endif %}
|
||||
{%- if user_blocked %}
|
||||
<div class="warn">
|
||||
<ul>
|
||||
<li class="error">User blocked you.</li>
|
||||
</ul>
|
||||
</div>
|
||||
{%- endif %}
|
||||
|
||||
<br/><br/>
|
||||
<table class="duckytable" align="center" width="800px" border="0" cellspacing="0" cellpadding="0">
|
||||
<caption>Messages:</caption>
|
||||
{%- for c in convos %}
|
||||
<tr>
|
||||
<td width="400px">
|
||||
|
||||
<div class="userInfo" style="margin:5px;min-height:40px;{{ c.border }}" >
|
||||
<img class="avatar" width="40" height="40" src="/{{ c.avatar }}"/>
|
||||
<div class="name">
|
||||
<strong>
|
||||
<a href="/inbox/{{ c.name_enc }}">{{ c.name }}</a>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="info">
|
||||
ID: <strong>{{ c.id }}</strong> /
|
||||
Images: <strong>{{ c.images }}</strong>
|
||||
{%- if let Some(w) = c.website %}/
|
||||
Website: <strong><a href="{{ w }}" target="_blank">
|
||||
{{ w }}
|
||||
</a></strong>
|
||||
{%- endif %}
|
||||
{%- if !c.title.is_empty() %}
|
||||
|
||||
/ Subject: <strong> {{ c.title|safe }}
|
||||
|
||||
|
||||
</strong><br />
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
{%- else %}
|
||||
<tr>
|
||||
<td width="400px">
|
||||
<div class="userInfo" style="border:1px solid #d2d9e7;padding:8px;">
|
||||
Your inbox is empty.
|
||||
</div>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
|
||||
|
||||
</table> <br/>
|
||||
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% include "header.html" %}
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<table class="duckytable" align="center" width="800px" border="0" cellspacing="0" cellpadding="0">
|
||||
|
||||
<caption>Conversation with:</caption>
|
||||
|
||||
<tr>
|
||||
<td width="400px" style="border-bottom: 0;">
|
||||
|
||||
<div class="userInfo" style=" {{ border }}">
|
||||
<img class="avatar" width="60" height="60" src="/{{ avatar }}"/>
|
||||
<div class="name">
|
||||
<strong>
|
||||
<a href="/user/{{ name_enc }}">{{ name }}</a>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="info">
|
||||
ID: <strong>{{ id }}</strong> /
|
||||
Images: <strong>{{ images }}</strong>
|
||||
{%- if let Some(w) = website %}/
|
||||
<strong><a href="{{ w }}" target="_blank">
|
||||
Website
|
||||
</a></strong>
|
||||
{%- endif %}
|
||||
<br /><br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<div align="center">
|
||||
<div class="comment" style=" {{ border }}margin:5px; text-align:left;width:700px;color:#333">
|
||||
<strong>Message to {{ name }}:</strong>
|
||||
<form method="post" class="addComment" action="/inbox/{{ name_enc }}">
|
||||
<div>
|
||||
<input type="hidden" name="_csrf" value="{{ l.csrf }}"/>
|
||||
<textarea name="content" rows="6" cols="93"></textarea><br />
|
||||
<input class="submit" type="submit" name="addComment" value="Submit Message"/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{%- for s in messages %}
|
||||
|
||||
<div class="comment" style=" margin:5px; text-align:left;width:700px;{{ s.border }}">
|
||||
<div class="commentHead">
|
||||
|
||||
<a href="/user/{{ s.from_enc }}">{{ s.from_name }}</a>
|
||||
at {{ s.date }}
|
||||
|
||||
</div>
|
||||
|
||||
<hr style=" {{ s.border }} width: 99%;">
|
||||
{{ s.body|safe }}
|
||||
</div>
|
||||
{%- else %}
|
||||
No messages found. <hr style="background-color:#d2d9e7;">
|
||||
{%- endfor %}
|
||||
|
||||
|
||||
|
||||
|
||||
</div> <br/>
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% include "header.html" %}
|
||||
<div align="center">
|
||||
<form action="/login" method="post">
|
||||
<fieldset>
|
||||
<legend>Invites</legend>
|
||||
|
||||
<dl class="form">
|
||||
<dt>Info:</dt>
|
||||
<dd>
|
||||
-Invitemissbrauch führt zum Ban. (Keine Reaktivierung) / Darunter fällt auch einem gebannten User einen Invite zu geben.
|
||||
</dd>
|
||||
<dt></dt>
|
||||
<dd>-Nutzt die Invites weise. Baut euer Gast groben Unfug, seid ihr auch weg.</dd>
|
||||
<dt>Howto:</dt>
|
||||
<dd>Einfach den Link weiterschicken.</dd>
|
||||
</dl>
|
||||
<table class="table" width="100%" border="0" cellspacing="0">
|
||||
<tr class="tabletop">
|
||||
<td align="center" class="tabletoptd"><strong>InviteCode</strong></td>
|
||||
<td align="center" class="tabletoptd" width="100px"><strong>Used</strong></td>
|
||||
</tr>
|
||||
{%- for i in invites %}
|
||||
<tr class="tablelist">
|
||||
<td align="center" class="tabletd">
|
||||
{%- if i.used_by.is_none() %}
|
||||
<strong><a href="{{ l.frontend_path }}invites/{{ i.code }}">{{ i.code }}</a></strong>
|
||||
{%- else %}
|
||||
{{ i.code }}
|
||||
{%- endif %}
|
||||
</td>
|
||||
<td align="center" class="tabletd" width="50px">{% if let Some(n) = i.used_by %}{{ n }}{% else %}no{% endif %}</td>
|
||||
</tr>
|
||||
{%- else %}
|
||||
<td align="center" class="tabletd">Zurzeit keine Invites für dich</td>
|
||||
<td class="tabletd"></td>
|
||||
{%- endfor %}
|
||||
|
||||
</table>
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% include "header.html" %}
|
||||
<div align="center">
|
||||
<form action="/login" method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ l.csrf }}"/>
|
||||
<fieldset>
|
||||
<legend>Login</legend>
|
||||
|
||||
{%- if wrong_login %}
|
||||
<div class="warn">
|
||||
Wrong user or password!
|
||||
</div>
|
||||
{%- endif %}
|
||||
{%- if let Some(reason) = banned %}
|
||||
<div class="warn">
|
||||
Account banned!<br /> {% if reason.len() > 2 %}Grund: {{ reason }}{% endif %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
|
||||
|
||||
<dl class="form">
|
||||
<dt>Name:</dt>
|
||||
<dd>
|
||||
<input type="text" name="name" value="{{ name }}"/>
|
||||
</dd>
|
||||
|
||||
<dt>Passwort:</dt>
|
||||
<dd>
|
||||
<input type="password" name="pass" />
|
||||
</dd>
|
||||
|
||||
<dt> </dt>
|
||||
<dd>
|
||||
<input class="check" type="checkbox" name="remember" id="inputRemember" value="1"/>
|
||||
<label for="inputRemember">Remember me!</label>
|
||||
</dd>
|
||||
|
||||
<dt> </dt>
|
||||
<dd>
|
||||
<input type="submit" class="button login" name="login" value="Login" />
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
Don't have an account yet? <a href="/register">Register!</a>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,79 @@
|
||||
{% include "header.html" %}
|
||||
<br />
|
||||
|
||||
<table align="center" class="duckytable" width="800px" cellspacing="0" cellpadding="0">
|
||||
<caption>Spammer / Abuse:</caption>
|
||||
<tr>
|
||||
<td style="width:50%;">
|
||||
{%- if !spammers.is_empty() %}
|
||||
<h2 style="margin:5px;" >Newest Spammer:</h2>
|
||||
{%- for u in spammers %}
|
||||
|
||||
<div class="userInfo" style="margin:5px;">
|
||||
<img class="avatar" width="40" height="40" src="/{{ u.avatar }}"/>
|
||||
<div class="name">
|
||||
<strong>
|
||||
<a href="/user/{{ u.name_enc }}">{{ u.name }}</a>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="info">
|
||||
ID: <strong>{{ u.id }}</strong> /
|
||||
Images: <strong>{{ u.images }}</strong>
|
||||
{%- if let Some(w) = u.website %}/
|
||||
Website: <strong><a href="{{ w }}" target="_blank">
|
||||
{{ w }}
|
||||
</a></strong>
|
||||
{%- endif %}
|
||||
{%- if let Some(until) = u.until %}/ Bis:{{ until }}<br>Grund: {{ u.reason }}{% endif %}{% if can_unblock %} / <a href="/manage/free/{{ u.id }}?t={{ l.csrf }}">Unblock</a>{% endif %}
|
||||
|
||||
</div>
|
||||
<div style="clear:both;"></div>
|
||||
</div>
|
||||
|
||||
{%- endfor %}
|
||||
{%- else %}
|
||||
<h2 style="margin:5px;" >No Spammer atm</h2>
|
||||
{%- endif %}</td>
|
||||
|
||||
<td style="width:50%;">
|
||||
|
||||
{%- if !abuses.is_empty() %}
|
||||
<h2 style="margin:5px;" >Newest Abuses:</h2>
|
||||
{%- for a in abuses %}
|
||||
{%- if let Some(t) = a.thumb %}
|
||||
|
||||
<div class="userInfo" style="margin:5px;">
|
||||
<img style="float:left; margin:2px;" width="64" height="64" src="{{ t }}"/>
|
||||
|
||||
<div class="info">
|
||||
Reason: <strong>{{ a.reason }}</strong> /
|
||||
Email: <strong>{{ a.email }}</strong>
|
||||
{%- if let Some(n) = a.user_name %}<br>User:<strong>{{ n }}</strong>{% endif %}
|
||||
<br><a href="/manage/del/{{ a.id }}?t={{ l.csrf }}">Delete Abuse</a>
|
||||
|
||||
</div>
|
||||
<div class="info">
|
||||
<strong><a href="/all/view/{{ a.keyword }}"> >> Zum Thread</a> </strong>
|
||||
</div>
|
||||
<div style="clear:both;"></div>
|
||||
</div>
|
||||
|
||||
{%- else %}
|
||||
<div class="userInfo" style="padding:5px;">
|
||||
{% if let Some(h) = a.imagelink_href %}<a href="{{ h }}" rel="nofollow noopener">{{ a.imagelink }}</a>{% else %}{{ a.imagelink }}{% endif %}<br>
|
||||
Reason: <strong>{{ a.reason }}</strong> /
|
||||
Email: <strong>{{ a.email }}</strong>
|
||||
</div>
|
||||
<div class="userInfo" style="padding:5px;">
|
||||
<a href="/manage/del/{{ a.id }}?t={{ l.csrf }}">Löschen</a>
|
||||
</div>
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- else %}
|
||||
<h2 style="margin:5px;">No Abuses</h2>
|
||||
{%- endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,159 @@
|
||||
{% include "header.html" %}
|
||||
<div align="center">
|
||||
|
||||
<form action="/profile" enctype="multipart/form-data" method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ l.csrf }}"/>
|
||||
<fieldset>
|
||||
<legend>Change Profile</legend>
|
||||
|
||||
{%- if m.pass_short %}
|
||||
<div class="warn">Your password must be at least 6 characters long!</div>
|
||||
{%- endif %}
|
||||
|
||||
{%- if m.pass_not_equal %}
|
||||
<div class="warn">Your both passwords are not equal!</div>
|
||||
{%- endif %}
|
||||
|
||||
{%- if m.avatar_failed %}
|
||||
<div class="warn">Your avatar Image could not be processed!</div>
|
||||
{%- endif %}
|
||||
|
||||
{%- if m.color_failed %}
|
||||
<div class="warn">Not a valid colorcode!</div>
|
||||
{%- endif %}
|
||||
|
||||
|
||||
|
||||
|
||||
<dl class="form">
|
||||
<dt>Passwort:</dt>
|
||||
<dd>
|
||||
<input type="password" name="cpass" /> (leave empty, if you don't want to change it)
|
||||
</dd>
|
||||
|
||||
<dt>(Repeat)</dt>
|
||||
<dd>
|
||||
<input type="password" name="cpass2" />
|
||||
</dd>
|
||||
|
||||
|
||||
{%- if !has_email %}
|
||||
<dt>E-Mail:</dt>
|
||||
<dd>
|
||||
<input type="text" name="email" />
|
||||
</dd>
|
||||
{%- endif %}
|
||||
|
||||
<dt>Website:</dt>
|
||||
<dd>
|
||||
<input type="text" name="website" value="{{ website }}"/>
|
||||
</dd>
|
||||
|
||||
<dt>Avatar:</dt>
|
||||
<dd>
|
||||
<input type="file" name="avatar" accept="image/jpeg,image/png,image/gif" style="color: #000; background-color: #fff;"/><br/>
|
||||
</dd>
|
||||
<dt>Color:</dt>
|
||||
<dd>
|
||||
<input class="color" maxlength="6" type="text" name="color" value="{{ color }}" style="width:100px;"/> (default:: 000000)
|
||||
<br/>
|
||||
</dd>
|
||||
|
||||
<dt>Style:</dt>
|
||||
<dd>
|
||||
<select name="stylecolor">
|
||||
{%- if style == "white" %}<option value="white">White</option><option value="black">Black</option>{% else %}<option value="black">Black</option><option value="white">White</option>{% endif %}
|
||||
</select>
|
||||
</dd>
|
||||
|
||||
<dt> </dt>
|
||||
<dd>
|
||||
<input type="submit" name="save" class="button" value="Save" />
|
||||
</dd>
|
||||
|
||||
|
||||
</dl>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
<form action="/profile" enctype="multipart/form-data" method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ l.csrf }}"/>
|
||||
<fieldset>
|
||||
<legend>Block user</legend>
|
||||
|
||||
{%- if m.blocked %}
|
||||
<div class="success">User blocked!</div>
|
||||
{%- endif %}
|
||||
{%- if m.block_failed %}
|
||||
<div class="warn">Could not block user!</div>
|
||||
{%- endif %}
|
||||
{%- if m.block_already %}
|
||||
<div class="warn">User already blocked!</div>
|
||||
{%- endif %}
|
||||
{%- if m.user_failed %}
|
||||
<div class="warn">User not found!</div>
|
||||
{%- endif %}
|
||||
{%- if m.unblocked %}
|
||||
<div class="success">User unblocked!</div>
|
||||
{%- endif %}
|
||||
{%- if m.unblock_failed %}
|
||||
<div class="warn">Could not unblock user!</div>
|
||||
{%- endif %}
|
||||
{%- if m.not_blocked %}
|
||||
<div class="warn">User not blocked!</div>
|
||||
{%- endif %}
|
||||
|
||||
|
||||
<dl class="form">
|
||||
{%- for b in blocked %}
|
||||
<div style="text-align:left" class="userInfo">
|
||||
<img class="avatar" width="40" height="40" src="/{{ b.avatar }}"/>
|
||||
<div class="name">
|
||||
<strong>
|
||||
<a href="/user/{{ b.name_enc }}">{{ b.name }}</a>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="info">
|
||||
|
||||
Images: <strong>{{ b.images }}</strong>
|
||||
|
||||
</div>
|
||||
<div style="clear:both;"></div>
|
||||
</div>
|
||||
{%- else %}
|
||||
|
||||
<div class="success">No users blocked!</div>
|
||||
|
||||
{%- endfor %}
|
||||
|
||||
<dt>User:</dt>
|
||||
<dd>
|
||||
<input type="text" name="blockuser" />
|
||||
</dd>
|
||||
<dt> </dt>
|
||||
<dd>
|
||||
<input type="submit" name="block" class="button" value="Block" />
|
||||
</dd>
|
||||
|
||||
<dt>User:</dt>
|
||||
<dd>
|
||||
{%- if !blocked.is_empty() %}
|
||||
<select name="unblockuser">
|
||||
<option value="select">Select user</option>
|
||||
{%- for b in blocked %}
|
||||
<option value="{{ b.name }}">{{ b.name }}</option>
|
||||
{%- endfor %}
|
||||
</select>
|
||||
{%- else %}No user blocked!{% endif %}
|
||||
</dd>
|
||||
<dt> </dt>
|
||||
<dd>
|
||||
<input type="submit" name="unblock" class="button" value="Unblock" />
|
||||
</dd>
|
||||
</dl>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% include "header.html" %}
|
||||
|
||||
<h1>
|
||||
» Quicktagger
|
||||
</h1>
|
||||
|
||||
<div class="article" id="quickTagIntro">
|
||||
<h2>Quick tagging?</h2>
|
||||
<p>
|
||||
This special site automatically searches for images which are in desperate need of getting tagged.
|
||||
That is: new images without any or very few tags.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This site is carefully designed to allow efficient tagging of images. You don't have to move your hands
|
||||
away from the keyboard - just write and press enter for the next image. Make sure javascript is
|
||||
enabled in your browser, or you wont see anything.
|
||||
</p>
|
||||
|
||||
|
||||
<h2>A few words about tagging</h2>
|
||||
|
||||
<p>
|
||||
If you are unsure what tags to write, just imagine you are searching for this image - what word(s) would you
|
||||
search for?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Also, is there any written text on the image itself? Try to write only the most essential words down. For instance
|
||||
if you see a motivational with the text:
|
||||
</p>
|
||||
<blockquote>
|
||||
<strong>Huddle House</strong><br/>
|
||||
For those times when Waffle House isn't quite white trashy enough
|
||||
</blockquote>
|
||||
<p>
|
||||
good tags for this image would be "<em>motivational huddle house white trash</em>"
|
||||
</p>
|
||||
<p>
|
||||
If you don't know what to write, just press enter to skip the image.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Don't abuse the system in any way, or the wrath of the administrator will come done on you!</strong>
|
||||
</p>
|
||||
|
||||
|
||||
<h2><a href="#" data-action="quicktag-start"> » I get it, let's start already!</a></h2>
|
||||
</div>
|
||||
|
||||
<div id="viewer" class="hidden">
|
||||
<div id="imageInfo">
|
||||
<div>
|
||||
location: <a id="imageLocation" href="#"></a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
Tags: <span id="tags"></span>
|
||||
</div>
|
||||
<form class="visible" id="addTag" action="">
|
||||
<input type="text" name="tags" id="tagText"/>
|
||||
<input type="hidden" id="imageId" name="imageId" value="0"/>
|
||||
<input type="hidden" id="logId" name="logId" value="0"/>
|
||||
<input type="button" name="save" value="Add Tags" class="button" data-action="quicktag"/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="imageContainer">
|
||||
<img id="image" class="scaled" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
|
||||
</div>
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,84 @@
|
||||
{% include "header.html" %}
|
||||
<div align="center">
|
||||
<form action="/register" method="post">
|
||||
<input type="hidden" name="_csrf" value="{{ l.csrf }}"/>
|
||||
<fieldset>
|
||||
<legend>Create Account</legend>
|
||||
|
||||
{%- if m.name_in_use %}
|
||||
<div class="warn">This Username is {% if invite.is_some() %}allready{% else %}already{% endif %} registered!</div>
|
||||
{%- endif %}
|
||||
|
||||
{%- if m.name_invalid %}
|
||||
<div class="warn">Your name must be at least 2 characters long an may not have any special characters!</div>
|
||||
{%- endif %}
|
||||
|
||||
{%- if m.pass_short %}
|
||||
<div class="warn">Your password must be at least 6 characters long!</div>
|
||||
{%- endif %}
|
||||
|
||||
{%- if m.pass_not_equal %}
|
||||
<div class="warn">Your both passwords are not equal!</div>
|
||||
{%- endif %}
|
||||
|
||||
{%- if m.email_in_use %}
|
||||
<div class="warn">There is already a registered user with this E-Mail address!</div>
|
||||
{%- endif %}
|
||||
|
||||
{%- if m.wrong_email %}
|
||||
<div class="warn">Your E-Mail address seems to be invalid!</div>
|
||||
{%- endif %}
|
||||
{%- if m.captcha_inc %}
|
||||
<div class="warn">Incorrect Captcha!</div>
|
||||
{%- endif %}
|
||||
|
||||
|
||||
<dl class="form">
|
||||
<dt>{% if invite.is_some() %}Name:{% else %}Username:{% endif %}</dt>
|
||||
<dd>
|
||||
<input type="text" name="name" value="{{ name }}"/>
|
||||
Only letters and numbers; at least 2 characters long{% if invite.is_none() %}; no spaces{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt>E-Mail:</dt>
|
||||
<dd>
|
||||
<input type="text" name="email" value="{{ email }}"/>
|
||||
Must be valid!
|
||||
</dd>
|
||||
|
||||
<dt>Password:</dt>
|
||||
<dd>
|
||||
<input type="password" name="pass" class="pass"/>
|
||||
</dd>
|
||||
|
||||
<dt>(Repeat)</dt>
|
||||
<dd>
|
||||
<input type="password" name="pass2" class="pass"/>
|
||||
</dd>
|
||||
{%- if let Some(code) = invite %}
|
||||
|
||||
<dt>Invitecode: </dt>
|
||||
<dd>
|
||||
<div style="padding:5px;">
|
||||
{{ code }}
|
||||
<input type="hidden" name="invite" value="{{ code }}"/>
|
||||
</div>
|
||||
</dd>
|
||||
{%- endif %}
|
||||
|
||||
<dt>Captcha{% if invite.is_none() %}:{% endif %}</dt>
|
||||
<dd>
|
||||
<img src="/captcha/{{ captcha }}.png" width="180" height="56" alt="captcha" style="vertical-align:middle;"/><br/>
|
||||
<input type="hidden" name="captcha_id" value="{{ captcha }}"/>
|
||||
<input type="text" name="captcha" autocomplete="off" maxlength="5"/>
|
||||
</dd>
|
||||
|
||||
<dt> </dt>
|
||||
<dd>
|
||||
<input type="submit" name="register" class="button register" value="Register" />
|
||||
</dd>
|
||||
</dl>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% include "header.html" %}
|
||||
<div align="center">
|
||||
|
||||
<fieldset>
|
||||
<legend>Register</legend>
|
||||
|
||||
<dl class="form">
|
||||
<dd>Registration is closed at the moment. Like us on Facebook to stay up2date:</dd>
|
||||
<dd><br /></dd>
|
||||
<dd><a href="http://www.facebook.com/4duckie" target="_blank">facebook.com/4duckie</a></dd>
|
||||
</dl>
|
||||
|
||||
|
||||
</fieldset>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% include "header.html" %}
|
||||
<div class="article">
|
||||
<h2>Account successfully created</h2>
|
||||
<p>You can now login.</p>
|
||||
|
||||
</div>
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>{{ site_title }}</title>
|
||||
<link rel="stylesheet" type="text/css" href="/media/{% if white %}styles_fb.css{% else %}styles_b.css{% endif %}" />
|
||||
<link rel="Shortcut Icon" href="/media/favicon.ico"/>
|
||||
<script type="text/javascript" src="/media/remotepost.js" defer="defer"></script>
|
||||
</head>
|
||||
<body style="margin: 0.2em 1em 0 1em; padding: 0;">
|
||||
|
||||
<h1 class="remotePost">4ducky.com - One click Uploader</h1>
|
||||
|
||||
{%- if status == "login" %}
|
||||
<form action="/remotepost.php" method="post">
|
||||
<em>Name:</em>
|
||||
<input type="text" name="name" style="width: 80px; margin-right: 10px" value="{{ name }}"/>
|
||||
|
||||
<em>Passwort:</em>
|
||||
<input type="password" style="width: 80px; margin-right: 10px" name="pass" />
|
||||
|
||||
<input type="submit" class="button login" name="login" value="Login" />
|
||||
<input type="hidden" name="remember" value="1"/>
|
||||
<input type="hidden" name="url" value="{{ url }}"/>
|
||||
<input type="hidden" name="_csrf" value="{{ csrf }}"/>
|
||||
</form>
|
||||
{%- else if status == "ready" %}
|
||||
<p>Please click on the image you want to publish on {{ site_name }}.com!</p>
|
||||
<p><strong>Rules:</strong> Gore, CP, Spam (no Thread) = Ban</p>
|
||||
{%- else if status == "confirm" %}
|
||||
<p><img src="{{ url }}" style="max-width:200px;max-height:120px;float:right;margin-left:10px;" alt=""/>Post this image to {{ site_name }}.com?</p>
|
||||
<form action="/remotepost.php" method="post">
|
||||
<input type="hidden" name="url" value="{{ url }}"/>
|
||||
<input type="hidden" name="_csrf" value="{{ csrf }}"/>
|
||||
<input type="submit" class="button" name="post" value="Post" />
|
||||
</form>
|
||||
<p><strong>Rules:</strong> Gore, CP, Spam (no Thread) = Ban</p>
|
||||
{%- else if status == "failed" %}
|
||||
<p>Sorry, the Image could not be posted for the following reason:</p>
|
||||
<p><strong>{% for e in errors %}{{ e|safe }} {% endfor %}</strong></p>
|
||||
{%- else %}
|
||||
<p id="RP_Success">Image posted! Thank you!</p>
|
||||
{%- endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,247 @@
|
||||
{% include "header.html" %}
|
||||
<div align="center" style="margin-top: 25px; margin-bottom: 25px;">
|
||||
|
||||
<table class="duckytable" width="600px" cellspacing="0" cellpadding="0">
|
||||
<caption>4ducky.com Finances:</caption>
|
||||
<tr>
|
||||
<td>Current 4ducky balance:</td>
|
||||
<td><strong>20,00 Euro</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><div class="meter"><span style="width: 50%">50 % achieved</span></div></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colspan="2" style="text-align:left;">
|
||||
|
||||
Jeder User hat die Möglichkeit 4ducky.com mit Spenden zu unterstützen. Der 4ducky.com Server kostet derzeit 30 Euro im Monat.
|
||||
Wer etwas beisteuern möchte, der schickt einfach einen <a href="https://www.amazon.de/gp/product/BT00DHI7XI">Amazon-Gutschein</a> (bitte auf eure E-Mail Adresse und nur den Code per PN) an den User "<a href="http://www.4ducky.com/inbox/Pandora">Pandora</a>" oder spendet via Bitcoin an 13wcgskYqQ2L4cPtV6TJHyfzrQ8NT5Zt9e. Spender werden hier geehrt:
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colspan="2"><strong>Last activities & donations:</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="duckytable" width="600px" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="width: 40%;"><strong>Date</strong></td>
|
||||
<td style="width: 40%;"><strong>Activity / Feeler</strong></td>
|
||||
<td style="width: 40%;"><strong>Amount</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div id="spendenducks">
|
||||
<table class="duckytable" width="600px" cellspacing="0" cellpadding="0">
|
||||
<tr class="spender">
|
||||
<td>Oktober 2015</td>
|
||||
<td><a href="/user/Pulvertoastmann">Pulvertoastmann</a></td>
|
||||
<td>+ 10,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Oktober 2015</td>
|
||||
<td><a href="/user/nadjer">nadjer</a></td>
|
||||
<td>+ 20,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>Oktober 2015</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>September 2015</td>
|
||||
<td><a href="/user/Moses">Moses</a></td>
|
||||
<td>+ 10,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>September 2015</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>August 2015</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>August 2015</td>
|
||||
<td>Anonym</td>
|
||||
<td>+ 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>Juli 2015</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Juni 2015</td>
|
||||
<td><a href="/user/DerFickNigger">DerFickNigger</a></td>
|
||||
<td>+ 10,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Juni 2015</td>
|
||||
<td><a href="/user/waluri">waluri</a></td>
|
||||
<td>+ 10,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Juni 2015</td>
|
||||
<td><a href="/user/Kastritis">Kastritis</a></td>
|
||||
<td>+ 17,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Juni 2015</td>
|
||||
<td><a href="/user/BASEDGOD">BASEDGOD</a></td>
|
||||
<td>+ 10,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>Juni 2015</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>Mai 2015</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>April 2015</td>
|
||||
<td><a href="/user/DerFickNigger">DerFickNigger</a></td>
|
||||
<td>+ 25,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>April 2015</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>März 2015</td>
|
||||
<td><a href="/user/AntraX">AntraX</a></td>
|
||||
<td>+ 88,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>März 2015</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Februar 2015</td>
|
||||
<td><a href="/user/Ahmed">Ahmed</a></td>
|
||||
<td>+ 50,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>März 2015</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Januar 2015</td>
|
||||
<td><a href="/user/FG">FG</a></td>
|
||||
<td>+ 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Januar 2015</td>
|
||||
<td><a href="/user/Nadjer">Nadjer</a></td>
|
||||
<td>+ 10,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>Januar 2015</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 30,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Dezember 2014</td>
|
||||
<td><a href="/user/Kastritis">Kastritis</a></td>
|
||||
<td>+ 11,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Dezember 2014</td>
|
||||
<td>vergessen wer</td>
|
||||
<td>+ 10,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Juli 2014</td>
|
||||
<td><a href="/user/RoterIndianer">RoterIndianer</a></td>
|
||||
<td>+ 40,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Juli 2014</td>
|
||||
<td><a href="/user/Seine_Durchlaucht">Seine_Durchlaucht</a></td>
|
||||
<td>+ 10,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Juli 2014</td>
|
||||
<td><a href="/user/Napalm">Napalm</a></td>
|
||||
<td>+ 10,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Juni 2014</td>
|
||||
<td><a href="/user/Sozialfall">Sozialfall</a></td>
|
||||
<td>+ 20,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Dezember 2013</td>
|
||||
<td><a href="/user/IchHab4Euro">IchHab4Euro</a></td>
|
||||
<td>+ 4,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Dezember 2013</td>
|
||||
<td><a href="/user/FG">FG</a></td>
|
||||
<td>+ 25,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Dezember 2013</td>
|
||||
<td><a href="/user/Kastritis">Kastritis</a></td>
|
||||
<td>+ 10,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>Oktober 2013</td>
|
||||
<td><a href="/user/Hitler">Hitler</a></td>
|
||||
<td>+ 20,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="spender">
|
||||
<td>September 2013</td>
|
||||
<td><a href="/user/Pandora">Pandora</a></td>
|
||||
<td>+ 20,00 Euro</td>
|
||||
</tr>
|
||||
<tr class="kosten">
|
||||
<td>September 2013</td>
|
||||
<td>Server-Miete</td>
|
||||
<td>- 20 Euro</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<table class="duckytable" width="600px" cellspacing="0" cellpadding="0">
|
||||
<caption>4ducky.com Statistics:</caption>
|
||||
|
||||
<tr>
|
||||
<td>Images:</td>
|
||||
<td>{{ img }} Images</td>
|
||||
<td>~ {{ self.per_user(img) }} Images/User</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>User:</td>
|
||||
<td>{{ duckys }} Duckies</td>
|
||||
<td>{{ banned }} Banned Duckies</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Comments:</td>
|
||||
<td>{{ comm }} Comments</td>
|
||||
<td>~ {{ self.per_user(comm) }} Comments/User</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Likes:</td>
|
||||
<td>{{ fav }} Likes</td>
|
||||
<td>~ {{ self.per_user(fav) }} Likes/User</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% include "header.html" %}
|
||||
<h1>
|
||||
» Browsing Users,
|
||||
Page: {{ current }} of {{ total }}
|
||||
</h1>
|
||||
|
||||
{%- for u in users %}
|
||||
<div class="userInfo" style=" {{ border }} ">
|
||||
<img class="avatar" width="40" height="40" src="/{{ u.avatar }}"/>
|
||||
<div class="name">
|
||||
<strong>
|
||||
<a href="/user/{{ u.name_enc }}">{{ u.name }}</a>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="info">
|
||||
|
||||
Images: <strong>{{ u.images }}</strong>
|
||||
{%- if let Some(w) = u.website %}|
|
||||
<strong><a href="{{ w }}" target="_blank">
|
||||
Website
|
||||
</a></strong>
|
||||
{%- endif %}
|
||||
</div>
|
||||
<div style="clear:both;"></div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
|
||||
<div class="userInfo">
|
||||
{%- if let Some(p) = prev %}
|
||||
<a href="/users/page/{{ p }}" class="textPrev" title="Previous">« Previous</a>
|
||||
{%- else %}
|
||||
<div class="textNoPrev">« Previous</div>
|
||||
{%- endif %}
|
||||
|
||||
{%- if let Some(n) = next %}
|
||||
<a href="/users/page/{{ n }}" class="textNext" title="Next">Next »</a>
|
||||
{%- else %}
|
||||
<div class="textNoNext">Next »</div>
|
||||
{%- endif %}
|
||||
|
||||
<div style="clear: both;"></div>
|
||||
</div>
|
||||
|
||||
{% include "footer.html" %}
|
||||
@@ -0,0 +1,182 @@
|
||||
{% include "header.html" %}
|
||||
|
||||
<h1> » Viewing
|
||||
{%- if let Some(cu) = ctx_user %}
|
||||
User: <a href="/user/{{ cu.1 }}">{{ cu.0 }}</a>
|
||||
{%- else %}
|
||||
: {{ pname }}, uploaded {{ howlong }}
|
||||
{%- endif %} ({{ date }})
|
||||
</h1>
|
||||
|
||||
<div id="viewer">
|
||||
{%- if let Some(p) = prev_href %}
|
||||
<a href="{{ p }}" class="prev" id="prevBar" title="Previous">Previous</a>
|
||||
{%- else %}
|
||||
<div class="noPrev" id="prevBar">Previous</div>
|
||||
{%- endif %}
|
||||
{%- if let Some(n) = next_href %}
|
||||
<a href="{{ n }}" class="next" id="nextBar" title="Next">Next</a>
|
||||
{%- else %}
|
||||
<div class="noNext" id="nextBar">Next</div>
|
||||
{%- endif %}
|
||||
|
||||
<div id="imageContainer">
|
||||
{%- if logged_in %}
|
||||
<img class="scaled" id="image" src="{{ image_src }}" alt="{{ pname }}"/>
|
||||
{%- else %}
|
||||
<a href="/register"><img id="image" src="/images/gate.png" alt="register"/></a><br />
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
<div id="imageInfo">
|
||||
|
||||
<div class="userInfo">
|
||||
<img class="avatar" width="50" height="50" alt="Avatar" src="/{{ up_avatar }}"/>
|
||||
|
||||
{%- if logged_in %}
|
||||
<a style="font-size: 45px; float:right;margin-top:-9px;" href="#" data-fav="{{ image_id }}"><img name="img1" class="duckfav" src="/media/ducksw.png" alt="fav" height="64px" width="64px"/></a>
|
||||
{%- endif %}
|
||||
|
||||
<div class="name"><strong><a title="ID: {{ up_id }} | Images: {{ up_images }}" style="{{ up_style }}" href="/user/{{ up_enc }}">{{ up_name }}</a> </strong>
|
||||
|
||||
{{ rank|safe }}
|
||||
|
||||
</div>
|
||||
|
||||
{%- if logged_in %}
|
||||
<div class="info"><a class="smalllink" href="#" data-toggle="MessageForm">message</a> | <a href="#CommentForm">comment</a> | {% else %} <div class="info">{% endif %}<a class="smalllink" href="#" data-toggle="ShareForm">share</a> | <a target="_blank" href="{{ search_link }}">search</a> | <a target="_blank" href="{{ edit_link }}">edit</a> | <a href="#" data-toggle="AbuseForm">abuse</a></div>
|
||||
|
||||
{%- if admin %}
|
||||
<div class="info" style="font-size: 14px; border:dotted 1px #666;float:left;padding:1px; margin-top:8px;">Score: {{ score }} |
|
||||
{% if superadmin %} <a href="#" data-admin="fame" data-id="{{ image_id }}">HoF</a> | <a href="#" data-admin="ban" data-id="{{ up_id }}">Ban</a> | {% endif %}<a href="#" data-admin="timeblock" data-id="{{ up_id }}">TimeBlock User</a> | <a href="#" data-admin="close" data-id="{{ image_id }}">Close Thread</a> | <a href="#" data-admin="delete" data-id="{{ image_id }}">Delete Pic</a></div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
{%- if admin %}
|
||||
<br/>
|
||||
{%- endif %}
|
||||
|
||||
<div style="clear: both;"></div>
|
||||
|
||||
<div id="AbuseForm" style="display: none">
|
||||
<div class="comment">
|
||||
|
||||
<form action="/abuse" method="post">
|
||||
<dl class="form">
|
||||
<dt>Reason:</dt>
|
||||
<dd>
|
||||
<input type="text" name="rsn" value=""/>
|
||||
</dd>
|
||||
<dt>Email:</dt>
|
||||
<dd>
|
||||
<input type="text" name="mail" value="" />
|
||||
</dd>
|
||||
<dt> </dt>
|
||||
<dd>
|
||||
<input type="submit" class="submit" name="abuse" value="Send" />
|
||||
<input type="hidden" name="image" value="{{ image_id }}"/>
|
||||
<input type="hidden" name="_csrf" value="{{ l.csrf }}"/>
|
||||
</dd>
|
||||
</dl>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="FavDiv" class="comment" style="display:none"> </div>
|
||||
|
||||
|
||||
<div id="MessageForm" style="display:none">
|
||||
<div class="comment">
|
||||
|
||||
Send:
|
||||
<form method="post" class="addComment" action="/inbox/{{ up_enc }}">
|
||||
<div>
|
||||
<input type="hidden" name="_csrf" value="{{ l.csrf }}"/>
|
||||
<input name="title" /><br />
|
||||
<textarea name="content" rows="3" cols="40"></textarea><br />
|
||||
<input class="submit" type="submit" name="addComment" value="Submit"/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ShareForm" style="display:none">
|
||||
<div class="comment">
|
||||
Link:<br/> <input type="text" readonly="1" value="{{ share_link }}" style="width: 400px; font-size:10px" data-select="1"/>
|
||||
<br />
|
||||
Forum-Code: <input type="text" readonly="1" value="{{ forum_code }}" style="width: 400px; font-size:10px" data-select="1"/>
|
||||
<br />
|
||||
HTML-Code: <input type="text" readonly="1" value="{{ html_code }}" style="width: 400px; font-size:10px" data-select="1"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comments">
|
||||
|
||||
<div id="cmts">
|
||||
<noscript>
|
||||
<div class="noscr">Please enable Javascript.</div>
|
||||
</noscript>
|
||||
<div align="center">
|
||||
<img src="{% if l.white %}/media/load.gif{% else %}/media/load_b.gif{% endif %}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{%- if logged_in && comment_open %}
|
||||
<div class="comment">
|
||||
<noscript>
|
||||
<div class="noscr">Please enable Javascript.</div>
|
||||
</noscript>
|
||||
<div class="noscr" id="CommentError" style="display:none"></div>
|
||||
|
||||
<form method="post" class="addComment" id="CommentForm" enctype="multipart/form-data" action="{{ form_action }}">
|
||||
<input type="hidden" name="_csrf" value="{{ l.csrf }}"/>
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
{%- if let Some(lines) = spam %}
|
||||
<td colspan="2"><div class="comment">
|
||||
{%- for line in lines %}{% if !loop.first %}<br>{% endif %}{{ line|safe }}{% endfor %}
|
||||
</div></td>
|
||||
{%- else %}
|
||||
|
||||
<td width="40" valign="top"><img class="avatarSmall" width="32" height="32" src="/{{ my_avatar }}"/></td>
|
||||
|
||||
<td valign="top" >
|
||||
» BB-Codes: <a class="quote" href="#" data-bbcode="img">[img]</a> <a class="quote" href="#" data-bbcode="link">[link]</a> <a class="quote" href="#" data-bbcode="spoiler">[spoiler]</a> <a class="quote" href="#" data-bbcode="youtube">[youtube]</a>
|
||||
|
||||
<div id="ImgFile" style="display:none">
|
||||
<input type="file" id="image_comment" name="image_comment" value="" style="color: #000; background-color: #fff; border:1px solid #bdc7d8;"/>
|
||||
<input type="text" id="url_comment" name="url_comment" value="http//URL Upload" />
|
||||
</div>
|
||||
<textarea name="content" id="content_comment" rows="6" cols="53"></textarea></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td><input id="SubBut" class="submit" type="button" name="addComment" value="Submit Comment" data-image-id="{{ image_id }}"/>
|
||||
<div style="text-align:center;display:none" id="SubLoad">
|
||||
<div align="center">
|
||||
<img src="{% if l.white %}/media/load.gif{% else %}/media/load_b.gif{% endif %}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
{%- endif %}
|
||||
</tr>
|
||||
</table>
|
||||
<div> </div>
|
||||
</form>
|
||||
</div>
|
||||
{%- else %}
|
||||
<div class="comment">
|
||||
{%- if comment_open %}
|
||||
Register to comment...
|
||||
{%- else %}
|
||||
Comments are closed!
|
||||
{%- endif %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "footer.html" %}
|
||||
Reference in New Issue
Block a user