Merge remote-tracking branch 'upstream/master'

l29utp0 3 years ago
commit 37fc2858ca
  1. 7
      CHANGELOG.md
  2. 46
      INSTALLATION.md
  3. 13
      README.md
  4. 10
      configs/nginx/README.md
  5. 196
      configs/nginx/nginx.sh
  6. 55
      configs/nginx/sites-available.example
  7. 2
      configs/template.js.example
  8. 7
      controllers/forms/boardsettings.js
  9. 6
      controllers/forms/globalsettings.js
  10. 28
      gulp/res/css/style.css
  11. 9
      gulpfile.js
  12. 36
      helpers/settingsdiff.js
  13. 8
      helpers/tasks.js
  14. 22
      migrations/0.1.8.js
  15. 22
      models/forms/actionhandler.js
  16. 73
      models/forms/addban.js
  17. 121
      models/forms/changeboardsettings.js
  18. 43
      models/forms/changeglobalsettings.js
  19. 18977
      package-lock.json
  20. 33
      package.json
  21. 10
      views/includes/footer.pug
  22. 25
      views/includes/head.pug
  23. 14
      views/mixins/catalogfile.pug
  24. 28
      views/mixins/catalogtile.pug
  25. 6
      views/mixins/modal.pug
  26. 2
      views/mixins/newspost.pug
  27. 4
      views/mixins/post.pug
  28. 6
      views/pages/globalmanagesettings.pug
  29. 6
      views/pages/managesettings.pug
  30. 1
      views/pages/overboardcatalog.pug
  31. 2
      views/pages/thread.pug

@ -61,3 +61,10 @@
- Dont show "local first" checkbox in boardlist when webring isn't even enabled
- Bugfix to code markup, remove only leading blank lines, keeping leading whitespace on first non-empty line
- Make overboard settings save somewhat in localstorage to help stupid zoomers who dont know what a BOOKMARK is
### 0.1.8
- Much improved nginx configuration for installation, script does most of the work
- Fixed settings form asking to save password -.-
- Multiple files & post flags now shown in catalog view
- Faster, more efficient global settings changes
- Add option for board owner to prevent OP deleting threads that are too old or have too many replies

@ -26,7 +26,7 @@
```bash
$ sudo apt-get update
$ sudo apt-get install nginx ffmpeg imagemagick graphicsmagick
$ sudo apt-get install nginx ffmpeg imagemagick graphicsmagick python-certbot-nginx
```
NOTE: If you plan to use animated .gif thumbnails, ffmpeg >=4.3.1 is recommended as there are known issues with older ffmpeg versions producing buggy thumbnails. You can [compile ffmpeg from source](https://trac.ffmpeg.org/wiki/CompilationGuide) to get a newer version.
@ -52,49 +52,9 @@ You may install Node.js yourself without nvm if you prefer.
**6. Configure nginx**
NOTE: The sample configs assume you use the apex domain and a www. subdomain.
For standard installations, run `configs/nginx/nginx.sh` as root. This will prompt you for installation directory, domains, onion/lokinet, enable geoip, install a letsencrypt certificate with certbot and more.
- Copy the nginx example config and snippets, and create a symlink from sites-available -> sites-enabled
```bash
$ sudo cp configs/nginx/nginx.example /etc/nginx/sites-available/EXAMPLE.COM
$ sudo ln -s /etc/nginx/sites-available/EXAMPLE.COM /etc/nginx/sites-enabled/EXAMPLE.COM
$ sudo cp configs/nginx/snippets/* /etc/nginx/snippets
```
If you have a .onion or .loki address:
- Uncomment the block in /etc/nginx/sites-available/EXAMPLE.COM
Edit/replace the following in your nginx config:
- "/path/to/jschan" with the path of your jschan root folder
- "example.com" with your domain name
- "example.onion" or "example.loki" with your tor or lokinet address
`sed` can be used to automate this process, for example:
```bash
$ sudo sed -i 's|/path/to/jschan|/path/to/your/install|g' /etc/nginx/sites-available/EXAMPLE.COM
$ sudo sed -i 's|/path/to/jschan|/path/to/your/install|g' /etc/nginx/snippets/*
$ sudo sed -i 's/example.com/your.example.com/g' /etc/nginx/sites-available/EXAMPLE.COM
$ sudo sed -i 's/example.com/your.example.com/g' /etc/nginx/snippets/*
# repeat the same for "example.onion" and "example.loki" with your addresses
```
If you don't use .onion or .loki address, remove the example domains from the content-security-policy snippet:
```bash
$ sudo sed -i 's/ wss:\/\/www.example.onion\/ wss:\/\/example.onion\///g' /etc/nginx/snippets/security_headers*
$ sudo sed -i 's/ wss:\/\/www.example.loki\/ wss:\/\/example.loki\///g' /etc/nginx/snippets/security_headers*
```
- Make sure the sites enabled folder is included by `/etc/nginx/nginx.conf` (in debian nginx package this is already done)
- Use [certbot](https://certbot.eff.org/) to get a free https certificate.
- For post flags to work, [follow this guide](http://archive.is/2SMOb) to setup the [legacy GeoIP database](https://www.miyuru.lk/geoiplegacy), then add this directive inside the http block of `/etc/nginx/nginx.conf`:
```
geoip_country /usr/share/GeoIP/GeoIP.dat;
```
If you plan on using hcaptcha or google recaptcha, you will need to modify the content-security-policy header (CSP) in your nginx config. (documentation: [google recaptcha](https://developers.google.com/recaptcha/docs/faq#im-using-content-security-policy-csp-on-my-website.-how-can-i-configure-it-to-work-with-recaptcha), [hcaptcha](https://docs.hcaptcha.com/#content-security-policy-settings))
If you use cloudflare, please read [these](https://support.cloudflare.com/hc/en-us/articles/200170786-Restoring-original-visitor-IPs-Logging-visitor-IP-addresses-with-mod-cloudflare-) [articles](https://support.cloudflare.com/hc/en-us/articles/200168236-Configuring-Cloudflare-IP-Geolocation) to setup proper IP forwarding and geolocation headers. Similar steps would apply to other CDNs/reverse proxies.
For non-standard installations like using a CDN, see [configs/nginx/README.md](configs/nginx/README.md) and DIY.
**7. Get the backend setup & running**

@ -38,3 +38,16 @@ See [CHANGELOG.md](CHANGELOG.md) for changes between versions.
## Contributing
Interested in contributing to jschan development? See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
<<<<<<< HEAD
=======
## Related Projects
Here are some other projects related to jschan that you might find useful. Unless explicitly specified here, they are not officially endorsed or otherwise guaranteed to work or be safe and should be used at your own risk.
- [myumyu/globalafk](https://gitgud.io/myumyu/globalafk/) - "A simple python script that sends ugly notifications when something happens on a jschan imageboard that you moderate."
## For generous people
Bitcoin (BTC): [`bc1q4elrlz5puak4m9xy3hfvmpempnpqpu95v8s9m6`](bitcoin:bc1q4elrlz5puak4m9xy3hfvmpempnpqpu95v8s9m6)
Monero (XMR): [`89J9DXPLUBr5HjNDNZTEo4WYMFTouSsGjUjBnUCCUxJGUirthnii4naZ8JafdnmhPe4NP1nkWsgcK82Uga7X515nNR1isuh`](monero:89J9DXPLUBr5HjNDNZTEo4WYMFTouSsGjUjBnUCCUxJGUirthnii4naZ8JafdnmhPe4NP1nkWsgcK82Uga7X515nNR1isuh)
>>>>>>> upstream/master

@ -1,2 +1,8 @@
`nginx.example` is your /etc/nginx/sites-available/example.com file
`snippets/*` goes in /etc/nginx/snippets/
`sites-available.example` is a sample /etc/nginx/sites-available/example.com file
`snippets/*` are sample snippets for /etc/nginx/snippets/
For standard installs, run nginx.sh for easy configuration with prompts.
For non-standard installations, DIY.
If you use cloudflare, please read [these](https://support.cloudflare.com/hc/en-us/articles/200170786-Restoring-original-visitor-IPs-Logging-visitor-IP-addresses-with-mod-cloudflare-) [articles](https://support.cloudflare.com/hc/en-us/articles/200168236-Configuring-Cloudflare-IP-Geolocation) to setup proper IP forwarding and geolocation headers. Similar steps would apply to other CDNs/reverse proxies.

@ -0,0 +1,196 @@
#!/bin/bash
#sets up nginx config
#are you root?
[[ "$EUID" -ne 0 ]] && echo "Please run as root" && exit;
echo "[jschan nginx configuration helper]"
read -p "Enter the directory you cloned jschan (blank=$(pwd)): " JSCHAN_DIRECTORY
JSCHAN_DIRECTORY=${JSCHAN_DIRECTORY:-$(pwd)}
read -p "Enter your clearnet domain name e.g. example.com (blank=no clearnet domain): " CLEARNET_DOMAIN
SITES_AVAILABLE_NAME=${CLEARNET_DOMAIN:-jschan} #not sure on a good default, used for sites-available config name
read -p "Enter tor .onion address (blank=no .onion address): " ONION_DOMAIN
read -p "Enter lokinet .loki address (blank=no .loki address): " LOKI_DOMAIN
read -p "Should robots.txt disallow compliant crawlers? (y/n): " ROBOTS_TXT_DISALLOW
read -p "Allow google captcha in content-security policy? (y/n): " GOOGLE_CAPTCHA
read -p "Allow Hcaptcha in content-security policy? (y/n): " H_CAPTCHA
read -p "Download and setup geoip for post flags? (y/n): " GEOIP
read -p "Use certbot to install letsencrypt certificate for https? (y/n): " LETSENCRYPT
#looks good?
read -p "Is this correct?
jschan directory: $JSCHAN_DIRECTORY
clearnet domain: $CLEARNET_DOMAIN
.onion address: $ONION_DOMAIN
.loki address: $LOKI_DOMAIN
robots.txt disallow all: $ROBOTS_TXT_DISALLOW
google captcha: $GOOGLE_CAPTCHA
hcaptcha: $H_CAPTCHA
geoip: $GEOIP
(y/n): " CORRECT
#not saying no = yes, just like real life
[[ "$CORRECT" == "n" ]] && echo "Exiting..." && exit;
#copy the snippets and replace install path, they aren't templated
sudo cp $JSCHAN_DIRECTORY/configs/nginx/snippets/* /etc/nginx/snippets
sudo sed -i "s|/path/to/jschan|$JSCHAN_DIRECTORY|g" /etc/nginx/snippets/*
#declare teplate start
JSCHAN_CONFIG="upstream chan {
server 127.0.0.1:7000;
}"
if [ "$CLEARNET_DOMAIN" != "" ]; then
if [ "$LETSENCRYPT" == "y" ]; then
#run certbot for certificate
sudo certbot certonly --standalone -d $CLEARNET_DOMAIN -d www.$CLEARNET_DOMAIN
fi
#onion_location rediret header
ONION_LOCATION=""
if [ "$ONION_DOMAIN" != "" ]; then
ONION_LOCATION="add_header onion-location 'http://$ONION_DOMAIN\$request_uri';"
fi
#concat clearnet server{} block
JSCHAN_CONFIG="${JSCHAN_CONFIG}
server {
server_name www.$CLEARNET_DOMAIN $CLEARNET_DOMAIN;
client_max_body_size 0;
$ONION_LOCATION
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/$CLEARNET_DOMAIN/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/$CLEARNET_DOMAIN/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
include /etc/nginx/snippets/security_headers.conf;
include /etc/nginx/snippets/error_pages.conf;
include /etc/nginx/snippets/jschan_clearnet_routes.conf;
include /etc/nginx/snippets/jschan_common_routes.conf;
}
server {
if (\$host = www.$CLEARNET_DOMAIN) {
return 301 https://\$host\$request_uri;
} # managed by Certbot
if (\$host = $CLEARNET_DOMAIN) {
return 301 https://$host\$request_uri;
} # managed by Certbot
server_name www.$CLEARNET_DOMAIN $CLEARNET_DOMAIN;
listen 80;
listen [::]:80;
return 444; # managed by Certbot
}"
#replace clearnet domain in snippets
sudo sed -i "s/example.com/$CLEARNET_DOMAIN/g" /etc/nginx/snippets/*
fi
if [ "$ONION_DOMAIN" != "" ]; then
#concat onion server{} block
JSCHAN_CONFIG="${JSCHAN_CONFIG}
server {
server_name www.$ONION_DOMAIN $ONION_DOMAIN;
client_max_body_size 0;
listen unix:/var/run/nginx-tor.sock;
allow 'unix:';
deny all;
include /etc/nginx/snippets/security_headers.conf;
include /etc/nginx/snippets/error_pages.conf;
include /etc/nginx/snippets/jschan_common_routes.conf;
include /etc/nginx/snippets/jschan_tor_routes.conf;
}"
#replace onion domain in snippets
sudo sed -i "s/example.onion/$ONION_DOMAIN/g" /etc/nginx/snippets/*
else
#no onion, remove it from CSP
sudo sed -i 's/ wss:\/\/www.example.onion\/ wss:\/\/example.onion\///g' /etc/nginx/snippets/security_headers*
fi
if [ "$LOKI_DOMAIN" != "" ]; then
#concat lokinet server{} block
JSCHAN_CONFIG="${JSCHAN_CONFIG}
server {
server_name www.$LOKI_DOMAIN $LOKI_DOMAIN;
client_max_body_size 0;
#address may vary if this address is already used by something other than lokinet
listen 172.16.0.1:80;
include /etc/nginx/snippets/security_headers.conf;
include /etc/nginx/snippets/error_pages.conf;
include /etc/nginx/snippets/jschan_common_routes.conf;
include /etc/nginx/snippets/jschan_loki_routes.conf;
}"
#replace lokinet domain in snippets
sudo sed -i "s/example.loki/$LOKI_DOMAIN/g" /etc/nginx/snippets/*
else
#no lokinet, remove it from csp
sudo sed -i 's/ wss:\/\/www.example.loki\/ wss:\/\/example.loki\///g' /etc/nginx/snippets/security_headers*
fi
#debug
#printf "$JSCHAN_CONFIG"
#write the config to file and syymlink to sites-available
printf "$JSCHAN_CONFIG" >> /etc/nginx/sites-available/$SITES_AVAILABLE_NAME
sudo ln -s /etc/nginx/sites-available/$SITES_AVAILABLE_NAME /etc/nginx/sites-enabled/$SITES_AVAILABLE_NAME
if [ "$GOOGLE_CAPTCHA" == "y" ]; then
#add google captcha CSP exceptions
sudo sed -i "s|script-src|script-src https://www.google.com/recaptcha/, https://www.gstatic.com/recaptcha/ |g" /etc/nginx/snippets/*
sudo sed -i "s|frame-src|frame-src https://www.google.com/recaptcha/, https://recaptcha.google.com/recaptcha/ |g" /etc/nginx/snippets/*
fi
if [ "$H_CAPTCHA" == "y" ]; then
#add hcaptcha CSP exceptions
sudo sed -i "s|script-src|script-src https://hcaptcha.com, https://*.hcaptcha.com |g" /etc/nginx/snippets/*
sudo sed -i "s|frame-src|frame-src https://hcaptcha.com, https://*.hcaptcha.com |g" /etc/nginx/snippets/*
sudo sed -i "s|style-src|style-src https://hcaptcha.com, https://*.hcaptcha.com |g" /etc/nginx/snippets/*
sudo sed -i "s|connect-src|connect-src https://hcaptcha.com, https://*.hcaptcha.com |g" /etc/nginx/snippets/*
fi
if [ "$ROBOTS_TXT_DISALLOW" == "y" ]; then
#add path / (all) to disallow to make robots.txt block all robots instead of allowing
sudo sed -d "s|Disallow:|Disallow: /|g" /etc/nginx/snippets/jschan_common_routes.conf
fi
if [ "$GEOIP" == "y" ]; then
#download geoip data
cd /usr/share/GeoIP
mv GeoIP.dat GeoIP.dat.bak
wget --retry-connrefused https://dl.miyuru.lk/geoip/dbip/country/dbip.dat.gz
gunzip dbip.dat.gz
mv dbip.dat GeoIP.dat
chown www-data:www-data /usr/share/GeoIP/GeoIP.dat
#add config statement to /etc/nginx/nginx.conf
sudo sed -i '/http {/a \
geoip_country /usr/share/GeoIP/GeoIP.dat;' /etc/nginx/nginx.conf
fi
#and restart nginx
sudo systemctl restart nginx

@ -2,40 +2,37 @@ upstream chan {
server 127.0.0.1:7000;
}
# uncomment for lokinet snapp
#server {
# server_name www.example.loki example.loki;
# client_max_body_size 0;
#
# #address may vary if this address is already used by something other than lokinet
# listen 172.16.0.1:80;
#
# include /etc/nginx/snippets/security_headers.conf;
# include /etc/nginx/snippets/error_pages.conf;
# include /etc/nginx/snippets/jschan_common_routes.conf;
# include /etc/nginx/snippets/jschan_loki_routes.conf;
#}
server {
server_name www.example.loki example.loki;
client_max_body_size 0;
# uncomment for .onion tor hidden service
#server {
# server_name www.example.onion example.onion;
# client_max_body_size 0;
#
# listen unix:/var/run/nginx-tor.sock;
# allow "unix:";
# deny all;
#
# include /etc/nginx/snippets/security_headers.conf;
# include /etc/nginx/snippets/error_pages.conf;
# include /etc/nginx/snippets/jschan_common_routes.conf;
# include /etc/nginx/snippets/jschan_tor_routes.conf;
#}
listen 172.16.0.1:80;
include /etc/nginx/snippets/security_headers.conf;
include /etc/nginx/snippets/error_pages.conf;
include /etc/nginx/snippets/jschan_common_routes.conf;
include /etc/nginx/snippets/jschan_loki_routes.conf;
}
server {
server_name www.example.onion example.onion;
client_max_body_size 0;
listen unix:/var/run/nginx-tor.sock;
allow "unix:";
deny all;
include /etc/nginx/snippets/security_headers.conf;
include /etc/nginx/snippets/error_pages.conf;
include /etc/nginx/snippets/jschan_common_routes.conf;
include /etc/nginx/snippets/jschan_tor_routes.conf;
}
server {
server_name www.example.com example.com;
client_max_body_size 0;
#uncomment if you have a .onion
#add_header onion-location 'http://example.onion$request_uri';
add_header onion-location 'http://example.onion$request_uri';
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot

@ -406,6 +406,8 @@ module.exports = {
filters: [], //words/phrases to block
filterMode: 0, //0=nothing, 1=prevent post, 2=auto ban
filterBanDuration: 0, //duration (in ms) to ban if filter mode=2
deleteProtectionAge: 0, //prevent non-staff OP from deleting their thread if it older than this age in ms
deleteProtectionCount: 0, //prevent non-staff op deleting their thread if it has more than this many replies
strictFiltering: false,
announcement: {
raw: null,

@ -1,3 +1,4 @@
'use strict';
const changeBoardSettings = require(__dirname+'/../../models/forms/changeboardsettings.js')
@ -12,12 +13,12 @@ const changeBoardSettings = require(__dirname+'/../../models/forms/changeboardse
module.exports = {
paramConverter: paramConverter({
timeFields: ['ban_duration'],
timeFields: ['ban_duration', 'delete_protection_age'],
trimFields: ['filters', 'moderators', 'tags', 'announcement', 'description', 'name', 'custom_css'],
allowedArrays: ['countries'],
numberFields: ['lock_reset', 'captcha_reset', 'filter_mode', 'lock_mode', 'message_r9k_mode', 'file_r9k_mode', 'captcha_mode', 'tph_trigger', 'pph_trigger', 'pph_trigger_action',
'tph_trigger_action', 'bump_limit', 'reply_limit', 'max_files', 'thread_limit', 'max_thread_message_length', 'max_reply_message_length', 'min_thread_message_length',
'min_reply_message_length'],
'min_reply_message_length', 'delete_protection_count'],
}),
controller: async (req, res, next) => {
@ -68,6 +69,8 @@ module.exports = {
{ result: numberBody(req.body.lock_reset, 0, 2), expected: true, error: 'Invalid trigger reset lock' },
{ result: numberBody(req.body.captcha_reset, 0, 2), expected: true, error: 'Invalid trigger reset captcha' },
{ result: numberBody(req.body.ban_duration, 0), expected: true, error: 'Invalid filter auto ban duration' },
{ result: numberBody(req.body.delete_protection_age, 0), expected: true, error: 'Invalid OP thread age delete protection' },
{ result: numberBody(req.body.delete_protection_count, 0), expected: true, error: 'Invalid OP thread reply count delete protection' },
{ result: inArrayBody(req.body.theme, themes), expected: true, error: 'Invalid theme' },
{ result: inArrayBody(req.body.code_theme, codeThemes), expected: true, error: 'Invalid code theme' },
], res.locals.permLevel);

@ -11,7 +11,7 @@ const changeGlobalSettings = require(__dirname+'/../../models/forms/changeglobal
module.exports = {
paramConverter: paramConverter({
timeFields: ['ban_duration', 'board_defaults_filter_ban_duration', 'default_ban_duration', 'block_bypass_expire_after_time', 'dnsbl_cache_time'],
timeFields: ['ban_duration', 'board_defaults_filter_ban_duration', 'default_ban_duration', 'block_bypass_expire_after_time', 'dnsbl_cache_time', 'board_defaults_delete_protection_age'],
trimFields: ['allowed_hosts', 'dnsbl_blacklists', 'other_mime_types', 'highlight_options_language_subset', 'global_limits_custom_css_filters', 'board_defaults_filters', 'filters', 'archive_links', 'reverse_links'],
numberFields: ['filter_mode', 'auth_level',
'captcha_options_generate_limit', 'captcha_options_grid_size', 'captcha_options_image_size', 'captcha_options_num_distorts_min', 'captcha_options_num_distorts_max',
@ -30,7 +30,7 @@ module.exports = {
'board_defaults_lock_mode', 'board_defaults_file_r9k_mode', 'board_defaults_message_r9k_mode', 'board_defaults_captcha_mode', 'board_defaults_tph_trigger',
'board_defaults_pph_trigger', 'board_defaults_tph_trigger_action', 'board_defaults_pph_trigger_action', 'board_defaults_captcha_reset', 'board_defaults_lock_reset',
'board_defaults_thread_limit', 'board_defaults_reply_limit', 'board_defaults_bump_limit', 'board_defaults_max_files', 'board_defaults_min_thread_message_length',
'board_defaults_min_reply_message_length', 'board_defaults_max_thread_message_length', 'board_defaults_max_reply_message_length', 'board_defaults_filter_mode',
'board_defaults_min_reply_message_length', 'board_defaults_max_thread_message_length', 'board_defaults_max_reply_message_length', 'board_defaults_filter_mode', 'board_defaults_delete_protection_count',
'perm_levels_markdown_pink', 'perm_levels_markdown_green', 'perm_levels_markdown_bold', 'perm_levels_markdown_underline', 'perm_levels_markdown_strike',
'perm_levels_markdown_italic', 'perm_levels_markdown_title', 'perm_levels_markdown_spoiler', 'perm_levels_markdown_mono', 'perm_levels_markdown_code',
'perm_levels_markdown_link', 'perm_levels_markdown_detected', 'perm_levels_markdown_dice', 'perm_levels_markdown_fortune'], //damn, this has a lot of numbers lol
@ -194,6 +194,8 @@ module.exports = {
{ result: minmaxBody(req.body.board_defaults_min_reply_message_length, req.body.board_defaults_max_reply_message_length), expected: true, error: 'Board defaults reply message length min must be less than max' },
{ result: numberBody(req.body.board_defaults_filter_mode, 0, 2), expected: true, error: 'Board defaults filter mode must be a number from 0-2' },
{ result: numberBody(req.body.board_defaults_filter_ban_duration), expected: true, error: 'Board defaults filter ban duration must be a number' },
{ result: numberBody(req.body.board_defaults_delete_protection_age, 0), expected: true, error: 'Invalid board defaults OP thread age delete protection' },
{ result: numberBody(req.body.board_defaults_delete_protection_count, 0), expected: true, error: 'Invalid board defaults OP thread reply count delete protection' },
{ result: lengthBody(req.body.webring_following, 0, 10000), expected: false, error: 'Webring following list must not exceed 10000 characters' },
{ result: lengthBody(req.body.webring_blacklist, 0, 10000), expected: false, error: 'Webring blacklist must not exceed 10000 characters' },
{ result: lengthBody(req.body.webring_logos, 0, 10000), expected: false, error: 'Webring logos list must not exceed 10000 characters' },

@ -272,13 +272,13 @@ object {
padding: 5px;
margin: 5px;
text-align: center;
height: 220px;
width: 180px;
height: 340px;
width: 280px;
overflow: hidden;
border: 1px solid var(--post-outline-color);
box-sizing: border-box;
flex-grow: 1;
max-width: 250px;
max-width: 280px;
}
.catalog-tile:focus {
@ -298,10 +298,10 @@ p {
display: block;
box-shadow: 0 0 3px black;
width: auto;
max-height: 64px;
max-height: 100px;
box-sizing: border-box;
object-fit: cover;
margin: 0 auto 5px;
margin: 3px;
padding: 2px;
}
@ -310,6 +310,19 @@ p {
width: 64px;
}
.catalog-thumb.small {
max-height: 48px;
max-width: 48px;
}
.ct-r1 {
justify-content: center;
}
.ct-r2 {
justify-content: space-evenly;
}
.upload-list {
max-height: 75px;
overflow-x: hidden;
@ -1387,6 +1400,11 @@ row.wrap.sb .col {
@media only screen and (max-width: 600px) {
.ct-r2 .catalog-thumb.small {
max-width: 32px;
max-height: 32px;
}
.postmenu {
border-left: .5em solid transparent;
border-right: .5em solid transparent;

@ -15,7 +15,7 @@ const config = require(__dirname+'/config.js')
, realFavicon = require('gulp-real-favicon')
, del = require('del')
, pug = require('pug')
, gulppug = require('@fatchan/gulp-pug')
, gulppug = require('gulp-pug')
, { migrateVersion, version } = require(__dirname+'/package.json')
, { randomBytes } = require('crypto')
, Redis = require(__dirname+'/redis.js')
@ -515,6 +515,11 @@ module.exports = {
migrate: gulp.series(init, migrate, closeConnections),
password: gulp.series(init, password, closeConnections),
ips: gulp.series(init, ips, closeConnections),
rebuild: [deletehtml, css, scripts, custompages],
default: gulp.series(init, build, closeConnections),
buildTasks: { //dont include init, etc
deletehtml,
css,
scripts,
custompages,
}
};

@ -0,0 +1,36 @@
'use strict';
const { isDeepStrictEqual } = require('util')
function getDotProp(obj, prop) {
return prop
.split('.')
.reduce((a, b) => a[b], obj);
}
function includeChildren(template, prop, tasks) {
Object.keys(getDotProp(template, prop))
.reduce((a, x) => {
a[`${prop}.${x}`] = tasks;
return a;
}, {});
}
function compareSettings(entries, oldObject, newObject, maxSetSize) {
const resultSet = new Set();
entries.every(entry => {
const oldValue = getDotProp(oldObject, entry[0]);
const newValue = getDotProp(newObject, entry[0]);
if (!isDeepStrictEqual(oldValue, newValue)) {
entry[1].forEach(t => resultSet.add(t));
}
return resultSet.size < maxSetSize;
});
return resultSet;
}
module.exports = {
getDotProp,
includeChildren,
compareSettings,
}

@ -11,17 +11,17 @@ const Mongo = require(__dirname+'/../db/db.js')
, render = require(__dirname+'/render.js')
, buildQueue = require(__dirname+'/../queue.js')
, gulp = require('gulp')
, { rebuild } = require(__dirname+'/../gulpfile.js')
, { buildTasks } = require(__dirname+'/../gulpfile.js')
, timeDiffString = require(__dirname+'/timediffstring.js');
module.exports = {
gulp: async () => {
gulp: async (options) => {
/* TODO: calculate differences in oldsettings vsnewsettings in globalmanagesettings model
and send task options with list of tasks instead of always doing all */
const label = `gulp tasks [${rebuild.map(x => x.name).join(', ')}] after global config change`;
const label = `running gulp tasks [${options.tasks.join(', ')}] after global config change`;
const start = process.hrtime();
gulp.series(rebuild, () => {
gulp.series(options.tasks.map(t => buildTasks[t]), () => {
const end = process.hrtime(start);
debugLogs && console.log(timeDiffString(label, end));
})();

@ -0,0 +1,22 @@
'use strict';
module.exports = async(db, redis) => {
console.log('Adding OP delete protection options to board settings');
await db.collection('globalsettings').updateOne({ _id: 'globalsettings' }, {
'$set': {
'boardDefaults.deleteProtectionAge': 0,
'boardDefaults.deleteProtectionCount': 0,
},
});
console.log('Clearing globalsettings cache');
await redis.deletePattern('globalsettings');
await db.collection('boards').updateMany({}, {
'$set': {
'settings.deleteProtectionAge': 0,
'settings.deleteProtectionCount': 0,
}
});
console.log('Clearing boards cache');
await redis.deletePattern('board:*');
};

@ -56,7 +56,7 @@ module.exports = async (req, res, next) => {
redirect,
});
}
res.locals.posts = passwordPosts
res.locals.posts = passwordPosts;
}
//affected boards, list and page numbers
@ -96,6 +96,26 @@ module.exports = async (req, res, next) => {
messages.push(message);
}
if (deleting) {
if (res.locals.permLevel >= 4) {
//delete protection. this could only be single board actions obvously with permLevel >=4
const { deleteProtectionAge, deleteProtectionCount } = res.locals.board.settings;
if (deleteProtectionAge > 0 || deleteProtectionCount > 0) {
const protectedThread = res.locals.posts.some(p => {
return p.thread === null //is a thread
&& ((deleteProtectionCount > 0 && p.replyposts > deleteProtectionCount) //and it has more replies than the protection count
|| (deleteProtectionAge > 0 && new Date() > new Date(p.date.getTime() + deleteProtectionAge))); //or was created too long ato
});
if (protectedThread === true) {
//alternatively, the above .some() could become a filter like some other options and silently not delete,
//but i think in this case it would be important to notify the user that their own thread(s) cant be deleted yet
return dynamicResponse(req, res, 403, 'message', {
'title': 'Forbidden',
'error': 'You cannot delete old threads or threads with too many replies',
redirect,
});
}
}
}
const postsBefore = res.locals.posts.length;
if (req.body.delete_ip_board || req.body.delete_ip_global || req.body.delete_ip_thread) {
const deletePostIps = res.locals.posts.map(x => x.ip.single);

@ -1,73 +0,0 @@
'use strict';
const { Bans, Modlogs } = require(__dirname+'/../../db/')
, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js')
, hashIp = require(__dirname+'/../../helpers/dynamic.js')
, buildQueue = require(__dirname+'/../../queue.js')
, { isIP } = require('net')
, config = require(__dirname+'/../../config.js');
module.exports = async (req, res, redirect) => {
const { defaultBanDuration } = config.get;
const actionDate = new Date();
const banPromise = Bans.insertOne({
//note: raw ip and type single because of
'type': 'single',
'ip': {
'single': isIP(req.body.ip) ? hashIp(req.body.ip) : req.body.ip,
'raw': req.body.ip,
},
'reason': req.body.ban_reason || req.body.log_message || 'No reason specified',
'board': req.params.board || null,
'posts': null,
'issuer': req.session.user,
'showUser': !req.body.hide_name,
'date': actionDate,
'expireAt': new Date(actionDate.getTime() + (req.body.ban_duration || defaultBanDuration)),
'allowAppeal': req.body.no_appeal ? false : true,
'appeal': null,
'seen': false,
});
const modlogPromise = Modlogs.insertOne({
'board': req.params.board || null,
'showLinks': false,
'postLinks': [],
'actions': [(req.params.board ? 'Ban' : 'Global Ban')],
'date': actionDate,
'showUser': !req.body.hide_name || res.locals.permLevel >= 4 ? true : false,
'message': req.body.log_message || null,
'user': res.locals.permLevel < 4 ? req.session.user : 'Unregistered User',
'ip': {
'single': res.locals.ip.single,
'raw': res.locals.ip.raw
}
});
await Promise.all([banPromise, modlogPromise]);
if (req.params.board) {
buildQueue.push({
'task': 'buildModLog',
'options': {
'board': res.locals.board,
}
});
buildQueue.push({
'task': 'buildModLogList',
'options': {
'board': res.locals.board,
}
});
}
return dynamicResponse(req, res, 200, 'message', {
'title': 'Success',
'message': 'Added ban',
redirect,
});
}

@ -11,7 +11,22 @@ const { Boards, Posts, Accounts } = require(__dirname+'/../../db/')
, messageHandler = require(__dirname+'/../../helpers/posting/message.js')
, { countryCodes } = require(__dirname+'/../../helpers/countries.js')
, { trimSetting, numberSetting, booleanSetting, arraySetting } = require(__dirname+'/../../helpers/setting.js')
, validCountryCodes = new Set(countryCodes);
, { compareSettings } = require(__dirname+'/../../helpers/settingsdiff.js')
, validCountryCodes = new Set(countryCodes)
, settingChangeEntries = Object.entries({
'userPostDelete': ['board', 'catalog', 'threads'],
'userPostSpoiler': ['board', 'catalog', 'threads'],
'userPostUnlink': ['board', 'catalog', 'threads'],
'replyLimit': ['board', 'threads'],
'archiveLinks': ['board', 'threads'],
'reverseImageSearchLinks': ['board', 'threads'],
'name': ['board', 'threads', 'catalog', 'other'],
'description': ['board', 'threads', 'catalog', 'other'],
'theme': ['board', 'threads', 'catalog', 'other'],
'codetheme': ['board', 'threads', 'catalog', 'other'],
'announcement.raw': ['board', 'threads', 'catalog', 'other'],
'customCss': ['board', 'threads', 'catalog', 'other'],
});
module.exports = async (req, res, next) => {
@ -107,6 +122,8 @@ module.exports = async (req, res, next) => {
'fileR9KMode': numberSetting(req.body.file_r9k_mode, oldSettings.fileR9KMode),
'filterMode': numberSetting(req.body.filter_mode, oldSettings.filterMode),
'filterBanDuration': numberSetting(req.body.ban_duration, oldSettings.filterBanDuration),
'deleteProtectionAge': numberSetting(req.body.delete_protection_age, oldSettings.deleteProtectionAge),
'deleteProtectionCount': numberSetting(req.body.delete_protection_count, oldSettings.deleteProtectionCount),
'filters': arraySetting(req.body.filters, oldSettings.filters, 50),
'blockedCountries': req.body.countries || [],
'disableAnonymizerFilePosting': booleanSetting(req.body.disable_anonymizer_file_posting),
@ -140,77 +157,49 @@ module.exports = async (req, res, next) => {
const oldMaxPage = Math.ceil(oldSettings.threadLimit/10);
const newMaxPage = Math.ceil(newSettings.threadLimit/10);
let rebuildThreads = false
, rebuildBoard = false
, rebuildCatalog = false
, rebuildOther = false;
if (newSettings.userPostDelete !== oldSettings.userPostDelete
|| newSettings.userPostSpoiler !== oldSettings.userPostSpoiler
|| newSettings.userPostUnlink !== oldSettings.userPostUnlink) {
rebuildThreads = true;
rebuildBoard = true;
rebuildCatalog = true;
}
if (newSettings.replyLimit !== oldSettings.replyLimit
|| newSettings.archiveLinks !== oldSettings.archiveLinks
|| newSettings.reverseImageSearchLinks !== oldSettings.reverseImageSearchLinks) {
rebuildBoard = true;
rebuildThreads = true;
}
const rebuildTasks = compareSettings(settingChangeEntries, oldSettings, newSettings, 4);
if (newSettings.captchaMode > oldSettings.captchaMode) {
if (oldSettings.captchaMode === 0) {
rebuildBoard = true;
rebuildCatalog = true;
}
if (newSettings.captchaMode === 2) {
rebuildThreads = true;
}
} else if (newSettings.captchaMode < oldSettings.captchaMode) {
if (oldSettings.captchaMode === 2) {
rebuildThreads = true;
}
if (newSettings.captchaMode === 0) {
rebuildBoard = true;
rebuildCatalog = true;
if (rebuildTasks.size < 4) {
//after that is stuff not direct equality comparisons calcluated by compareSettings()
if (newSettings.captchaMode > oldSettings.captchaMode) {
if (oldSettings.captchaMode === 0) {
rebuildTasks.add('board')
.add('catalog');
}
if (newSettings.captchaMode === 2) {
rebuildTasks.add('threads');
}
} else if (newSettings.captchaMode < oldSettings.captchaMode) {
if (oldSettings.captchaMode === 2) {
rebuildTasks.add('threads');
}
if (newSettings.captchaMode === 0) {
rebuildTasks.add('board')
.add('catalog')
}
}
}
//do rebuilding and pruning if max number of pages is changed and any threads are pruned
if (newMaxPage < oldMaxPage) {
//prune old threads
const prunedThreads = await Posts.pruneThreads(res.locals.board);
if (prunedThreads.length > 0) {
await deletePosts(prunedThreads, req.params.board);
//remove board page html/json for pages > newMaxPage
for (let i = newMaxPage+1; i <= oldMaxPage; i++) {
promises.push(remove(`${uploadDirectory}/html/${req.params.board}/${i}.html`));
promises.push(remove(`${uploadDirectory}/json/${req.params.board}/${i}.json`));
//do rebuilding and pruning if max number of pages is changed and any threads are pruned
if (newMaxPage < oldMaxPage) {
//prune old threads
const prunedThreads = await Posts.pruneThreads(res.locals.board);
if (prunedThreads.length > 0) {
await deletePosts(prunedThreads, req.params.board);
//remove board page html/json for pages > newMaxPage
for (let i = newMaxPage+1; i <= oldMaxPage; i++) {
promises.push(remove(`${uploadDirectory}/html/${req.params.board}/${i}.html`));
promises.push(remove(`${uploadDirectory}/json/${req.params.board}/${i}.json`));
}
//rebuild all board pages for page nav numbers, and catalog
rebuildTasks.add('board')
.add('catalog');
}
//rebuild all board pages for page nav numbers, and catalog
rebuildBoard = true;
rebuildCatalog = true;
}
}
if (newSettings.name !== oldSettings.name
|| newSettings.description !== oldSettings.description
|| newSettings.theme !== oldSettings.theme
|| newSettings.codeTheme !== oldSettings.codeTheme
|| newSettings.announcement.raw !== oldSettings.announcement.raw
|| newSettings.customCss !== oldSettings.customCss) {
rebuildThreads = true;
rebuildBoard = true;
rebuildCatalog = true;
rebuildOther = true;
}
if (rebuildThreads) {
if (rebuildTasks.has('threads')) {
promises.push(remove(`${uploadDirectory}/html/${req.params.board}/thread/`));
}
if (rebuildBoard) {
if (rebuildTasks.has('board')) {
buildQueue.push({
'task': 'buildBoardMultiple',
'options': {
@ -220,7 +209,7 @@ module.exports = async (req, res, next) => {
}
});
}
if (rebuildCatalog) {
if (rebuildTasks.has('catalog')) {
buildQueue.push({
'task': 'buildCatalog',
'options': {
@ -228,7 +217,7 @@ module.exports = async (req, res, next) => {
}
});
}
if (rebuildOther) {
if (rebuildTasks.has('other')) {
promises.push(remove(`${uploadDirectory}/html/${req.params.board}/logs/`));
promises.push(remove(`${uploadDirectory}/html/${req.params.board}/custompage/`));
buildQueue.push({

@ -11,7 +11,32 @@ const { Boards, Posts, Accounts } = require(__dirname+'/../../db/')
, { prepareMarkdown } = require(__dirname+'/../../helpers/posting/markdown.js')
, messageHandler = require(__dirname+'/../../helpers/posting/message.js')
, { trimSetting, numberSetting, booleanSetting, arraySetting } = require(__dirname+'/../../helpers/setting.js')
, { remove } = require('fs-extra');
, { includeChildren, compareSettings } = require(__dirname+'/../../helpers/settingsdiff.js')
, { remove } = require('fs-extra')
, template = require(__dirname+'/../../configs/template.js.example')
, settingChangeEntries = Object.entries({
//doesnt seem like it would be much different transforming this to be tasks: [settings] or this way, so this way it is
'globalAnnouncement.raw': ['deletehtml'],
'meta.siteName': ['deletehtml', 'scripts', 'custompages'],
'meta.url': ['deletehtml', 'scripts', 'custompages'],
'captchaOptions.type': ['deletehtml', 'css', 'scripts'],
'archiveLinksURL': ['deletehtml'],
'reverseImageLinksURL': ['deletehtml'],
'enableWebring': ['deletehtml'],
'thumbSize': ['deletehtml', 'css', 'scripts'],
'previewReplies': ['deletehtml'],
'stickyPreviewReplies': ['deletehtml'],
'maxRecentNews': ['deletehtml'],
'themes': ['scripts'],
'codeThemes': ['scripts'],
'globalLimits.postFiles.max': ['deletehtml'],
'globalLimits.postFilesSize.max': ['deletehtml'],
//these will make it easier to keep updated and include objects where any/all property change needs tasks
//basically, it expands to all of globalLimits.fieldLength.* or frontendScriptDefault.*
//it could be calculated in compareSettings with *, but im just precompiling it now. probably a tiny bit faster not doing it each time
...includeChildren(template, 'globalLimits.fieldLength', ['deletehtml']),
...includeChildren(template, 'frontendScriptDefault', ['scripts']),
});
module.exports = async (req, res, next) => {
@ -275,6 +300,8 @@ module.exports = async (req, res, next) => {
disableAnonymizerFilePosting: booleanSetting(req.body.board_defaults_disable_anonymizer_file_posting, oldSettings.boardDefaults.disableAnonymizerFilePosting),
filterMode: numberSetting(req.body.board_defaults_filter_mode, oldSettings.boardDefaults.filterMode),
filterBanDuration: numberSetting(req.body.board_defaults_filter_ban_duration, oldSettings.boardDefaults.filterBanDuration),
deleteProtectionAge: numberSetting(req.body.board_defaults_delete_protection_age, oldSettings.boardDefaults.deleteProtectionAge),
deleteProtectionCount: numberSetting(req.body.board_defaults_delete_protection_count, oldSettings.boardDefaults.deleteProtectionCount),
strictFiltering: booleanSetting(req.body.board_defaults_strict_filtering, oldSettings.boardDefaults.strictFiltering),
customCSS: null,
blockedCountries: [],
@ -308,9 +335,17 @@ module.exports = async (req, res, next) => {
//publish to redis so running processes get updated config
redis.redisPublisher.publish('config', JSON.stringify(newSettings));
buildQueue.push({
'task': 'gulp'
});
//relevant tasks: deletehtml, css, scripts, custompages
const gulpTasks = compareSettings(settingChangeEntries, oldSettings, newSettings, 4);
if (gulpTasks.size > 0) {
buildQueue.push({
'task': 'gulp',
'options': {
'tasks': [...gulpTasks],
}
});
}
return dynamicResponse(req, res, 200, 'message', {
'title': 'Success',

18977
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,15 +1,14 @@
{
"name": "jschan",
"version": "0.1.7",
"migrateVersion": "0.1.6",
"version": "0.1.8",
"migrateVersion": "0.1.8",
"description": "",
"main": "server.js",
"dependencies": {
"@fatchan/gulp-pug": "^4.0.1",
"bcrypt": "^5.0.1",
"bull": "^3.27.0",
"bull": "^3.29.3",
"cache-pug-templates": "^2.0.3",
"connect-redis": "^5.2.0",
"connect-redis": "^6.0.0",
"cookie-parser": "^1.4.5",
"csurf": "^1.11.0",
"del": "^6.0.0",
@ -17,37 +16,39 @@
"express": "^4.17.1",
"express-fileupload": "git+https://gitgud.io/fatchan/express-fileupload.git#b2c0d9c0868fed3b4bbd5c0318cb162ee81146b4",
"express-session": "^1.17.2",
"file-type": "^15.0.1",
"file-type": "^16.5.3",
"fluent-ffmpeg": "^2.1.2",
"form-data": "^4.0.0",
"fs": "0.0.1-security",
"fs-extra": "^9.1.0",
"fs-extra": "^10.0.0",
"gm": "git+https://gitgud.io/fatchan/gm.git",
"gulp": "^4.0.2",
"gulp-clean-css": "^4.3.0",
"gulp-concat": "^2.6.1",
"gulp-less": "^4.0.1",
"gulp-less": "^5.0.0",
"gulp-pug": "^5.0.0",
"gulp-real-favicon": "^0.3.2",
"gulp-replace": "^1.1.3",
"gulp-uglify-es": "^2.0.0",
"highlight.js": "^10.7.3",
"gulp-uglify-es": "^3.0.0",
"highlight.js": "^11.2.0",
"i18n-iso-countries": "^6.8.0",
"iconv-lite": "^0.6.3",
"imghash": "0.0.8",
"ioredis": "^4.27.6",
"imghash": "0.0.9",
"ioredis": "^4.28.0",
"ip6addr": "^0.2.3",
"mongodb": "^4.0.1",
"node-fetch": "^2.6.1",
"node-fetch": "^2.6.5",
"node-image-hash": "^1.1.0",
"path": "^0.12.7",
"pm2": "^5.1.0",
"pug": "^3.0.2",
"redlock": "^4.1.0",
"sanitize-html": "^2.4.0",
"sanitize-html": "^2.5.2",
"saslprep": "^1.0.3",
"semver": "^7.3.5",
"socket.io": "^4.1.3",
"socket.io": "^4.3.0",
"socket.io-redis": "^6.1.1",
"socks-proxy-agent": "^5.0.1",
"socks-proxy-agent": "^6.1.0",
"unix-crypt-td-js": "^1.1.4"
},
"scripts": {

@ -14,6 +14,14 @@ unless minimal
a(href='/faq.html#cripto') Doar
| •
div
<<<<<<< HEAD
p Todas as marcas registadas, direitos de autor, comentários e ficheiros neste site são propriedade e responsabilidade dos seus respectivos autores e proprietários. Só um louco levaria o que aqui é escrito a sério.
script(src=`/js/render.js?v=${commit}`)
script(src=`/js/render.js?v=${commit}`)
=======
a(href='https://gitgud.io/fatchan/jschan/') jschan
| #{version}
//- "render" script - for scripted things that are blocking
script(src=`/js/render.js?v=${commit}`)
>>>>>>> upstream/master

@ -1,23 +1,40 @@
meta(charset='utf-8')
meta(name='viewport' content='width=device-width initial-scale=1')
//- very basic styles to hide some elements and improve experience for users with noscript
noscript
style .jsonly { display: none!important; } .user-id { cursor: auto!important; }
//- whether this page is rendered for a board
- const isBoard = board != null;
//- general meta and opengraph meta tags
if isBoard
if board.settings.description
meta(name='description' content=board.settings.description)
if board.settings.tags
meta(name='keywords' content=board.settings.tags.join(','))
noscript
style .jsonly { display: none!important; } .user-id { cursor: auto!important; }
meta(property='og:site_name', value=meta.siteName)
meta(property='og:url', content=meta.url)
//- main stylesheet
link(rel='stylesheet' href=`/css/style.css?v=${commit}&ct=${captchaType}`)
//- theme stylesheets
- const theme = isBoard ? board.settings.theme : defaultTheme;
- const codeTheme = isBoard ? board.settings.codeTheme : defaultCodeTheme;
link#theme(rel='stylesheet' data-theme=theme href=`/css/themes/${theme}.css`)
- const codeTheme = isBoard ? board.settings.codeTheme : defaultCodeTheme;
link#codetheme(rel='stylesheet' data-theme=codeTheme href=`/css/codethemes/${codeTheme}.css`)
if isBoard && board.settings.customCss
style#board-customcss #{board.settings.customCss}
link#codetheme(rel='stylesheet' data-theme=codeTheme href=`/css/codethemes/${codeTheme}.css`)
//- include html_code from gulp-favicon
include ../../gulp/res/icons/html_code.html
//- main script
script(src=`/js/all.js?v=${commit}&ct=${captchaType}`)
//- additional scripts included only if hcaptcha or recaptcha is used
if captchaType === 'google'
script(src='https://www.google.com/recaptcha/api.js' async defer)
if captchaType === 'hcaptcha'

@ -0,0 +1,14 @@
mixin catalogfile(postURL, post, file, small=false)
- const type = file.mimetype.split('/')[0]
.post-file-src
a(href=`${postURL}#${post.postId}`)
if post.spoiler || file.spoiler
div.spoilerimg.catalog-thumb(class=(small?'small':''))
else if file.hasThumb
img.catalog-thumb(class=(small?'small':'') src=`/file/thumb/${file.hash}${file.thumbextension}` width=file.geometry.thumbwidth height=file.geometry.thumbheight loading='lazy')
else if file.attachment
div.attachmentimg.catalog-thumb(class=(small?'small':'') data-mimetype=file.mimetype)
else if type === 'audio'
div.audioimg.catalog-thumb(class=(small?'small':''))
else
img.catalog-thumb(class=(small?'small':'') src=`/file/${file.filename}` width=file.geometry.width height=file.geometry.height loading='lazy')

@ -1,3 +1,4 @@
include ./catalogfile.pug
mixin catalogtile(post, index, overboard=false)
- let anchorSubject = post.subject;
if post.subject
@ -27,6 +28,14 @@ mixin catalogtile(post, index, overboard=false)
if modview
a.left.ml-5.bold(href=`recent.html?postid=${post.postId}`) [+]
include ../includes/posticons.pug
if post.country && post.country.code
if post.country.custom === true
span(title=post.country.name)
img.customflag(src=`/flag/${post.board}/${post.country.src}` alt=' ' loading='lazy')
|
else
span(class=`flag flag-${post.country.code.toLowerCase()}` title=post.country.name alt=post.country.name)
|
a.no-decoration.post-subject(href=postURL) #{post.subject || 'No subject'}
br
span(title='Replies') R: #{post.replyposts}
@ -36,18 +45,13 @@ mixin catalogtile(post, index, overboard=false)
| /
span(title='Page') P: #{Math.ceil(index/10)}
if post.files.length > 0
.post-file-src
a(href=`${postURL}#${post.postId}`)
.col
.row.ct-r1
- const file = post.files[0]
if post.spoiler || file.spoiler
div.spoilerimg.catalog-thumb
else if file.hasThumb
img.catalog-thumb(src=`/file/thumb/${file.hash}${file.thumbextension}` width=file.geometry.thumbwidth height=file.geometry.thumbheight loading='lazy')
else if file.attachment
div.attachmentimg.catalog-thumb(data-mimetype=file.mimetype)
else if file.mimetype.startsWith('audio')
div.audioimg.catalog-thumb
else
img.catalog-thumb(src=`/file/${file.filename}` width=file.geometry.width height=file.geometry.height loading='lazy')
+catalogfile(postURL, post, file, false)
if post.files.length > 1
.row.ct-r2.wrap
each file, fileindex in post.files.slice(1)
+catalogfile(postURL, post, file, true)
if post.message
pre.no-m-p.post-message !{post.message}

@ -125,12 +125,12 @@ mixin modal(data)
.label Video/Audio volume
label.postform-style.ph-5
input#volume-setting(type='range' min='0' max='100')
.row
.label Post password
input#postpassword-setting(type='password' name='postpassword' autocomplete='new-password')
.row
.label Default name
input#name-setting(type='text' name='name')
.row
.label Post password
input#postpassword-setting(type='password' name='postpassword' autocomplete='new-password')
.row
.label Theme
select#theme-setting

@ -6,7 +6,7 @@ mixin newspost(post, globalmanage=false)
th
if globalmanage === true
input.left.post-check(type='checkbox', name='checkednews' value=post._id)
a.left(href=`#${post._id}`) #{post.title}
a.left(href=`/news.html#${post._id}`) #{post.title}
if globalmanage === true
a.right.ml-5(href=`/globalmanage/editnews/${post._id}.html`) [Edit]
- const newsDate = new Date(post.date);

@ -86,10 +86,10 @@ mixin post(post, truncate, manage=false, globalmanage=false, ban=false, overboar
if globalmanage && file.phash != null
span #{file.phash}
br
if file.hasThumb && !(post.spoiler || file.spoiler)
if !file.attachment && !(post.spoiler || file.spoiler)
span.jsonly
b [
a.dummy-link.hide-image.noselect(data-src=`/file/thumb/${file.hash}${file.thumbextension}`) Hide
a.dummy-link.hide-image.noselect(data-src=`/file/${file.hasThumb ? 'thumb/'+file.hash+file.thumbextension : file.filename}`) Hide
b ]
span
| (#{file.sizeString}

@ -756,6 +756,12 @@ block content
.row
.label Max Reply Message Length
input(type='number' name='board_defaults_max_reply_message_length' value=settings.boardDefaults.maxReplyMessageLength)
.row
.label OP Reply Count Delete Protection
input(type='number' name='board_defaults_delete_protection_count' value=settings.boardDefaults.deleteProtectionCount)
.row
.label OP Thread Age Delete Protection
input(type='text' name='board_defaults_delete_protection_age' placeholder='e.g. 1w' value=settings.boardDefaults.deleteProtectionAge)
.row
.label Disable anonymizer file posting
label.postform-style.ph-5

@ -212,6 +212,12 @@ block content
option(value='0', selected=board.settings.messageR9KMode === 0) Off
option(value='1', selected=board.settings.messageR9KMode === 1) Per Thread
option(value='2', selected=board.settings.messageR9KMode === 2) Board Wide
.row
.label OP Reply Count Delete Protection
input(type='number' name='delete_protection_count' value=board.settings.deleteProtectionCount)
.row
.label OP Thread Age Delete Protection
input(type='text' name='delete_protection_age' placeholder='e.g. 1w' value=board.settings.deleteProtectionAge)
.col.w900
.row
h4.mv-5 Antispam

@ -6,7 +6,6 @@ include ../mixins/announcements.pug
block head
title Overboard Catalog
block content
.board-header.mb-5
h1.board-title Overboard Catalog

@ -9,9 +9,7 @@ block head
- const subjectString = thread.subject || (thread.nomarkup ? `${thread.nomarkup.substring(0, globalLimits.fieldLength.subject)}${thread.nomarkup.length > globalLimits.fieldLength.subject ? '...' : ''}` : thread.postId);
title /#{board._id}/ - #{subjectString}
if !modview
meta(property='og:site_name', value=meta.siteName)
meta(property='og:title', content=thread.subject)
meta(property='og:url', content=meta.url)
meta(property='og:description', content=thread.nomarkup)
if thread.files.length > 0
if thread.spoiler || thread.files[0].spoiler

Loading…
Cancel
Save